MergeProperitesMavenResourcesFiltering.java

/*
 * Copyright 2014-2020 Polago AB.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package org.polago.maven.plugins.mergeproperties;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.TreeMap;

import org.apache.commons.configuration2.Configuration;
import org.apache.commons.configuration2.MapConfiguration;
import org.apache.commons.configuration2.PropertiesConfiguration;
import org.apache.commons.configuration2.PropertiesConfigurationLayout;
import org.apache.commons.configuration2.ex.ConfigurationException;
import org.apache.commons.io.FilenameUtils;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.model.Resource;
import org.apache.maven.project.MavenProject;
import org.apache.maven.shared.filtering.MavenFileFilter;
import org.apache.maven.shared.filtering.MavenFilteringException;
import org.apache.maven.shared.filtering.MavenResourcesExecution;
import org.apache.maven.shared.filtering.MavenResourcesFiltering;
import org.apache.maven.shared.utils.PathTool;
import org.apache.maven.shared.utils.ReaderFactory;
import org.apache.maven.shared.utils.StringUtils;
import org.apache.maven.shared.utils.io.FileUtils;
import org.apache.maven.shared.utils.io.FileUtils.FilterWrapper;
import org.codehaus.plexus.component.annotations.Component;
import org.codehaus.plexus.component.annotations.Requirement;
import org.codehaus.plexus.logging.AbstractLogEnabled;
import org.codehaus.plexus.personality.plexus.lifecycle.phase.Initializable;
import org.codehaus.plexus.personality.plexus.lifecycle.phase.InitializationException;
import org.codehaus.plexus.util.IOUtil;
import org.codehaus.plexus.util.Scanner;
import org.sonatype.plexus.build.incremental.BuildContext;

/**
 * MavenResourcesFiltering Plexus Component that merges properties into a single file.
 */
