001/*
002 * Minify Maven Plugin
003 * https://github.com/samaxes/minify-maven-plugin
004 *
005 * Copyright (c) 2009 samaxes.com
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 */
019package com.samaxes.maven.minify.plugin;
020
021import com.samaxes.maven.minify.common.SourceFilesEnumeration;
022import com.samaxes.maven.minify.common.YuiConfig;
023import com.samaxes.maven.minify.plugin.MinifyMojo.Engine;
024import org.apache.maven.plugin.logging.Log;
025import org.codehaus.plexus.util.DirectoryScanner;
026import org.codehaus.plexus.util.FileUtils;
027import org.codehaus.plexus.util.IOUtil;
028
029import java.io.*;
030import java.nio.charset.Charset;
031import java.util.ArrayList;
032import java.util.Collections;
033import java.util.Comparator;
034import java.util.List;
035import java.util.concurrent.Callable;
036import java.util.zip.GZIPOutputStream;
037
038/**
039 * Abstract class for merging and compressing a files list.
040 */
041public abstract class ProcessFilesTask implements Callable<Object> {
042
043    public static final String TEMP_SUFFIX = ".tmp";
044
045    protected final Log log;
046
047    protected final boolean verbose;
048
049    protected final Integer bufferSize;
050
051    protected final Charset charset;
052
053    protected final String suffix;
054
055    protected final boolean nosuffix;
056
057    protected final boolean skipMerge;
058
059    protected final boolean skipMinify;
060
061    protected final Engine engine;
062
063    protected final YuiConfig yuiConfig;
064
065    private final File sourceDir;
066
067    private final File targetDir;
068
069    private final String mergedFilename;
070
071    private final List<File> files = new ArrayList<>();
072
073    private final boolean sourceFilesEmpty;
074
075    private final boolean sourceIncludesEmpty;
076
077    /**
078     * Task constructor.
079     *
080     * @param log             Maven plugin log
081     * @param verbose         display additional info
082     * @param bufferSize      size of the buffer used to read source files
083     * @param charset         if a character set is specified, a byte-to-char variant allows the encoding to be selected.
084     *                        Otherwise, only byte-to-byte operations are used
085     * @param suffix          final file name suffix
086     * @param nosuffix        whether to use a suffix for the minified file name or not
087     * @param skipMerge       whether to skip the merge step or not
088     * @param skipMinify      whether to skip the minify step or not
089     * @param webappSourceDir web resources source directory
090     * @param webappTargetDir web resources target directory
091     * @param inputDir        directory containing source files
092     * @param sourceFiles     list of source files to include
093     * @param sourceIncludes  list of source files to include
094     * @param sourceExcludes  list of source files to exclude
095     * @param outputDir       directory to write the final file
096     * @param outputFilename  the output file name
097     * @param engine          minify processor engine selected
098     * @param yuiConfig       YUI Compressor configuration
099     * @throws FileNotFoundException when the given source file does not exist
100     */
101    public ProcessFilesTask(Log log, boolean verbose, Integer bufferSize, Charset charset, String suffix,
102                            boolean nosuffix, boolean skipMerge, boolean skipMinify, String webappSourceDir,
103                            String webappTargetDir, String inputDir, List<String> sourceFiles,
104                            List<String> sourceIncludes, List<String> sourceExcludes, String outputDir,
105                            String outputFilename, Engine engine, YuiConfig yuiConfig) throws FileNotFoundException {
106        this.log = log;
107        this.verbose = verbose;
108        this.bufferSize = bufferSize;
109        this.charset = charset;
110        this.suffix = suffix;
111        this.nosuffix = nosuffix;
112        this.skipMerge = skipMerge;
113        this.skipMinify = skipMinify;
114        this.engine = engine;
115        this.yuiConfig = yuiConfig;
116
117        this.sourceDir = new File(webappSourceDir + File.separator + inputDir);
118        this.targetDir = new File(webappTargetDir + File.separator + outputDir);
119        this.mergedFilename = outputFilename;
120        for (String sourceFilename : sourceFiles) {
121            addNewSourceFile(mergedFilename, sourceFilename);
122        }
123        for (File sourceInclude : getFilesToInclude(sourceIncludes, sourceExcludes)) {
124            if (!files.contains(sourceInclude)) {
125                addNewSourceFile(mergedFilename, sourceInclude);
126            }
127        }
128        this.sourceFilesEmpty = sourceFiles.isEmpty();
129        this.sourceIncludesEmpty = sourceIncludes.isEmpty();
130    }
131
132    /**
133     * Method executed by the thread.
134     *
135     * @throws IOException when the merge or minify steps fail
136     */
137    @Override
138    public Object call() throws IOException {
139        synchronized (log) {
140            String fileType = (this instanceof ProcessCSSFilesTask) ? "CSS" : "JavaScript";
141            log.info("Starting " + fileType + " task:");
142
143            if (!targetDir.exists() && !targetDir.mkdirs()) {
144                throw new RuntimeException("Unable to create target directory for: " + targetDir);
145            }
146
147            if (!files.isEmpty()) {
148                if (skipMerge) {
149                    log.info("Skipping the merge step...");
150                    String sourceBasePath = sourceDir.getAbsolutePath();
151
152                    for (File mergedFile : files) {
153                        // Create folders to preserve sub-directory structure when only minifying
154                        String originalPath = mergedFile.getAbsolutePath();
155                        String subPath = originalPath.substring(sourceBasePath.length(),
156                                originalPath.lastIndexOf(File.separator));
157                        File targetPath = new File(targetDir.getAbsolutePath() + subPath);
158                        if (!targetPath.exists() && !targetPath.mkdirs()) {
159                            throw new RuntimeException("Unable to create target directory for: " + targetPath);
160                        }
161
162                        File minifiedFile = new File(targetPath, (nosuffix) ? mergedFile.getName()
163                                : FileUtils.removeExtension(mergedFile.getName()) + suffix + "." + FileUtils.extension(mergedFile.getName()));
164                        minify(mergedFile, minifiedFile);
165                    }
166                } else if (skipMinify) {
167                    File mergedFile = new File(targetDir, mergedFilename);
168                    merge(mergedFile);
169                    log.info("Skipping the minify step...");
170                } else {
171                    File mergedFile = new File(targetDir, (nosuffix) ? mergedFilename + TEMP_SUFFIX : mergedFilename);
172                    merge(mergedFile);
173                    File minifiedFile = new File(targetDir, (nosuffix) ? mergedFilename
174                            : FileUtils.removeExtension(mergedFilename) + suffix + "." + FileUtils.extension(mergedFilename));
175                    minify(mergedFile, minifiedFile);
176                    if (nosuffix) {
177                        if (!mergedFile.delete()) {
178                            mergedFile.deleteOnExit();
179                        }
180                    }
181                }
182                log.info("");
183            } else if (!sourceFilesEmpty || !sourceIncludesEmpty) {
184                // 'files' list will be empty if source file paths or names added to the project's POM are invalid.
185                log.error("No valid " + fileType + " source files found to process.");
186            }
187        }
188
189        return null;
190    }
191
192    /**
193     * Merges a list of source files. Create missing parent directories if needed.
194     *
195     * @param mergedFile output file resulting from the merged step
196     * @throws IOException when the merge step fails
197     */
198    protected void merge(File mergedFile) throws IOException {
199        if (!mergedFile.getParentFile().exists() && !mergedFile.getParentFile().mkdirs()) {
200            throw new RuntimeException("Unable to create target directory for: " + mergedFile.getParentFile());
201        }
202
203        try (InputStream sequence = new SequenceInputStream(new SourceFilesEnumeration(log, files, verbose));
204             OutputStream out = new FileOutputStream(mergedFile);
205             InputStreamReader sequenceReader = new InputStreamReader(sequence, charset);
206             OutputStreamWriter outWriter = new OutputStreamWriter(out, charset)) {
207            log.info("Creating the merged file [" + (verbose ? mergedFile.getPath() : mergedFile.getName()) + "].");
208
209            IOUtil.copy(sequenceReader, outWriter, bufferSize);
210        } catch (IOException e) {
211            log.error("Failed to concatenate files.", e);
212            throw e;
213        }
214    }
215
216    /**
217     * Minifies a source file. Create missing parent directories if needed.
218     *
219     * @param mergedFile   input file resulting from the merged step
220     * @param minifiedFile output file resulting from the minify step
221     * @throws IOException when the minify step fails
222     */
223    abstract void minify(File mergedFile, File minifiedFile) throws IOException;
224
225    /**
226     * Logs compression gains.
227     *
228     * @param mergedFile   input file resulting from the merged step
229     * @param minifiedFile output file resulting from the minify step
230     */
231    void logCompressionGains(File mergedFile, File minifiedFile) {
232        try {
233            File temp = File.createTempFile(minifiedFile.getName(), ".gz");
234
235            try (InputStream in = new FileInputStream(minifiedFile);
236                 OutputStream out = new FileOutputStream(temp);
237                 GZIPOutputStream outGZIP = new GZIPOutputStream(out)) {
238                IOUtil.copy(in, outGZIP, bufferSize);
239            }
240
241            log.info("Uncompressed size: " + mergedFile.length() + " bytes.");
242            log.info("Compressed size: " + minifiedFile.length() + " bytes minified (" + temp.length()
243                    + " bytes gzipped).");
244
245            temp.deleteOnExit();
246        } catch (IOException e) {
247            log.debug("Failed to calculate the gzipped file size.", e);
248        }
249    }
250
251    /**
252     * Logs an addition of a new source file.
253     *
254     * @param finalFilename  the final file name
255     * @param sourceFilename the source file name
256     * @throws FileNotFoundException when the given source file does not exist
257     */
258    private void addNewSourceFile(String finalFilename, String sourceFilename) throws FileNotFoundException {
259        File sourceFile = new File(sourceDir, sourceFilename);
260
261        addNewSourceFile(finalFilename, sourceFile);
262    }
263
264    /**
265     * Logs an addition of a new source file.
266     *
267     * @param finalFilename the final file name
268     * @param sourceFile    the source file
269     * @throws FileNotFoundException when the given source file does not exist
270     */
271    private void addNewSourceFile(String finalFilename, File sourceFile) throws FileNotFoundException {
272        if (sourceFile.exists()) {
273            if (finalFilename.equalsIgnoreCase(sourceFile.getName())) {
274                log.warn("The source file [" + (verbose ? sourceFile.getPath() : sourceFile.getName())
275                        + "] has the same name as the final file.");
276            }
277            log.debug("Adding source file [" + (verbose ? sourceFile.getPath() : sourceFile.getName()) + "].");
278            files.add(sourceFile);
279        } else {
280            throw new FileNotFoundException("The source file ["
281                    + (verbose ? sourceFile.getPath() : sourceFile.getName()) + "] does not exist.");
282        }
283    }
284
285    /**
286     * Returns the files to copy. Default exclusions are used when the excludes list is empty.
287     *
288     * @param includes list of source files to include
289     * @param excludes list of source files to exclude
290     * @return the files to copy
291     */
292    private List<File> getFilesToInclude(List<String> includes, List<String> excludes) {
293        List<File> includedFiles = new ArrayList<>();
294
295        if (includes != null && !includes.isEmpty()) {
296            DirectoryScanner scanner = new DirectoryScanner();
297
298            scanner.setIncludes(includes.toArray(new String[includes.size()]));
299            scanner.setExcludes(excludes.toArray(new String[excludes.size()]));
300            scanner.addDefaultExcludes();
301            scanner.setBasedir(sourceDir);
302            scanner.scan();
303
304            for (String includedFilename : scanner.getIncludedFiles()) {
305                includedFiles.add(new File(sourceDir, includedFilename));
306            }
307
308            Collections.sort(includedFiles, new Comparator<File>() {
309                @Override
310                public int compare(File o1, File o2) {
311                    return o1.getName().compareToIgnoreCase(o2.getName());
312                }
313            });
314        }
315
316        return includedFiles;
317    }
318}