View Javadoc
1   /*
2    * Copyright 2014-2020 Polago AB.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *      http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  
17  /*
18   * Licensed to the Apache Software Foundation (ASF) under one
19   * or more contributor license agreements.  See the NOTICE file
20   * distributed with this work for additional information
21   * regarding copyright ownership.  The ASF licenses this file
22   * to you under the Apache License, Version 2.0 (the
23   * "License"); you may not use this file except in compliance
24   * with the License.  You may obtain a copy of the License at
25   *
26   *   http://www.apache.org/licenses/LICENSE-2.0
27   *
28   * Unless required by applicable law or agreed to in writing,
29   * software distributed under the License is distributed on an
30   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
31   * KIND, either express or implied.  See the License for the
32   * specific language governing permissions and limitations
33   * under the License.
34   */
35  
36  package org.polago.maven.plugins.mergeproperties;
37  
38  import java.io.File;
39  import java.io.FileInputStream;
40  import java.io.FileWriter;
41  import java.io.IOException;
42  import java.io.InputStream;
43  import java.io.InputStreamReader;
44  import java.io.Reader;
45  import java.util.ArrayList;
46  import java.util.Arrays;
47  import java.util.List;
48  import java.util.Map.Entry;
49  import java.util.Properties;
50  import java.util.TreeMap;
51  
52  import org.apache.commons.configuration2.Configuration;
53  import org.apache.commons.configuration2.MapConfiguration;
54  import org.apache.commons.configuration2.PropertiesConfiguration;
55  import org.apache.commons.configuration2.PropertiesConfigurationLayout;
56  import org.apache.commons.configuration2.ex.ConfigurationException;
57  import org.apache.commons.io.FilenameUtils;
58  import org.apache.maven.execution.MavenSession;
59  import org.apache.maven.model.Resource;
60  import org.apache.maven.project.MavenProject;
61  import org.apache.maven.shared.filtering.MavenFileFilter;
62  import org.apache.maven.shared.filtering.MavenFilteringException;
63  import org.apache.maven.shared.filtering.MavenResourcesExecution;
64  import org.apache.maven.shared.filtering.MavenResourcesFiltering;
65  import org.apache.maven.shared.utils.PathTool;
66  import org.apache.maven.shared.utils.ReaderFactory;
67  import org.apache.maven.shared.utils.StringUtils;
68  import org.apache.maven.shared.utils.io.FileUtils;
69  import org.apache.maven.shared.utils.io.FileUtils.FilterWrapper;
70  import org.codehaus.plexus.component.annotations.Component;
71  import org.codehaus.plexus.component.annotations.Requirement;
72  import org.codehaus.plexus.logging.AbstractLogEnabled;
73  import org.codehaus.plexus.personality.plexus.lifecycle.phase.Initializable;
74  import org.codehaus.plexus.personality.plexus.lifecycle.phase.InitializationException;
75  import org.codehaus.plexus.util.IOUtil;
76  import org.codehaus.plexus.util.Scanner;
77  import org.sonatype.plexus.build.incremental.BuildContext;
78  
79  /**
80   * MavenResourcesFiltering Plexus Component that merges properties into a single file.
81   */
82  @Component(role = MavenResourcesFiltering.class, hint = "merge")
83  public class MergeProperitesMavenResourcesFiltering extends AbstractLogEnabled
84      implements MavenResourcesFiltering, Initializable {
85  
86      private static final String[] EMPTY_STRING_ARRAY = {};
87  
88      private static final String[] DEFAULT_INCLUDES = {"**/**.properties"};
89  
90      private List<String> defaultNonFilteredFileExtensions;
91  
92      @Requirement(hint = "default")
93      private MavenFileFilter mavenFileFilter;
94  
95      @Requirement
96      private BuildContext buildContext;
97  
98      @Requirement
99      private MavenSession session;
100 
101     @Requirement
102     private MavenProject project;
103 
104     private String outputFile;
105 
106     private boolean overwriteProperties = false;
107 
108     /**
109      * {@inheritDoc}
110      */
111     @Override
112     public void initialize() throws InitializationException {
113 
114         this.defaultNonFilteredFileExtensions = new ArrayList<String>();
115     }
116 
117     /**
118      * {@inheritDoc}
119      */
120     @Override
121     public boolean filteredFileExtension(String fileName, List<String> userNonFilteredFileExtensions) {
122         List<String> nonFilteredFileExtensions = new ArrayList<String>(getDefaultNonFilteredFileExtensions());
123         if (userNonFilteredFileExtensions != null) {
124             nonFilteredFileExtensions.addAll(userNonFilteredFileExtensions);
125         }
126         boolean filteredFileExtension =
127             !nonFilteredFileExtensions.contains(StringUtils.lowerCase(FilenameUtils.getExtension(fileName)));
128         if (getLogger().isDebugEnabled()) {
129             getLogger().debug(
130                 "file " + fileName + " has a" + (filteredFileExtension ? " " : " non ") + "filtered file extension");
131         }
132         return filteredFileExtension;
133     }
134 
135     /**
136      * {@inheritDoc}
137      */
138     @Override
139     public List<String> getDefaultNonFilteredFileExtensions() {
140         if (this.defaultNonFilteredFileExtensions == null) {
141             this.defaultNonFilteredFileExtensions = new ArrayList<String>();
142         }
143         return this.defaultNonFilteredFileExtensions;
144     }
145 
146     /**
147      * {@inheritDoc}
148      */
149     @Override
150     public void filterResources(MavenResourcesExecution mavenResourcesExecution) throws MavenFilteringException {
151 
152         if (mavenResourcesExecution == null) {
153             throw new MavenFilteringException("mavenResourcesExecution cannot be null");
154         }
155 
156         if (mavenResourcesExecution.getResources() == null) {
157             getLogger().info("No resources configured, skipping merging");
158             return;
159         }
160 
161         if (mavenResourcesExecution.getOutputDirectory() == null) {
162             throw new MavenFilteringException("outputDirectory cannot be null");
163         }
164 
165         if (mavenResourcesExecution.isUseDefaultFilterWrappers()) {
166             handleDefaultFilterWrappers(mavenResourcesExecution);
167         }
168 
169         if (mavenResourcesExecution.getEncoding() == null || mavenResourcesExecution.getEncoding().length() < 1) {
170             getLogger().warn("Using platform encoding (" + ReaderFactory.FILE_ENCODING
171                 + " actually) to merge properties, i.e. build is platform dependent!");
172         } else {
173             getLogger().info("Using '" + mavenResourcesExecution.getEncoding() + "' encoding to merge properties.");
174         }
175 
176         Properties outputProperties = new Properties();
177         long lastModified = 0L;
178 
179         for (Resource resource : mavenResourcesExecution.getResources()) {
180 
181             if (getLogger().isDebugEnabled()) {
182                 String ls = System.getProperty("line.separator");
183                 StringBuffer debugMessage =
184                     new StringBuffer("Resource with targetPath " + resource.getTargetPath()).append(ls);
185                 debugMessage.append("directory " + resource.getDirectory()).append(ls);
186                 debugMessage
187                     .append(
188                         "excludes " + (resource.getExcludes() == null ? " empty " : resource.getExcludes().toString()))
189                     .append(ls);
190                 debugMessage.append(
191                     "includes " + (resource.getIncludes() == null ? " empty " : resource.getIncludes().toString()));
192                 getLogger().debug(debugMessage.toString());
193             }
194 
195             String targetPath = resource.getTargetPath();
196 
197             File resourceDirectory = new File(resource.getDirectory());
198 
199             if (!resourceDirectory.isAbsolute()) {
200                 resourceDirectory =
201                     new File(mavenResourcesExecution.getResourcesBaseDirectory(), resourceDirectory.getPath());
202             }
203 
204             if (!resourceDirectory.exists()) {
205                 getLogger().info("Skipping non-existing resourceDirectory: " + resourceDirectory.getPath());
206                 continue;
207             }
208 
209             // this part is required in case the user specified "../something" as destination. See MNG-1345.
210             File outputDirectory = mavenResourcesExecution.getOutputDirectory();
211             boolean outputExists = outputDirectory.exists();
212             if (!outputExists && !outputDirectory.mkdirs()) {
213                 throw new MavenFilteringException("Cannot create resource output directory: " + outputDirectory);
214             }
215 
216             boolean ignoreDelta = !outputExists || mavenResourcesExecution.isOverwrite()
217                 || buildContext.hasDelta(mavenResourcesExecution.getFileFilters())
218                 || buildContext.hasDelta(getRelativeOutputDirectory(mavenResourcesExecution));
219             getLogger().debug("ignoreDelta " + ignoreDelta);
220             Scanner scanner = buildContext.newScanner(resourceDirectory, ignoreDelta);
221 
222             setupScanner(resource, scanner, mavenResourcesExecution.isAddDefaultExcludes());
223 
224             scanner.scan();
225 
226             List<String> includedFiles = Arrays.asList(scanner.getIncludedFiles());
227             if (!ignoreDelta && buildContext.isIncremental() && !includedFiles.isEmpty()) {
228                 // Perform a full scan since we need to consider all files when the file list is nonEmpty in
229                 // an incremental build
230                 getLogger().debug("Reverting to full scan");
231 
232                 scanner = buildContext.newScanner(resourceDirectory, true);
233                 setupScanner(resource, scanner, mavenResourcesExecution.isAddDefaultExcludes());
234                 scanner.scan();
235                 includedFiles = Arrays.asList(scanner.getIncludedFiles());
236             }
237 
238             getLogger().info("Merging " + includedFiles.size() + " resource" + (includedFiles.size() > 1 ? "s" : "")
239                 + (targetPath == null ? "" : " to " + targetPath));
240 
241             for (String name : includedFiles) {
242                 getLogger().debug("Processing file " + name);
243                 File source = new File(resourceDirectory, name);
244                 lastModified = Math.max(lastModified, source.lastModified());
245 
246                 boolean filteredExt =
247                     filteredFileExtension(source.getName(), mavenResourcesExecution.getNonFilteredFileExtensions());
248 
249                 mergeProperties(outputProperties, source, resource.isFiltering() && filteredExt,
250                     mavenResourcesExecution.getFilterWrappers(), mavenResourcesExecution.getEncoding(),
251                     overwriteProperties);
252             }
253 
254         }
255 
256         File destinationFile = getDestinationFile(mavenResourcesExecution.getOutputDirectory(), outputFile);
257         if (mavenResourcesExecution.isOverwrite() || lastModified > destinationFile.lastModified()) {
258             storeProperties(outputProperties, destinationFile);
259         } else {
260             getLogger().info("Skipping merge since no files were modified");
261         }
262     }
263 
264     /**
265      * Add Default Filter Wrappers to the MavenResourcesExecution instance.
266      *
267      * @param mavenResourcesExecution the instance to modify
268      * @throws MavenFilteringException indicating error
269      */
270     private void handleDefaultFilterWrappers(MavenResourcesExecution mavenResourcesExecution)
271         throws MavenFilteringException {
272         List<FileUtils.FilterWrapper> filterWrappers = new ArrayList<FileUtils.FilterWrapper>();
273         if (mavenResourcesExecution.getFilterWrappers() != null) {
274             filterWrappers.addAll(mavenResourcesExecution.getFilterWrappers());
275         }
276         filterWrappers.addAll(mavenFileFilter.getDefaultFilterWrappers(mavenResourcesExecution));
277         mavenResourcesExecution.setFilterWrappers(filterWrappers);
278     }
279 
280     /**
281      * Gets the destination file for the given file and dir.
282      *
283      * @param outputDirectory the directory used when file is a relative path
284      * @param file the file path
285      * @return a File representing the file
286      */
287     private File getDestinationFile(File outputDirectory, String file) {
288         File destinationFile = new File(file);
289         if (!destinationFile.isAbsolute()) {
290             destinationFile = new File(outputDirectory, file);
291         }
292 
293         if (!destinationFile.getParentFile().exists()) {
294             destinationFile.getParentFile().mkdirs();
295         }
296         return destinationFile;
297     }
298 
299     /**
300      * Prepare the Scanner for use.
301      *
302      * @param resource the Resource to process
303      * @param scanner the Scanner to setup
304      * @param addDefaultExcludes if true, add default excludes to the Scanner
305      */
306     private void setupScanner(Resource resource, Scanner scanner, boolean addDefaultExcludes) {
307         String[] includes;
308         if (resource.getIncludes() != null && !resource.getIncludes().isEmpty()) {
309             includes = resource.getIncludes().toArray(EMPTY_STRING_ARRAY);
310         } else {
311             includes = DEFAULT_INCLUDES;
312         }
313         scanner.setIncludes(includes);
314 
315         String[] excludes;
316         if (resource.getExcludes() != null && !resource.getExcludes().isEmpty()) {
317             excludes = resource.getExcludes().toArray(EMPTY_STRING_ARRAY);
318             scanner.setExcludes(excludes);
319         }
320 
321         if (addDefaultExcludes) {
322             scanner.addDefaultExcludes();
323         }
324 
325         scanner.addDefaultExcludes();
326     }
327 
328     /**
329      * Gets the relative path based on the project basedir.
330      *
331      * @param execution the MavenResourcesExecution instance to use
332      * @return a path relative to the projects base directory
333      */
334     private String getRelativeOutputDirectory(MavenResourcesExecution execution) {
335         String relOutDir = execution.getOutputDirectory().getAbsolutePath();
336 
337         if (execution.getMavenProject() != null && execution.getMavenProject().getBasedir() != null) {
338             String basedir = execution.getMavenProject().getBasedir().getAbsolutePath();
339             relOutDir = PathTool.getRelativeFilePath(basedir, relOutDir);
340             if (relOutDir == null) {
341                 relOutDir = execution.getOutputDirectory().getPath();
342             } else {
343                 relOutDir = relOutDir.replace('\\', '/');
344             }
345         }
346 
347         return relOutDir;
348     }
349 
350     /**
351      * Write the Properties to the given file using apache Commons-Configuration to avoid timestamp header.
352      *
353      * @param properties the Properties to use
354      * @param file the file to store Properties into
355      * @throws MavenFilteringException indicating File IO Error
356      */
357     protected void storeProperties(Properties properties, File file) throws MavenFilteringException {
358         try (FileWriter f = new FileWriter(file)) {
359             TreeMap<String, Object> sortedByKeyMap = new TreeMap<>();
360             properties.entrySet().forEach(e -> sortedByKeyMap.put((String) e.getKey(), e.getValue()));
361             final Configuration configuration = new MapConfiguration(sortedByKeyMap);
362             PropertiesConfiguration p = new PropertiesConfiguration();
363             PropertiesConfigurationLayout layout = new PropertiesConfigurationLayout();
364             layout.setGlobalSeparator("=");
365             p.setLayout(layout);
366             p.copy(configuration);
367             p.write(f);
368         } catch (ConfigurationException | IOException e) {
369             throw new MavenFilteringException(e.getMessage(), e);
370         }
371     }
372 
373     /**
374      * Merge the source as a Properties file into outputProperties.
375      *
376      * @param properties the Properties to merge into
377      * @param source the source file to read Properties from
378      * @param filtering true if the filterWrappers should be applied
379      * @param filterWrappers the FilterWrappers to use
380      * @param encoding the encoding to use when filtering
381      * @param overwrite true if existing properties should be overwritten. If false, duplicate properties is a build
382      * error
383      * @throws MavenFilteringException indicating failure
384      */
385     private void mergeProperties(Properties properties, File source, boolean filtering,
386         List<FilterWrapper> filterWrappers, String encoding, boolean overwrite) throws MavenFilteringException {
387 
388         Properties p = getFilteredProperties(source, filtering, filterWrappers, encoding);
389         for (Entry<Object, Object> entry : p.entrySet()) {
390             String key = (String) entry.getKey();
391             String value = (String) entry.getValue();
392             String existing = properties.getProperty(key);
393             if (existing != null) {
394                 if (overwrite) {
395                     properties.setProperty(key, value);
396                     getLogger().info("Overwriting existing Property '" + key + "' (existing value is '" + existing
397                         + "', new value is '" + value + "') while merging source: " + source);
398                 } else {
399                     throw new MavenFilteringException("Property '" + key + "' already exists (existing value is '"
400                         + existing + "', new value is '" + value + "') while merging source: " + source);
401                 }
402             }
403             properties.setProperty(key, value);
404         }
405     }
406 
407     /**
408      * Load and filter properties.
409      *
410      * @param source the source file to read Properties from
411      * @param filtering true if the filterWrappers should be applied
412      * @param filterWrappers the FilterWrappers to use
413      * @param encoding the encoding to use when filtering
414      * @return filtered Properties
415      * @throws MavenFilteringException indicating failure
416      */
417     private Properties getFilteredProperties(File source, boolean filtering, List<FilterWrapper> filterWrappers,
418         String encoding) throws MavenFilteringException {
419 
420         Properties p = new Properties();
421         Reader r = null;
422         try (InputStream is = new FileInputStream(source)) {
423             r = new InputStreamReader(is, encoding);
424             if (filtering) {
425                 for (FilterWrapper fw : filterWrappers) {
426                     r = fw.getReader(r);
427                 }
428             }
429             p.load(r);
430         } catch (IOException e) {
431             throw new MavenFilteringException(e.getMessage(), e);
432         } finally {
433             IOUtil.close(r);
434         }
435         return p;
436     }
437 
438     /**
439      * Gets the outputFile property value.
440      *
441      * @return the current value of the outputFile property
442      */
443     public String getOutputFile() {
444         return outputFile;
445     }
446 
447     /**
448      * Sets the outputFile property.
449      *
450      * @param outputFile the new property value
451      */
452     public void setOutputFile(String outputFile) {
453         this.outputFile = outputFile;
454     }
455 
456     /**
457      * Determine if any duplicate properties should be overwritten or fail the build.
458      * <p>
459      * Default value is false.
460      *
461      * @param overwriteProperties true if duplicate properties should be overwritten.
462      */
463     public void setOverwriteProperties(boolean overwriteProperties) {
464         this.overwriteProperties = overwriteProperties;
465     }
466 
467     /**
468      * Gets the overwriteProperties property value.
469      *
470      * @return the current value of the overwriteProperties property
471      */
472     public boolean isOverwriteProperties() {
473         return overwriteProperties;
474     }
475 
476     /**
477      * Sets the buildContext property.
478      *
479      * @param buildContext the new property value
480      */
481     public void setBuildContext(BuildContext buildContext) {
482         this.buildContext = buildContext;
483     }
484 }