AbstractGenerateMessagesMojo.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-2013 ForgeRock AS.
 *      Portions Copyright 2017 Wren Security.
 */
package org.forgerock.i18n.maven;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
import java.util.UnknownFormatConversionException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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

/**
 * Abstract Mojo implementation for generating message source files from a one
 * or more property files.
 */
abstract class AbstractGenerateMessagesMojo extends AbstractMojo {
    /**
     * A message file descriptor passed in from the POM configuration.
     */
    static final class MessageFile {
        /**
         * The name of the message property file to be processed.
         */
        private final String name;

        /**
         * Creates a new message file.
         *
         * @param name
         *            The name of the message file relative to the resource
         *            directory.
         */
        MessageFile(final String name) {
            this.name = name;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public String toString() {
            return name;
        }

        /**
         * Returns the class name (without package) which will contain the
         * generated message descriptors.
         *
         * @return The class name (without package) which will contain the
         *         generated message descriptors.
         */
        String getClassName() {
            final StringBuilder builder = new StringBuilder();
            final String shortName = getShortName();
            boolean upperCaseNextChar = true;
            for (final char c : shortName.toCharArray()) {
                if (c == '_' || c == '-') {
                    upperCaseNextChar = true;
                    continue;
                }

                if (upperCaseNextChar) {
                    builder.append(Character.toUpperCase(c));
                    upperCaseNextChar = false;
                } else {
                    builder.append(c);
                }
            }
            builder.append("Messages");
            return builder.toString();
        }

        /**
         * The name of the message file relative to the resource directory.
         *
         * @return The name of the message file relative to the resource
         *         directory.
         */
        String getName() {
            return name;
        }

        /**
         * Returns a {@code File} representing the full path to the output Java
         * file.
         *
         * @param outputDirectory
         *            The target directory.
         * @return A {@code File} representing the full path to the output Java
         *         file.
         */
        File getOutputFile(final File outputDirectory) {
            final int lastSlash = name.lastIndexOf('/');
            final String parentPath = name.substring(0, lastSlash);
            final String path = parentPath.replace('/', File.separatorChar)
                    + File.separator + getClassName() + ".java";
            return new File(outputDirectory, path);
        }

        /**
         * Returns the name of the package containing the message file.
         *
         * @return The name of the package containing the message file.
         */
        String getPackageName() {
            final int lastSlash = name.lastIndexOf('/');
            final String parentPath = name.substring(0, lastSlash);
            return parentPath.replace('/', '.');
        }

        /**
         * The resource bundle name.
         *
         * @return The resource bundle name.
         */
        String getResourceBundleName() {
            return getPackageName() + "." + getShortName();
        }

        /**
         * Returns a {@code File} representing the full path to this message
         * file.
         *
         * @param resourceDirectory
         *            The resource directory.
         * @return A {@code File} representing the full path to this message
         *         file.
         */
        File getResourceFile(final File resourceDirectory) {
            final String path = name.replace('/', File.separatorChar);
            return new File(resourceDirectory, path);
        }

        /**
         * Returns the name of the message file with the package name and
         * trailing ".properties" suffix stripped.
         *
         * @return The name of the message file with the package name and
         *         trailing ".properties" suffix stripped.
         */
        String getShortName() {
            final int lastSlash = name.lastIndexOf('/');
            final String fileName = name.substring(lastSlash + 1);
            final int lastDot = fileName.lastIndexOf('.');
            return fileName.substring(0, lastDot);
        }

    }

    /**
     * Representation of a format specifier (for example %s).
     */
    private static final class FormatSpecifier {

        private final String[] sa;

        /**
         * Creates a new specifier.
         *
         * @param sa
         *            Specifier components.
         */
        FormatSpecifier(final String[] sa) {
            this.sa = sa;
        }

