View Javadoc
1   /*
2    * The contents of this file are subject to the terms of the Common Development and
3    * Distribution License (the License). You may not use this file except in compliance with the
4    * License.
5    *
6    * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7    * specific language governing permission and limitations under the License.
8    *
9    * When distributing Covered Software, include this CDDL Header Notice in each file and include
10   * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11   * Header, with the fields enclosed by brackets [] replaced by your own identifying
12   * information: "Portions Copyrighted [year] [name of copyright owner]".
13   *
14   *      Copyright 2011-2013 ForgeRock AS.
15   *      Portions Copyright 2017 Wren Security.
16   */
17  package org.forgerock.i18n.maven;
18  
19  import com.google.common.xml.XmlEscapers;
20  import java.io.BufferedReader;
21  import java.io.File;
22  import java.io.FileInputStream;
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.io.InputStreamReader;
26  import java.io.PrintWriter;
27  import java.nio.charset.Charset;
28  import java.util.ArrayList;
29  import java.util.Arrays;
30  import java.util.Calendar;
31  import java.util.List;
32  import java.util.Map;
33  import java.util.Properties;
34  import java.util.TreeMap;
35  import java.util.UnknownFormatConversionException;
36  import java.util.regex.Matcher;
37  import java.util.regex.Pattern;
38  import org.apache.maven.plugin.AbstractMojo;
39  import org.apache.maven.plugin.MojoExecutionException;
40  import org.apache.maven.plugins.annotations.Parameter;
41  import org.apache.maven.project.MavenProject;
42  
43  /**
44   * Abstract Mojo implementation for generating message source files from a one
45   * or more property files.
46   */
47  abstract class AbstractGenerateMessagesMojo extends AbstractMojo {
48      /**
49       * A message file descriptor passed in from the POM configuration.
50       */
51      static final class MessageFile {
52          /**
53           * The name of the message property file to be processed.
54           */
55          private final String name;
56  
57          /**
58           * Creates a new message file.
59           *
60           * @param name
61           *            The name of the message file relative to the resource
62           *            directory.
63           */
64          MessageFile(final String name) {
65              this.name = name;
66          }
67  
68          /**
69           * {@inheritDoc}
70           */
71          @Override
72          public String toString() {
73              return name;
74          }
75  
76          /**
77           * Returns the class name (without package) which will contain the
78           * generated message descriptors.
79           *
80           * @return The class name (without package) which will contain the
81           *         generated message descriptors.
82           */
83          String getClassName() {
84              final StringBuilder builder = new StringBuilder();
85              final String shortName = getShortName();
86              boolean upperCaseNextChar = true;
87              for (final char c : shortName.toCharArray()) {
88                  if (c == '_' || c == '-') {
89                      upperCaseNextChar = true;
90                      continue;
91                  }
92  
93                  if (upperCaseNextChar) {
94                      builder.append(Character.toUpperCase(c));
95                      upperCaseNextChar = false;
96                  } else {
97                      builder.append(c);
98                  }
99              }
100             builder.append("Messages");
101             return builder.toString();
102         }
103 
104         /**
105          * The name of the message file relative to the resource directory.
106          *
107          * @return The name of the message file relative to the resource
108          *         directory.
109          */
110         String getName() {
111             return name;
112         }
113 
114         /**
115          * Returns a {@code File} representing the full path to the output Java
116          * file.
117          *
118          * @param outputDirectory
119          *            The target directory.
120          * @return A {@code File} representing the full path to the output Java
121          *         file.
122          */
123         File getOutputFile(final File outputDirectory) {
124             final int lastSlash = name.lastIndexOf('/');
125             final String parentPath = name.substring(0, lastSlash);
126             final String path = parentPath.replace('/', File.separatorChar)
127                     + File.separator + getClassName() + ".java";
128             return new File(outputDirectory, path);
129         }
130 
131         /**
132          * Returns the name of the package containing the message file.
133          *
134          * @return The name of the package containing the message file.
135          */
136         String getPackageName() {
137             final int lastSlash = name.lastIndexOf('/');
138             final String parentPath = name.substring(0, lastSlash);
139             return parentPath.replace('/', '.');
140         }
141 
142         /**
143          * The resource bundle name.
144          *
145          * @return The resource bundle name.
146          */
147         String getResourceBundleName() {
148             return getPackageName() + "." + getShortName();
149         }
150 
151         /**
152          * Returns a {@code File} representing the full path to this message
153          * file.
154          *
155          * @param resourceDirectory
156          *            The resource directory.
157          * @return A {@code File} representing the full path to this message
158          *         file.
159          */
160         File getResourceFile(final File resourceDirectory) {
161             final String path = name.replace('/', File.separatorChar);
162             return new File(resourceDirectory, path);
163         }
164 
165         /**
166          * Returns the name of the message file with the package name and
167          * trailing ".properties" suffix stripped.
168          *
169          * @return The name of the message file with the package name and
170          *         trailing ".properties" suffix stripped.
171          */
172         String getShortName() {
173             final int lastSlash = name.lastIndexOf('/');
174             final String fileName = name.substring(lastSlash + 1);
175             final int lastDot = fileName.lastIndexOf('.');
176             return fileName.substring(0, lastDot);
177         }
178 
179     }
180 
181     /**
182      * Representation of a format specifier (for example %s).
183      */
184     private static final class FormatSpecifier {
185 
186         private final String[] sa;
187 
188         /**
189          * Creates a new specifier.
190          *
191          * @param sa
192          *            Specifier components.
193          */
194         FormatSpecifier(final String[] sa) {
195             this.sa = sa;
196         }
197 
198         /**
199          * Returns a java class associated with a particular formatter based on
200          * the conversion type of the specifier.
201          *
202          * @return Class for representing the type of argument used as a
203          *         replacement for this specifier.
204          */
205         Class<?> getSimpleConversionClass() {
206             Class<?> c = null;
207             final String sa4 = sa[4] != null ? sa[4].toLowerCase() : null;
208             final String sa5 = sa[5] != null ? sa[5].toLowerCase() : null;
209             if ("t".equals(sa4)) {
210                 c = Calendar.class;
211             } else if ("b".equals(sa5)) {
212                 c = Boolean.class;
213             } else if ("h".equals(sa5) /* Hashcode */) {
214                 c = Object.class;
215             } else if ("s".equals(sa5)) {
216                 c = Object.class; /* Conversion using toString() */
217             } else if ("c".equals(sa5)) {
218                 c = Character.class;
219             } else if ("d".equals(sa5) || "o".equals(sa5) || "x".equals(sa5)
220                     || "e".equals(sa5) || "f".equals(sa5) || "g".equals(sa5)
221                     || "a".equals(sa5)) {
222                 c = Number.class;
223             } else if ("n".equals(sa5) || "%".equals(sa5)) {
224                 // ignore literals
225             }
226             return c;
227         }
228 
229         /**
230          * Returns {@code true} if this specifier uses argument indexes (for
231          * example 2$).
232          *
233          * @return boolean {@code true} if this specifier uses argument indexes.
234          */
235         boolean specifiesArgumentIndex() {
236             return this.sa[0] != null;
237         }
238 
239     }
240 
241     /**
242      * Represents a message to be written into the messages files.
243      */
244     private static final class MessageDescriptorDeclaration {
245 
246         private final MessagePropertyKey key;
247         private final String formatString;
248         private final List<FormatSpecifier> specifiers;
249         private final List<Class<?>> classTypes;
250         private String[] constructorArgs;
251 
252         /**
253          * Creates a parameterized instance.
254          *
255          * @param key
256          *            The message key.
257          * @param formatString
258          *            The message format string.
259          */
260         MessageDescriptorDeclaration(final MessagePropertyKey key,
261                 final String formatString) {
262             this.key = key;
263             this.formatString = formatString;
264             this.specifiers = parse(formatString);
265             this.classTypes = new ArrayList<Class<?>>();
266             for (final FormatSpecifier f : specifiers) {
267                 final Class<?> c = f.getSimpleConversionClass();
268                 if (c != null) {
269                     classTypes.add(c);
270                 }
271             }
272         }
273 
274         /**
275          * {@inheritDoc}
276          */
277         @Override
278         public String toString() {
279             final StringBuilder sb = new StringBuilder();
280             sb.append(getComment());
281             sb.append(indent(1));
282             sb.append("public static final ");
283             sb.append(getDescriptorClassDeclaration());
284             sb.append(" ");
285             sb.append(key.getName());
286             sb.append(" =");
287             sb.append(EOL);
288             sb.append(indent(5));
289             sb.append("new ");
290             sb.append(getDescriptorClassDeclaration());
291             sb.append("(");
292             if (constructorArgs != null) {
293                 for (int i = 0; i < constructorArgs.length; i++) {
294                     sb.append(constructorArgs[i]);
295                     if (i < constructorArgs.length - 1) {
296                         sb.append(", ");
297                     }
298                 }
299             }
300             sb.append(");");
301             return sb.toString();
302         }
303 
304         /**
305          * Returns a string representing the message type class' variable
306          * information (for example '<String,Integer>') that is based on the
307          * type of arguments specified by the specifiers in this message.
308          *
309          * @return String representing the message type class parameters.
310          */
311         String getClassTypeVariables() {
312             final StringBuilder sb = new StringBuilder();
313             if (classTypes.size() > 0) {
314                 sb.append("<");
315                 for (int i = 0; i < classTypes.size(); i++) {
316                     final Class<?> c = classTypes.get(i);
317                     if (c != null) {
318                         sb.append(getShortClassName(c));
319                         if (i < classTypes.size() - 1) {
320                             sb.append(", ");
321                         }
322                     }
323                 }
324                 sb.append(">");
325             }
326             return sb.toString();
327         }
328 
329         /**
330          * Returns the javadoc comments that will appear above the messages
331          * declaration in the messages file.
332          *
333          * @return The javadoc comments that will appear above the messages
334          *         declaration in the messages file.
335          */
336         String getComment() {
337             final StringBuilder sb = new StringBuilder();
338             sb.append(indent(1)).append("/**").append(EOL);
339             sb.append(indent(1)).append(" * ").append("<code>").append(EOL);
340 
341             // Unwrapped so that you can search through the descriptor
342             // file for a message and not have to worry about line breaks
343             final String ws = XmlEscapers.xmlContentEscaper().escape(formatString); // wrapText(formatString, 70);
344 
345             final String[] sa = ws.split(EOL);
346             for (final String s : sa) {
347                 sb.append(indent(1)).append(" * ").append(s).append(EOL);
348             }
349 
350             sb.append(indent(1)).append(" * ").append("</code>").append(EOL);
351             sb.append(indent(1)).append(" */").append(EOL);
352             return sb.toString();
353         }
354 
355         /**
356          * Returns the name of the Java class that will be used to represent
357          * this message's type.
358          *
359          * @return The name of the Java class that will be used to represent
360          *         this message's type.
361          */
362         String getDescriptorClassDeclaration() {
363             final StringBuilder sb = new StringBuilder();
364             if (useGenericMessageTypeClass()) {
365                 sb.append("LocalizableMessageDescriptor");
366                 sb.append(".");
367                 sb.append(DESCRIPTOR_CLASS_BASE_NAME);
368                 sb.append("N");
369             } else {
370                 sb.append("LocalizableMessageDescriptor");
371                 sb.append(".");
372                 sb.append(DESCRIPTOR_CLASS_BASE_NAME);
373                 sb.append(classTypes.size());
374                 sb.append(getClassTypeVariables());
375             }
376             return sb.toString();
377         }
378 
379         /**
380          * Sets the arguments that will be supplied in the declaration of the
381          * message.
382          *
383          * @param s
384          *            The array of string arguments that will be passed in the
385          *            constructor.
386          */
387         void setConstructorArguments(final String... s) {
388             this.constructorArgs = s;
389         }
390 
391         private void checkText(final String s) {
392             int idx = s.indexOf('%');
393             // If there are any '%' in the given string, we got a bad format
394             // specifier.
395             if (idx != -1) {
396                 final char c = (idx > s.length() - 2 ? '%' : s.charAt(idx + 1));
397                 throw new UnknownFormatConversionException(String.valueOf(c));
398             }
399         }
400 
401         private String getShortClassName(final Class<?> c) {
402             String name;
403             final String fqName = c.getName();
404             final int i = fqName.lastIndexOf('.');
405             if (i > 0) {
406                 name = fqName.substring(i + 1);
407             } else {
408                 name = fqName;
409             }
410             return name;
411         }
412 
413         private String indent(final int indent) {
414             final char[] blankArray = new char[4 * indent];
415             Arrays.fill(blankArray, ' ');
416             return new String(blankArray);
417         }
418 
419         /**
420          * Returns a list of format specifiers contained in the provided format
421          * string.
422          *
423          * @param s
424          *            The format string.
425          * @return The list of format specifiers.
426          */
427         private List<FormatSpecifier> parse(final String s) {
428             final List<FormatSpecifier> sl = new ArrayList<FormatSpecifier>();
429             final Matcher m = SPECIFIER_PATTERN.matcher(s);
430             int i = 0;
431             while (i < s.length()) {
432                 if (m.find(i)) {
433                     // Anything between the start of the string and the
434                     // beginning of the format specifier is either fixed text or contains
435                     // an invalid format string.
436                     if (m.start() != i) {
437                         // Make sure we didn't miss any invalid format
438                         // specifiers
439                         checkText(s.substring(i, m.start()));
440 
441                         // Assume previous characters were fixed text
442                         // al.add(new FixedString(s.substring(i, m.start())));
443                     }
444 
445                     // Expect 6 groups in regular expression
446                     final String[] sa = new String[6];
447                     for (int j = 0; j < m.groupCount(); j++) {
448                         sa[j] = m.group(j + 1);
449                     }
450                     sl.add(new FormatSpecifier(sa));
451                     i = m.end();
452                 } else {
453                     // No more valid format specifiers. Check for possible
454                     // invalid format specifiers.
455                     checkText(s.substring(i));
456 
457                     // The rest of the string is fixed text
458                     // al.add(new FixedString(s.substring(i)));
459                     break;
460                 }
461             }
462             return sl;
463         }
464 
465         /**
466          * Indicates whether the generic message type class should be used. In
467          * general this is when a format specifier is more complicated than we
468          * support or when the number of arguments exceeds the number of
469          * specific message type classes (MessageType0, MessageType1 ...) that
470          * are defined.
471          *
472          * @return boolean {@code true} if the generic message type class should
473          *         be used.
474          */
475         private boolean useGenericMessageTypeClass() {
476             if (specifiers.size() > DESCRIPTOR_MAX_ARG_HANDLER) {
477                 return true;
478             } else {
479                 for (final FormatSpecifier s : specifiers) {
480                     if (s.specifiesArgumentIndex()) {
481                         return true;
482                     }
483                 }
484             }
485             return false;
486         }
487 
488     }
489 
490     /**
491      * Indicates whether or not message files should be regenerated even if they
492      * are already up to date.
493      */
494     @Parameter(defaultValue="false", required=true)
495     private boolean force;
496 
497     /**
498      * The list of files we want to transfer, relative to the resource
499      * directory.
500      */
501     @Parameter(required=true)
502     private String[] messageFiles;
503 
504     /**
505      * The current Maven project.
506      */
507     @Parameter(defaultValue="${project}", readonly=true, required=true)
508     private MavenProject project;
509 
510     /**
511      * The base name of the specific argument handling subclasses defined below.
512      * The class names consist of the base name followed by a number indicating
513      * the number of arguments that they handle when creating messages or the
514      * letter "N" meaning any number of arguments.
515      */
516     private static final String DESCRIPTOR_CLASS_BASE_NAME = "Arg";
517 
518     /**
519      * The maximum number of arguments that can be handled by a specific
520      * subclass. If you define more subclasses be sure to increment this number
521      * appropriately.
522      */
523     private static final int DESCRIPTOR_MAX_ARG_HANDLER = 11;
524 
525     private static final String SPECIFIER_REGEX =
526             "%(\\d+\\$)?([-#+ 0,(\\<]*)?(\\d+)?(\\.\\d+)?([tT])?([a-zA-Z%])";
527 
528     private static final Pattern SPECIFIER_PATTERN = Pattern
529             .compile(SPECIFIER_REGEX);
530 
531     /**
532      * The end-of-line character for this platform.
533      */
534     private static final String EOL = System.getProperty("line.separator");
535 
536     /**
537      * The UTF-8 character set used for encoding/decoding files.
538      */
539     private static final Charset UTF_8 = Charset.forName("UTF-8");
540 
541     /**
542      * {@inheritDoc}
543      */
544     @Override
545     public final void execute() throws MojoExecutionException {
546         final File resourceDirectory = getResourceDirectory();
547 
548         if (!resourceDirectory.exists()) {
549             throw new MojoExecutionException("Source directory "
550                     + resourceDirectory.getPath() + " does not exist");
551         } else if (!resourceDirectory.isDirectory()) {
552             throw new MojoExecutionException("Source directory "
553                     + resourceDirectory.getPath() + " is not a directory");
554         }
555 
556         final File targetDirectory = getTargetDirectory();
557 
558         if (!targetDirectory.exists()) {
559             if (targetDirectory.mkdirs()) {
560                 getLog().info(
561                         "Created message output directory: "
562                                 + targetDirectory.getPath());
563             } else {
564                 throw new MojoExecutionException(
565                         "Unable to create message output directory: "
566                                 + targetDirectory.getPath());
567             }
568         } else if (!targetDirectory.isDirectory()) {
569             throw new MojoExecutionException("Output directory "
570                     + targetDirectory.getPath() + " is not a directory");
571         }
572 
573         if (project != null) {
574             getLog().info(
575                     "Adding source directory: " + targetDirectory.getPath());
576             addNewSourceDirectory(targetDirectory);
577         }
578 
579         for (final String messageFile : messageFiles) {
580             processMessageFile(new MessageFile(messageFile));
581         }
582     }
583 
584     /**
585      * Adds the generated source directory to the compilation path.
586      *
587      * @param targetDirectory
588      *            The source directory to be added.
589      */
590     abstract void addNewSourceDirectory(final File targetDirectory);
591 
592     /**
593      * Returns the resource directory containing the message files.
594      *
595      * @return The resource directory containing the message files.
596      */
597     abstract File getResourceDirectory();
598 
599     /**
600      * Returns the target directory in which the source files should be
601      * generated.
602      *
603      * @return The target directory in which the source files should be
604      *         generated.
605      */
606     abstract File getTargetDirectory();
607 
608     /*
609      * Returns the message Java stub file from this plugin's resources.
610      */
611     private InputStream getStubFile() throws MojoExecutionException {
612         return getClass().getResourceAsStream("Messages.java.stub");
613     }
614 
615     private void processMessageFile(final MessageFile messageFile) throws MojoExecutionException {
616         final File resourceDirectory = getResourceDirectory();
617         final File targetDirectory = getTargetDirectory();
618 
619         final File sourceFile = messageFile.getResourceFile(resourceDirectory);
620         final File outputFile = messageFile.getOutputFile(targetDirectory);
621 
622         // Decide whether to generate messages based on modification
623         // times and print status messages.
624         if (!sourceFile.exists()) {
625             throw new MojoExecutionException("Message file "
626                     + messageFile.getName() + " does not exist");
627         }
628 
629         if (outputFile.exists()) {
630             if (force || sourceFile.lastModified() > outputFile.lastModified()) {
631                 if (!outputFile.delete()) {
632                     throw new MojoExecutionException(
633                             "Unable to continue because the old message file "
634                                     + messageFile.getName()
635                                     + " could not be deleted");
636                 }
637 
638                 getLog().info(
639                         "Regenerating " + outputFile.getName() + " from "
640                                 + sourceFile.getName());
641             } else {
642                 getLog().info(outputFile.getName() + " is up to date");
643                 return;
644             }
645         } else {
646             final File packageDirectory = outputFile.getParentFile();
647             if (!packageDirectory.exists()) {
648                 if (!packageDirectory.mkdirs()) {
649                     throw new MojoExecutionException(
650                             "Unable to create message output directory: "
651                                     + packageDirectory.getPath());
652                 }
653             }
654             getLog().info(
655                     "Generating " + outputFile.getName() + " from "
656                             + sourceFile.getName());
657         }
658 
659         BufferedReader stubReader = null;
660         PrintWriter outputWriter = null;
661 
662         try {
663             stubReader = new BufferedReader(new InputStreamReader(
664                     getStubFile(), UTF_8));
665             outputWriter = new PrintWriter(outputFile, "UTF-8");
666 
667             final Properties properties = new Properties();
668             final FileInputStream propertiesFile = new FileInputStream(
669                     sourceFile);
670             try {
671                 properties.load(propertiesFile);
672             } finally {
673                 try {
674                     propertiesFile.close();
675                 } catch (Exception ignored) {
676                     // Ignore.
677                 }
678             }
679 
680             for (String stubLine = stubReader.readLine(); stubLine != null; stubLine = stubReader
681                     .readLine()) {
682                 if (stubLine.contains("${MESSAGES}")) {
683                     final Map<MessagePropertyKey, String> propertyMap =
684                             new TreeMap<MessagePropertyKey, String>();
685 
686                     for (final Map.Entry<Object, Object> property : properties
687                             .entrySet()) {
688                         final String propKey = property.getKey().toString();
689                         final MessagePropertyKey key = MessagePropertyKey
690                                 .valueOf(propKey);
691                         propertyMap.put(key, property.getValue().toString());
692                     }
693 
694                     int usesOfGenericDescriptor = 0;
695 
696                     for (final Map.Entry<MessagePropertyKey, String> property : propertyMap
697                             .entrySet()) {
698                         final MessageDescriptorDeclaration message =
699                                 new MessageDescriptorDeclaration(property.getKey(),
700                                         property.getValue());
701 
702                         message.setConstructorArguments(
703                                 messageFile.getClassName() + ".class",
704                                 "RESOURCE",
705                                 quote(property.getKey().toString()),
706                                 String.valueOf(property.getKey().getOrdinal()));
707                         outputWriter.println(message.toString());
708                         outputWriter.println();
709 
710                         // Keep track of when we use the generic descriptor so
711                         // that we can report it later
712                         if (message.useGenericMessageTypeClass()) {
713                             usesOfGenericDescriptor++;
714                         }
715                     }
716 
717                     getLog().debug(
718                             "  Generated " + propertyMap.size()
719                                     + " LocalizableMessage");
720                     getLog().debug(
721                             "  Number of LocalizableMessageDescriptor.ArgN: "
722                                     + usesOfGenericDescriptor);
723                 } else {
724                     stubLine = stubLine.replace("${PACKAGE}",
725                             messageFile.getPackageName());
726                     stubLine = stubLine.replace("${CLASS_NAME}",
727                             messageFile.getClassName());
728                     stubLine = stubLine.replace("${FILE_NAME}",
729                             messageFile.getName());
730                     stubLine = stubLine.replace("${RESOURCE_BUNDLE_NAME}",
731                             messageFile.getResourceBundleName());
732                     outputWriter.println(stubLine);
733                 }
734             }
735         } catch (final IOException e) {
736             // Don't leave a malformed file laying around. Delete it so it will
737             // be forced to be regenerated.
738             if (outputFile.exists()) {
739                 outputFile.deleteOnExit();
740             }
741             throw new MojoExecutionException(
742                     "An IO error occurred while generating the message file: "
743                             + e);
744         } finally {
745             if (stubReader != null) {
746                 try {
747                     stubReader.close();
748                 } catch (final Exception e) {
749                     // Ignore.
750                 }
751             }
752 
753             if (outputWriter != null) {
754                 try {
755                     outputWriter.close();
756                 } catch (final Exception e) {
757                     // Ignore.
758                 }
759             }
760         }
761 
762     }
763 
764     private String quote(final String s) {
765         return new StringBuilder().append("\"").append(s).append("\"")
766                 .toString();
767     }
768 
769     /**
770      * Returns the Maven project associated with this Mojo.
771      *
772      * @return The Maven project associated with this Mojo.
773      */
774     protected MavenProject getMavenProject() {
775         return project;
776     }
777 }