CleanMessagesMojo.java

/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyrighted [year] [name of copyright owner]".
 *
 * Copyright 2011 ForgeRock AS
 * Portions Copyright 2022 Wren Security.
 */
package org.forgerock.i18n.maven;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;

/**
 * Goal which cleans unused messages files from a property file.
 */
@Mojo(name="clean-messages", threadSafe=true)
@SuppressWarnings("resource")
public final class CleanMessagesMojo extends AbstractMojo {

    /**
     * A task which searches a single source file for any messages.
     */
    private final class SourceFileTask implements Runnable {

        private final File sourceFile;

        private SourceFileTask(final File sourceFile) {
            this.sourceFile = sourceFile;
        }

        /**
         * {@inheritDoc}
         */
        public void run() {
            // Cache the keys that we want to check so that we avoid excessive
            // contention on the CHM.
            final List<MessagePropertyKey> keys = new LinkedList<MessagePropertyKey>(
                    unreferencedProperties.keySet());

            try {
                final FileInputStream s = new FileInputStream(sourceFile);
                try {
                    final LineNumberReader reader = new LineNumberReader(
                            new InputStreamReader(s, encoding));

                    String line;
                    while ((line = reader.readLine()) != null) {
                        final Iterator<MessagePropertyKey> i = keys.iterator();
                        while (i.hasNext()) {
                            final MessagePropertyKey key = i.next();
                            if (key.isPresent(line)) {
                                i.remove();
                                unreferencedProperties.remove(key);
                                referencedProperties.put(key, ""); // Dummy
                                                                   // value.
                            }
                        }
                    }
                } finally {
                    try {
                        s.close();
                    } catch (final Exception ignored) {
                        // Ignore.
                    }
                }

                fileCount.incrementAndGet();
            } catch (final IOException e) {
                getLog().error(
                        "An error occurred while reading source file "
                                + sourceFile.getName(), e);
            }
        }

    }

    /**
     * The target directory in which the source files should be generated.
     */
    @Parameter(defaultValue="${project.build.sourceDirectory}", required=true)
    private File sourceDirectory;

    /**
     * The message message file to be cleaned.
     */
    @Parameter(required=true)
    private File messageFile;

    /**
     * The encoding argument used by Java source files.
     */
    @Parameter(defaultValue="${project.build.sourceEncoding}", required=true)
    private String encoding;

    private final Map<MessagePropertyKey, String> unreferencedProperties =
            new ConcurrentHashMap<MessagePropertyKey, String>();
    private final Map<MessagePropertyKey, String> referencedProperties =
            new ConcurrentHashMap<MessagePropertyKey, String>();
    private final AtomicInteger fileCount = new AtomicInteger();