        /**
         * Returns a java class associated with a particular formatter based on
         * the conversion type of the specifier.
         *
         * @return Class for representing the type of argument used as a
         *         replacement for this specifier.
         */
        Class<?> getSimpleConversionClass() {
            Class<?> c = null;
            final String sa4 = sa[4] != null ? sa[4].toLowerCase() : null;
            final String sa5 = sa[5] != null ? sa[5].toLowerCase() : null;
            if ("t".equals(sa4)) {
                c = Calendar.class;
            } else if ("b".equals(sa5)) {
                c = Boolean.class;
            } else if ("h".equals(sa5) /* Hashcode */) {
                c = Object.class;
            } else if ("s".equals(sa5)) {
                c = Object.class; /* Conversion using toString() */
            } else if ("c".equals(sa5)) {
                c = Character.class;
            } else if ("d".equals(sa5) || "o".equals(sa5) || "x".equals(sa5)
                    || "e".equals(sa5) || "f".equals(sa5) || "g".equals(sa5)
                    || "a".equals(sa5)) {
                c = Number.class;
            } else if ("n".equals(sa5) || "%".equals(sa5)) {
                // ignore literals
            }
            return c;
        }

        /**
         * Returns {@code true} if this specifier uses argument indexes (for
         * example 2$).
         *
         * @return boolean {@code true} if this specifier uses argument indexes.
         */
        boolean specifiesArgumentIndex() {
            return this.sa[0] != null;
        }

    }

    /**
     * Represents a message to be written into the messages files.
     */
    private static final class MessageDescriptorDeclaration {

        private final MessagePropertyKey key;
        private final String formatString;
        private final List<FormatSpecifier> specifiers;
        private final List<Class<?>> classTypes;
        private String[] constructorArgs;