@Component(role = MavenResourcesFiltering.class, hint = "merge")
public class MergeProperitesMavenResourcesFiltering extends AbstractLogEnabled
    implements MavenResourcesFiltering, Initializable {

    private static final String[] EMPTY_STRING_ARRAY = {};

    private static final String[] DEFAULT_INCLUDES = {"**/**.properties"};

    private List<String> defaultNonFilteredFileExtensions;

    @Requirement(hint = "default")
    private MavenFileFilter mavenFileFilter;

    @Requirement
    private BuildContext buildContext;

    @Requirement
    private MavenSession session;

    @Requirement
    private MavenProject project;

    private String outputFile;

    private boolean overwriteProperties = false;

    /**
     * {@inheritDoc}
     */
    @Override
    public void initialize() throws InitializationException {

        this.defaultNonFilteredFileExtensions = new ArrayList<String>();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean filteredFileExtension(String fileName, List<String> userNonFilteredFileExtensions) {
        List<String> nonFilteredFileExtensions = new ArrayList<String>(getDefaultNonFilteredFileExtensions());
        if (userNonFilteredFileExtensions != null) {
            nonFilteredFileExtensions.addAll(userNonFilteredFileExtensions);
        }
        boolean filteredFileExtension =
            !nonFilteredFileExtensions.contains(StringUtils.lowerCase(FilenameUtils.getExtension(fileName)));
        if (getLogger().isDebugEnabled()) {
            getLogger().debug(
                "file " + fileName + " has a" + (filteredFileExtension ? " " : " non ") + "filtered file extension");
        }
        return filteredFileExtension;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public List<String> getDefaultNonFilteredFileExtensions() {
        if (this.defaultNonFilteredFileExtensions == null) {
            this.defaultNonFilteredFileExtensions = new ArrayList<String>();
        }
        return this.defaultNonFilteredFileExtensions;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void filterResources(MavenResourcesExecution mavenResourcesExecution) throws MavenFilteringException {

        if (mavenResourcesExecution == null) {
            throw new MavenFilteringException("mavenResourcesExecution cannot be null");
        }

        if (mavenResourcesExecution.getResources() == null) {
            getLogger().info("No resources configured, skipping merging");
            return;
        }

        if (mavenResourcesExecution.getOutputDirectory() == null) {
            throw new MavenFilteringException("outputDirectory cannot be null");
        }

        if (mavenResourcesExecution.isUseDefaultFilterWrappers()) {
            handleDefaultFilterWrappers(mavenResourcesExecution);
        }

        if (mavenResourcesExecution.getEncoding() == null || mavenResourcesExecution.getEncoding().length() < 1) {
            getLogger().warn("Using platform encoding (" + ReaderFactory.FILE_ENCODING
                + " actually) to merge properties, i.e. build is platform dependent!");
        } else {
            getLogger().info("Using '" + mavenResourcesExecution.getEncoding() + "' encoding to merge properties.");
        }

        Properties outputProperties = new Properties();
        long lastModified = 0L;

        for (Resource resource : mavenResourcesExecution.getResources()) {

            if (getLogger().isDebugEnabled()) {
                String ls = System.getProperty("line.separator");
                StringBuffer debugMessage =
                    new StringBuffer("Resource with targetPath " + resource.getTargetPath()).append(ls);
                debugMessage.append("directory " + resource.getDirectory()).append(ls);
                debugMessage
                    .append(
                        "excludes " + (resource.getExcludes() == null ? " empty " : resource.getExcludes().toString()))
                    .append(ls);
                debugMessage.append(
                    "includes " + (resource.getIncludes() == null ? " empty " : resource.getIncludes().toString()));
                getLogger().debug(debugMessage.toString());
            }

            String targetPath = resource.getTargetPath();

            File resourceDirectory = new File(resource.getDirectory());

            if (!resourceDirectory.isAbsolute()) {
                resourceDirectory =
                    new File(mavenResourcesExecution.getResourcesBaseDirectory(), resourceDirectory.getPath());
            }

            if (!resourceDirectory.exists()) {
                getLogger().info("Skipping non-existing resourceDirectory: " + resourceDirectory.getPath());
                continue;
            }

            // this part is required in case the user specified "../something" as destination. See MNG-1345.
            File outputDirectory = mavenResourcesExecution.getOutputDirectory();
            boolean outputExists = outputDirectory.exists();
            if (!outputExists && !outputDirectory.mkdirs()) {
                throw new MavenFilteringException("Cannot create resource output directory: " + outputDirectory);
            }

            boolean ignoreDelta = !outputExists || mavenResourcesExecution.isOverwrite()
                || buildContext.hasDelta(mavenResourcesExecution.getFileFilters())
                || buildContext.hasDelta(getRelativeOutputDirectory(mavenResourcesExecution));
            getLogger().debug("ignoreDelta " + ignoreDelta);
            Scanner scanner = buildContext.newScanner(resourceDirectory, ignoreDelta);

            setupScanner(resource, scanner, mavenResourcesExecution.isAddDefaultExcludes());

            scanner.scan();

            List<String> includedFiles = Arrays.asList(scanner.getIncludedFiles());
            if (!ignoreDelta && buildContext.isIncremental() && !includedFiles.isEmpty()) {
                // Perform a full scan since we need to consider all files when the file list is nonEmpty in
                // an incremental build
                getLogger().debug("Reverting to full scan");

                scanner = buildContext.newScanner(resourceDirectory, true);
                setupScanner(resource, scanner, mavenResourcesExecution.isAddDefaultExcludes());
                scanner.scan();
                includedFiles = Arrays.asList(scanner.getIncludedFiles());
            }

            getLogger().info("Merging " + includedFiles.size() + " resource" + (includedFiles.size() > 1 ? "s" : "")
                + (targetPath == null ? "" : " to " + targetPath));

            for (String name : includedFiles) {
                getLogger().debug("Processing file " + name);
                File source = new File(resourceDirectory, name);
                lastModified = Math.max(lastModified, source.lastModified());

                boolean filteredExt =
                    filteredFileExtension(source.getName(), mavenResourcesExecution.getNonFilteredFileExtensions());

                mergeProperties(outputProperties, source, resource.isFiltering() && filteredExt,
                    mavenResourcesExecution.getFilterWrappers(), mavenResourcesExecution.getEncoding(),
                    overwriteProperties);
            }

        }

        File destinationFile = getDestinationFile(mavenResourcesExecution.getOutputDirectory(), outputFile);
        if (mavenResourcesExecution.isOverwrite() || lastModified > destinationFile.lastModified()) {
            storeProperties(outputProperties, destinationFile);
        } else {
            getLogger().info("Skipping merge since no files were modified");
        }
    }

    /**
     * Add Default Filter Wrappers to the MavenResourcesExecution instance.
     *
     * @param mavenResourcesExecution the instance to modify
     * @throws MavenFilteringException indicating error
     */
    private void handleDefaultFilterWrappers(MavenResourcesExecution mavenResourcesExecution)
        throws MavenFilteringException {
        List<FileUtils.FilterWrapper> filterWrappers = new ArrayList<FileUtils.FilterWrapper>();
        if (mavenResourcesExecution.getFilterWrappers() != null) {
            filterWrappers.addAll(mavenResourcesExecution.getFilterWrappers());
        }
        filterWrappers.addAll(mavenFileFilter.getDefaultFilterWrappers(mavenResourcesExecution));
        mavenResourcesExecution.setFilterWrappers(filterWrappers);
    }

    /**
     * Gets the destination file for the given file and dir.
     *
     * @param outputDirectory the directory used when file is a relative path
     * @param file the file path
     * @return a File representing the file
     */
    private File getDestinationFile(File outputDirectory, String file) {
        File destinationFile = new File(file);
        if (!destinationFile.isAbsolute()) {
            destinationFile = new File(outputDirectory, file);
        }

        if (!destinationFile.getParentFile().exists()) {
            destinationFile.getParentFile().mkdirs();
        }
        return destinationFile;
    }

    /**
     * Prepare the Scanner for use.
     *
     * @param resource the Resource to process
     * @param scanner the Scanner to setup
     * @param addDefaultExcludes if true, add default excludes to the Scanner
     */
    private void setupScanner(Resource resource, Scanner scanner, boolean addDefaultExcludes) {
        String[] includes;
        if (resource.getIncludes() != null && !resource.getIncludes().isEmpty()) {
            includes = resource.getIncludes().toArray(EMPTY_STRING_ARRAY);
        } else {
            includes = DEFAULT_INCLUDES;
        }
        scanner.setIncludes(includes);

        String[] excludes;
        if (resource.getExcludes() != null && !resource.getExcludes().isEmpty()) {
            excludes = resource.getExcludes().toArray(EMPTY_STRING_ARRAY);
            scanner.setExcludes(excludes);
        }

        if (addDefaultExcludes) {
            scanner.addDefaultExcludes();
        }

        scanner.addDefaultExcludes();
    }

    /**
     * Gets the relative path based on the project basedir.
     *
     * @param execution the MavenResourcesExecution instance to use
     * @return a path relative to the projects base directory
     */
    private String getRelativeOutputDirectory(MavenResourcesExecution execution) {
        String relOutDir = execution.getOutputDirectory().getAbsolutePath();

        if (execution.getMavenProject() != null && execution.getMavenProject().getBasedir() != null) {
            String basedir = execution.getMavenProject().getBasedir().getAbsolutePath();
            relOutDir = PathTool.getRelativeFilePath(basedir, relOutDir);
            if (relOutDir == null) {
                relOutDir = execution.getOutputDirectory().getPath();
            } else {
                relOutDir = relOutDir.replace('\\', '/');
            }
        }

        return relOutDir;
    }

    /**
     * Write the Properties to the given file using apache Commons-Configuration to avoid timestamp header.
     *
     * @param properties the Properties to use
     * @param file the file to store Properties into
     * @throws MavenFilteringException indicating File IO Error
     */
    protected void storeProperties(Properties properties, File file) throws MavenFilteringException {
        try (FileWriter f = new FileWriter(file)) {
            TreeMap<String, Object> sortedByKeyMap = new TreeMap<>();
            properties.entrySet().forEach(e -> sortedByKeyMap.put((String) e.getKey(), e.getValue()));
            final Configuration configuration = new MapConfiguration(sortedByKeyMap);
            PropertiesConfiguration p = new PropertiesConfiguration();
            PropertiesConfigurationLayout layout = new PropertiesConfigurationLayout();
            layout.setGlobalSeparator("=");
            p.setLayout(layout);
            p.copy(configuration);
            p.write(f);
        } catch (ConfigurationException | IOException e) {
            throw new MavenFilteringException(e.getMessage(), e);
        }
    }

    /**
     * Merge the source as a Properties file into outputProperties.
     *
     * @param properties the Properties to merge into
     * @param source the source file to read Properties from
     * @param filtering true if the filterWrappers should be applied
     * @param filterWrappers the FilterWrappers to use
     * @param encoding the encoding to use when filtering
     * @param overwrite true if existing properties should be overwritten. If false, duplicate properties is a build
     * error
     * @throws MavenFilteringException indicating failure
     */
    private void mergeProperties(Properties properties, File source, boolean filtering,
        List<FilterWrapper> filterWrappers, String encoding, boolean overwrite) throws MavenFilteringException {

        Properties p = getFilteredProperties(source, filtering, filterWrappers, encoding);
        for (Entry<Object, Object> entry : p.entrySet()) {
            String key = (String) entry.getKey();
            String value = (String) entry.getValue();
            String existing = properties.getProperty(key);
            if (existing != null) {
                if (overwrite) {
                    properties.setProperty(key, value);
                    getLogger().info("Overwriting existing Property '" + key + "' (existing value is '" + existing
                        + "', new value is '" + value + "') while merging source: " + source);
                } else {
                    throw new MavenFilteringException("Property '" + key + "' already exists (existing value is '"
                        + existing + "', new value is '" + value + "') while merging source: " + source);
                }
            }
            properties.setProperty(key, value);
        }
    }

    /**
     * Load and filter properties.
     *
     * @param source the source file to read Properties from
     * @param filtering true if the filterWrappers should be applied
     * @param filterWrappers the FilterWrappers to use
     * @param encoding the encoding to use when filtering
     * @return filtered Properties
     * @throws MavenFilteringException indicating failure
     */
    private Properties getFilteredProperties(File source, boolean filtering, List<FilterWrapper> filterWrappers,
        String encoding) throws MavenFilteringException {

        Properties p = new Properties();
        Reader r = null;
        try (InputStream is = new FileInputStream(source)) {
            r = new InputStreamReader(is, encoding);
            if (filtering) {
                for (FilterWrapper fw : filterWrappers) {
                    r = fw.getReader(r);
                }
            }
            p.load(r);
        } catch (IOException e) {
            throw new MavenFilteringException(e.getMessage(), e);
        } finally {
            IOUtil.close(r);
        }
        return p;
    }

    /**
     * Gets the outputFile property value.
     *
     * @return the current value of the outputFile property
     */
    public String getOutputFile() {
        return outputFile;
    }

    /**
     * Sets the outputFile property.
     *
     * @param outputFile the new property value
     */
    public void setOutputFile(String outputFile) {
        this.outputFile = outputFile;
    }

    /**
     * Determine if any duplicate properties should be overwritten or fail the build.
     * <p>
     * Default value is false.
     *
     * @param overwriteProperties true if duplicate properties should be overwritten.
     */
    public void setOverwriteProperties(boolean overwriteProperties) {
        this.overwriteProperties = overwriteProperties;
    }

    /**
     * Gets the overwriteProperties property value.
     *
     * @return the current value of the overwriteProperties property
     */
    public boolean isOverwriteProperties() {
        return overwriteProperties;
    }

    /**
     * Sets the buildContext property.
     *
     * @param buildContext the new property value
     */
    public void setBuildContext(BuildContext buildContext) {
        this.buildContext = buildContext;
    }
}