    /**
     * {@inheritDoc}
     */
    // @Checkstyle:ignore
    public void execute() throws MojoExecutionException {
        if (!sourceDirectory.exists()) {
            throw new MojoExecutionException("Source directory "
                    + sourceDirectory.getPath() + " does not exist");
        } else if (!sourceDirectory.isDirectory()) {
            throw new MojoExecutionException("Source directory "
                    + sourceDirectory.getPath() + " is not a directory");
        }

        if (!messageFile.exists()) {
            throw new MojoExecutionException("Message file "
                    + messageFile.getPath() + " does not exist");
        } else if (!messageFile.isFile()) {
            throw new MojoExecutionException("Message file "
                    + messageFile.getPath() + " is not a file");
        }

        final Properties properties = new Properties();

        try {
            final FileInputStream propertiesFile = new FileInputStream(
                    messageFile);
            try {
                properties.load(propertiesFile);
            } finally {
                try {
                    propertiesFile.close();
                } catch (final Exception ignored) {
                    // Ignore.
                }
            }
        } catch (final IOException e) {
            throw new MojoExecutionException(
                    "An IO error occurred while reading the message property file: "
                            + e);
        }

        // Initially all properties are deemed to have no references.
        for (final Map.Entry<Object, Object> property : properties.entrySet()) {
            final String propKey = property.getKey().toString();
            final MessagePropertyKey key = MessagePropertyKey.valueOf(propKey);
            unreferencedProperties.put(key, property.getValue().toString());
        }

        final int messageCount = unreferencedProperties.size();

        // Process source files in parallel.
        final ExecutorService executor = Executors.newFixedThreadPool(Runtime
                .getRuntime().availableProcessors() * 2);

        // Recursively add source files to task queue.
        processSourceDirectory(executor, sourceDirectory);

        executor.shutdown();
        try {
            executor.awaitTermination(1, TimeUnit.DAYS);
        } catch (final InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new MojoExecutionException(
                    "Interrupted while processing source files");
        }

        if ((unreferencedProperties.size() + referencedProperties.size()) != messageCount) {
            throw new IllegalStateException("Message table sizes are invalid");
        }

        getLog().info("Processed " + fileCount.get() + " source files");
        getLog().info(
                "Found " + unreferencedProperties.size() + " / " + messageCount
                        + " unreferenced properties");

        // All source files processed and each message is known to be either
        // referenced or not. Re-write the property file including only the
        // referenced properties.
        int cleanedMessageCount = 0;
        int savedMessageCount = 0;

        final List<String> lines = new ArrayList<String>(10000);
        try {
            final FileInputStream propertiesFile = new FileInputStream(
                    messageFile);
            try {
                final LineNumberReader reader = new LineNumberReader(
                        new InputStreamReader(propertiesFile, "ISO-8859-1"));

                String line;
                boolean inValue = false;
                boolean lineNeedsRemoving = false;
                boolean foundErrors = false;
                while ((line = reader.readLine()) != null) {
                    if (!inValue) {
                        // This could be a comment or the start of a new
                        // property.
                        final String trimmedLine = line.trim();
                        if (trimmedLine.length() == 0) {
                            lines.add(line);
                        } else if (trimmedLine.startsWith("#")) {
                            lines.add(line);
                        } else {
                            final String key;
                            final int separator = trimmedLine.indexOf('=');
                            if (separator < 0) {
                                key = trimmedLine;
                            } else {
                                key = trimmedLine.substring(0, separator)
                                        .trim();
                            }

                            MessagePropertyKey mpk;
                            try {
                                mpk = MessagePropertyKey.valueOf(key);
                            } catch (IllegalArgumentException e) {
                                getLog().error(
                                        "Unable to decode line "
                                                + reader.getLineNumber() + ": "
                                                + line, e);
                                lines.add(line);
                                lineNeedsRemoving = false;
                                inValue = isContinuedOnNextLine(line);
                                foundErrors = true;
                                continue;
                            }

                            if (referencedProperties.containsKey(mpk)) {
                                savedMessageCount++;
                                lines.add(line);
                                lineNeedsRemoving = false;
                            } else {
                                if (!unreferencedProperties.containsKey(mpk)) {
                                    throw new IllegalStateException(
                                            "Unregistered message key");
                                }
                                cleanedMessageCount++;
                                lineNeedsRemoving = true;
                            }
                            inValue = isContinuedOnNextLine(line);
                        }
                    } else {
                        // This is a continuation line.
                        if (!lineNeedsRemoving) {
                            lines.add(line);
                        }

                        inValue = isContinuedOnNextLine(line);
                    }
                }

                if (foundErrors) {
                    throw new MojoExecutionException(
                            "Aborting because the message file could not be parsed");
                }
            } finally {
                try {
                    propertiesFile.close();
                } catch (final Exception ignored) {
                    // Ignore.
                }
            }
        } catch (final IOException e) {
            throw new MojoExecutionException(
                    "An IO error occurred while reading the message property file: "
                            + e);
        }

        // Now write out the cleaned message file.
        if (cleanedMessageCount == 0) {
            // Nothing to do.
            getLog().info(
                    "Message file " + messageFile.getName()
                            + " unchanged: no messages were cleaned");
        } else {
            try {
                final FileOutputStream propertiesFile = new FileOutputStream(
                        messageFile);
                try {
                    final OutputStreamWriter writer = new OutputStreamWriter(
                            propertiesFile, "ISO-8859-1");
                    final String eol = System.getProperty("line.separator");
                    for (final String line : lines) {
                        writer.write(line);
                        writer.write(eol);
                    }

                    writer.close();
                } finally {
                    try {
                        propertiesFile.close();
                    } catch (final Exception ignored) {
                        // Ignore.
                    }
                }
            } catch (final IOException e) {
                throw new MojoExecutionException(
                        "An IO error occurred while writing the message property file: "
                                + e);
            }

            getLog().info(
                    "Message file " + messageFile.getName() + " cleaned: "
                            + cleanedMessageCount + " messages removed and "
                            + savedMessageCount + " kept");
        }
    }

    // Check to see if this line ends with a continuation character (odd
    // number of consecutive back-slash).
    boolean isContinuedOnNextLine(final String line) {
        int bsCount = 0;
        for (int i = line.length() - 1; i >= 0 && line.charAt(i) == '\\'; i--) {
            bsCount++;
        }

        return ((bsCount % 2) == 1);
    }

    // Recursively create tasks for each Java source file in the provided source
    // directory and sub-directories.
    private void processSourceDirectory(final ExecutorService executor,
            final File s) {
        for (final File f : s.listFiles()) {
            if (f.isDirectory()) {
                processSourceDirectory(executor, f);
            } else if (f.isFile()) {
                if (f.getName().endsWith(".java")) {
                    // Add a task for processing this source file.
                    executor.execute(new SourceFileTask(f));
                }
            }
        }
    }
}