        /**
         * Creates a parameterized instance.
         *
         * @param key
         *            The message key.
         * @param formatString
         *            The message format string.
         */
        MessageDescriptorDeclaration(final MessagePropertyKey key,
                final String formatString) {
            this.key = key;
            this.formatString = formatString;
            this.specifiers = parse(formatString);
            this.classTypes = new ArrayList<Class<?>>();
            for (final FormatSpecifier f : specifiers) {
                final Class<?> c = f.getSimpleConversionClass();
                if (c != null) {
                    classTypes.add(c);
                }
            }
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public String toString() {
            final StringBuilder sb = new StringBuilder();
            sb.append(getComment());
            sb.append(indent(1));
            sb.append("public static final ");
            sb.append(getDescriptorClassDeclaration());
            sb.append(" ");
            sb.append(key.getName());
            sb.append(" =");
            sb.append(EOL);
            sb.append(indent(5));
            sb.append("new ");
            sb.append(getDescriptorClassDeclaration());
            sb.append("(");
            if (constructorArgs != null) {
                for (int i = 0; i < constructorArgs.length; i++) {
                    sb.append(constructorArgs[i]);
                    if (i < constructorArgs.length - 1) {
                        sb.append(", ");
                    }
                }
            }
            sb.append(");");
            return sb.toString();
        }

        /**
         * Returns a string representing the message type class' variable
         * information (for example '<String,Integer>') that is based on the
         * type of arguments specified by the specifiers in this message.
         *
         * @return String representing the message type class parameters.
         */
        String getClassTypeVariables() {
            final StringBuilder sb = new StringBuilder();
            if (classTypes.size() > 0) {
                sb.append("<");
                for (int i = 0; i < classTypes.size(); i++) {
                    final Class<?> c = classTypes.get(i);
                    if (c != null) {
                        sb.append(getShortClassName(c));
                        if (i < classTypes.size() - 1) {
                            sb.append(", ");
                        }
                    }
                }
                sb.append(">");
            }
            return sb.toString();
        }

        /**
         * Returns the javadoc comments that will appear above the messages
         * declaration in the messages file.
         *
         * @return The javadoc comments that will appear above the messages
         *         declaration in the messages file.
         */
        String getComment() {
            final StringBuilder sb = new StringBuilder();
            sb.append(indent(1)).append("/**").append(EOL);
            sb.append(indent(1)).append(" * ").append("{@code").append(EOL);

            // Unwrapped so that you can search through the descriptor
            // file for a message and not have to worry about line breaks
            final String ws = formatString; // wrapText(formatString, 70);

            final String[] sa = ws.split(EOL);
            for (final String s : sa) {
                sb.append(indent(1)).append(" * ").append(s).append(EOL);
            }

            sb.append(indent(1)).append(" * ").append("}").append(EOL);
            sb.append(indent(1)).append(" */").append(EOL);
            return sb.toString();
        }

        /**
         * Returns the name of the Java class that will be used to represent
         * this message's type.
         *
         * @return The name of the Java class that will be used to represent
         *         this message's type.
         */
        String getDescriptorClassDeclaration() {
            final StringBuilder sb = new StringBuilder();
            if (useGenericMessageTypeClass()) {
                sb.append("LocalizableMessageDescriptor");
                sb.append(".");
                sb.append(DESCRIPTOR_CLASS_BASE_NAME);
                sb.append("N");
            } else {
                sb.append("LocalizableMessageDescriptor");
                sb.append(".");
                sb.append(DESCRIPTOR_CLASS_BASE_NAME);
                sb.append(classTypes.size());
                sb.append(getClassTypeVariables());
            }
            return sb.toString();
        }

        /**
         * Sets the arguments that will be supplied in the declaration of the
         * message.
         *
         * @param s
         *            The array of string arguments that will be passed in the
         *            constructor.
         */
        void setConstructorArguments(final String... s) {
            this.constructorArgs = s;
        }

        private void checkText(final String s) {
            int idx = s.indexOf('%');
            // If there are any '%' in the given string, we got a bad format
            // specifier.
            if (idx != -1) {
                final char c = (idx > s.length() - 2 ? '%' : s.charAt(idx + 1));
                throw new UnknownFormatConversionException(String.valueOf(c));
            }
        }

        private String getShortClassName(final Class<?> c) {
            String name;
            final String fqName = c.getName();
            final int i = fqName.lastIndexOf('.');
            if (i > 0) {
                name = fqName.substring(i + 1);
            } else {
                name = fqName;
            }
            return name;
        }

        private String indent(final int indent) {
            final char[] blankArray = new char[4 * indent];
            Arrays.fill(blankArray, ' ');
            return new String(blankArray);
        }

        /**
         * Returns a list of format specifiers contained in the provided format
         * string.
         *
         * @param s
         *            The format string.
         * @return The list of format specifiers.
         */
        private List<FormatSpecifier> parse(final String s) {
            final List<FormatSpecifier> sl = new ArrayList<FormatSpecifier>();
            final Matcher m = SPECIFIER_PATTERN.matcher(s);
            int i = 0;
            while (i < s.length()) {
                if (m.find(i)) {
                    // Anything between the start of the string and the
                    // beginning of the format specifier is either fixed text or contains
                    // an invalid format string.
                    if (m.start() != i) {
                        // Make sure we didn't miss any invalid format
                        // specifiers
                        checkText(s.substring(i, m.start()));

                        // Assume previous characters were fixed text
                        // al.add(new FixedString(s.substring(i, m.start())));
                    }

                    // Expect 6 groups in regular expression
                    final String[] sa = new String[6];
                    for (int j = 0; j < m.groupCount(); j++) {
                        sa[j] = m.group(j + 1);
                    }
                    sl.add(new FormatSpecifier(sa));
                    i = m.end();
                } else {
                    // No more valid format specifiers. Check for possible
                    // invalid format specifiers.
                    checkText(s.substring(i));

                    // The rest of the string is fixed text
                    // al.add(new FixedString(s.substring(i)));
                    break;
                }
            }
            return sl;
        }

        /**
         * Indicates whether the generic message type class should be used. In
         * general this is when a format specifier is more complicated than we
         * support or when the number of arguments exceeds the number of
         * specific message type classes (MessageType0, MessageType1 ...) that
         * are defined.
         *
         * @return boolean {@code true} if the generic message type class should
         *         be used.
         */
        private boolean useGenericMessageTypeClass() {
            if (specifiers.size() > DESCRIPTOR_MAX_ARG_HANDLER) {
                return true;
            } else {
                for (final FormatSpecifier s : specifiers) {
                    if (s.specifiesArgumentIndex()) {
                        return true;
                    }
                }
            }
            return false;
        }

    }

    /**
     * Indicates whether or not message files should be regenerated even if they
     * are already up to date.
     */
    @Parameter(defaultValue="false", required=true)
    private boolean force;

    /**
     * The list of files we want to transfer, relative to the resource
     * directory.
     */
    @Parameter(required=true)
    private String[] messageFiles;

    /**
     * The current Maven project.
     */
    @Parameter(defaultValue="${project}", readonly=true, required=true)
    private MavenProject project;

    /**
     * The base name of the specific argument handling subclasses defined below.
     * The class names consist of the base name followed by a number indicating
     * the number of arguments that they handle when creating messages or the
     * letter "N" meaning any number of arguments.
     */
    private static final String DESCRIPTOR_CLASS_BASE_NAME = "Arg";

    /**
     * The maximum number of arguments that can be handled by a specific
     * subclass. If you define more subclasses be sure to increment this number
     * appropriately.
     */
    private static final int DESCRIPTOR_MAX_ARG_HANDLER = 11;

    private static final String SPECIFIER_REGEX =
            "%(\\d+\\$)?([-#+ 0,(\\<]*)?(\\d+)?(\\.\\d+)?([tT])?([a-zA-Z%])";

    private static final Pattern SPECIFIER_PATTERN = Pattern
            .compile(SPECIFIER_REGEX);

    /**
     * The end-of-line character for this platform.
     */
    private static final String EOL = System.getProperty("line.separator");

    /**
     * The UTF-8 character set used for encoding/decoding files.
     */
    private static final Charset UTF_8 = Charset.forName("UTF-8");

    /**
     * {@inheritDoc}
     */
    public final void execute() throws MojoExecutionException {
        final File resourceDirectory = getResourceDirectory();

        if (!resourceDirectory.exists()) {
            throw new MojoExecutionException("Source directory "
                    + resourceDirectory.getPath() + " does not exist");
        } else if (!resourceDirectory.isDirectory()) {
            throw new MojoExecutionException("Source directory "
                    + resourceDirectory.getPath() + " is not a directory");
        }

        final File targetDirectory = getTargetDirectory();

        if (!targetDirectory.exists()) {
            if (targetDirectory.mkdirs()) {
                getLog().info(
                        "Created message output directory: "
                                + targetDirectory.getPath());
            } else {
                throw new MojoExecutionException(
                        "Unable to create message output directory: "
                                + targetDirectory.getPath());
            }
        } else if (!targetDirectory.isDirectory()) {
            throw new MojoExecutionException("Output directory "
                    + targetDirectory.getPath() + " is not a directory");
        }

        if (project != null) {
            getLog().info(
                    "Adding source directory: " + targetDirectory.getPath());
            addNewSourceDirectory(targetDirectory);
        }

        for (final String messageFile : messageFiles) {
            processMessageFile(new MessageFile(messageFile));
        }
    }

    /**
     * Adds the generated source directory to the compilation path.
     *
     * @param targetDirectory
     *            The source directory to be added.
     */
    abstract void addNewSourceDirectory(final File targetDirectory);

    /**
     * Returns the resource directory containing the message files.
     *
     * @return The resource directory containing the message files.
     */
    abstract File getResourceDirectory();

    /**
     * Returns the target directory in which the source files should be
     * generated.
     *
     * @return The target directory in which the source files should be
     *         generated.
     */
    abstract File getTargetDirectory();

    /*
     * Returns the message Java stub file from this plugin's resources.
     */
    private InputStream getStubFile() throws MojoExecutionException {
        return getClass().getResourceAsStream("Messages.java.stub");
    }

    private void processMessageFile(final MessageFile messageFile) throws MojoExecutionException {
        final File resourceDirectory = getResourceDirectory();
        final File targetDirectory = getTargetDirectory();

        final File sourceFile = messageFile.getResourceFile(resourceDirectory);
        final File outputFile = messageFile.getOutputFile(targetDirectory);

        // Decide whether to generate messages based on modification
        // times and print status messages.
        if (!sourceFile.exists()) {
            throw new MojoExecutionException("Message file "
                    + messageFile.getName() + " does not exist");
        }

        if (outputFile.exists()) {
            if (force || sourceFile.lastModified() > outputFile.lastModified()) {
                if (!outputFile.delete()) {
                    throw new MojoExecutionException(
                            "Unable to continue because the old message file "
                                    + messageFile.getName()
                                    + " could not be deleted");
                }

                getLog().info(
                        "Regenerating " + outputFile.getName() + " from "
                                + sourceFile.getName());
            } else {
                getLog().info(outputFile.getName() + " is up to date");
                return;
            }
        } else {
            final File packageDirectory = outputFile.getParentFile();
            if (!packageDirectory.exists()) {
                if (!packageDirectory.mkdirs()) {
                    throw new MojoExecutionException(
                            "Unable to create message output directory: "
                                    + packageDirectory.getPath());
                }
            }
            getLog().info(
                    "Generating " + outputFile.getName() + " from "
                            + sourceFile.getName());
        }

        BufferedReader stubReader = null;
        PrintWriter outputWriter = null;

        try {
            stubReader = new BufferedReader(new InputStreamReader(
                    getStubFile(), UTF_8));
            outputWriter = new PrintWriter(outputFile, "UTF-8");

            final Properties properties = new Properties();
            final FileInputStream propertiesFile = new FileInputStream(
                    sourceFile);
            try {
                properties.load(propertiesFile);
            } finally {
                try {
                    propertiesFile.close();
                } catch (Exception ignored) {
                    // Ignore.
                }
            }

            for (String stubLine = stubReader.readLine(); stubLine != null; stubLine = stubReader
                    .readLine()) {
                if (stubLine.contains("${MESSAGES}")) {
                    final Map<MessagePropertyKey, String> propertyMap =
                            new TreeMap<MessagePropertyKey, String>();

                    for (final Map.Entry<Object, Object> property : properties
                            .entrySet()) {
                        final String propKey = property.getKey().toString();
                        final MessagePropertyKey key = MessagePropertyKey
                                .valueOf(propKey);
                        propertyMap.put(key, property.getValue().toString());
                    }

                    int usesOfGenericDescriptor = 0;

                    for (final Map.Entry<MessagePropertyKey, String> property : propertyMap
                            .entrySet()) {
                        final MessageDescriptorDeclaration message =
                                new MessageDescriptorDeclaration(property.getKey(),
                                        property.getValue());

                        message.setConstructorArguments(
                                messageFile.getClassName() + ".class",
                                "RESOURCE",
                                quote(property.getKey().toString()),
                                String.valueOf(property.getKey().getOrdinal()));
                        outputWriter.println(message.toString());
                        outputWriter.println();

                        // Keep track of when we use the generic descriptor so
                        // that we can report it later
                        if (message.useGenericMessageTypeClass()) {
                            usesOfGenericDescriptor++;
                        }
                    }

                    getLog().debug(
                            "  Generated " + propertyMap.size()
                                    + " LocalizableMessage");
                    getLog().debug(
                            "  Number of LocalizableMessageDescriptor.ArgN: "
                                    + usesOfGenericDescriptor);
                } else {
                    stubLine = stubLine.replace("${PACKAGE}",
                            messageFile.getPackageName());
                    stubLine = stubLine.replace("${CLASS_NAME}",
                            messageFile.getClassName());
                    stubLine = stubLine.replace("${FILE_NAME}",
                            messageFile.getName());
                    stubLine = stubLine.replace("${RESOURCE_BUNDLE_NAME}",
                            messageFile.getResourceBundleName());
                    outputWriter.println(stubLine);
                }
            }
        } catch (final IOException e) {
            // Don't leave a malformed file laying around. Delete it so it will
            // be forced to be regenerated.
            if (outputFile.exists()) {
                outputFile.deleteOnExit();
            }
            throw new MojoExecutionException(
                    "An IO error occurred while generating the message file: "
                            + e);
        } finally {
            if (stubReader != null) {
                try {
                    stubReader.close();
                } catch (final Exception e) {
                    // Ignore.
                }
            }

            if (outputWriter != null) {
                try {
                    outputWriter.close();
                } catch (final Exception e) {
                    // Ignore.
                }
            }
        }

    }

    private String quote(final String s) {
        return new StringBuilder().append("\"").append(s).append("\"")
                .toString();
    }

    /**
     * Returns the Maven project associated with this Mojo.
     *
     * @return The Maven project associated with this Mojo.
     */
    protected MavenProject getMavenProject() {
        return project;
    }
}