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 java.io.BufferedReader;
20  import java.io.File;
21  import java.io.FileInputStream;
22  import java.io.IOException;
23  import java.io.InputStream;
24  import java.io.InputStreamReader;
25  import java.io.PrintWriter;
26  import java.nio.charset.Charset;
27  import java.util.ArrayList;
28  import java.util.Arrays;
29  import java.util.Calendar;
30  import java.util.List;
31  import java.util.Map;
32  import java.util.Properties;
33  import java.util.TreeMap;
34  import java.util.UnknownFormatConversionException;
35  import java.util.regex.Matcher;
36  import java.util.regex.Pattern;
37  
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 = 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("}").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     public final void execute() throws MojoExecutionException {
545         final File resourceDirectory = getResourceDirectory();
546 
547         if (!resourceDirectory.exists()) {
548             throw new MojoExecutionException("Source directory "
549                     + resourceDirectory.getPath() + " does not exist");
550         } else if (!resourceDirectory.isDirectory()) {
551             throw new MojoExecutionException("Source directory "
552                     + resourceDirectory.getPath() + " is not a directory");
553         }
554 
555         final File targetDirectory = getTargetDirectory();
556 
557         if (!targetDirectory.exists()) {
558             if (targetDirectory.mkdirs()) {
559                 getLog().info(
560                         "Created message output directory: "
561                                 + targetDirectory.getPath());
562             } else {
563                 throw new MojoExecutionException(
564                         "Unable to create message output directory: "
565                                 + targetDirectory.getPath());
566             }
567         } else if (!targetDirectory.isDirectory()) {
568             throw new MojoExecutionException("Output directory "
569                     + targetDirectory.getPath() + " is not a directory");
570         }
571 
572         if (project != null) {
573             getLog().info(
574                     "Adding source directory: " + targetDirectory.getPath());
575             addNewSourceDirectory(targetDirectory);
576         }
577 
578         for (final String messageFile : messageFiles) {
579             processMessageFile(new MessageFile(messageFile));
580         }
581     }
582 
583     /**
584      * Adds the generated source directory to the compilation path.
585      *
586      * @param targetDirectory
587      *            The source directory to be added.
588      */
589     abstract void addNewSourceDirectory(final File targetDirectory);
590 
591     /**
592      * Returns the resource directory containing the message files.
593      *
594      * @return The resource directory containing the message files.
595      */
596     abstract File getResourceDirectory();
597 
598     /**
599      * Returns the target directory in which the source files should be
600      * generated.
601      *
602      * @return The target directory in which the source files should be
603      *         generated.
604      */
605     abstract File getTargetDirectory();
606 
607     /*
608      * Returns the message Java stub file from this plugin's resources.
609      */
610     private InputStream getStubFile() throws MojoExecutionException {
611         return getClass().getResourceAsStream("Messages.java.stub");
612     }
613 
614     private void processMessageFile(final MessageFile messageFile) throws MojoExecutionException {
615         final File resourceDirectory = getResourceDirectory();
616         final File targetDirectory = getTargetDirectory();
617 
618         final File sourceFile = messageFile.getResourceFile(resourceDirectory);
619         final File outputFile = messageFile.getOutputFile(targetDirectory);
620 
621         // Decide whether to generate messages based on modification
622         // times and print status messages.
623         if (!sourceFile.exists()) {
624             throw new MojoExecutionException("Message file "
625                     + messageFile.getName() + " does not exist");
626         }
627 
628         if (outputFile.exists()) {
629             if (force || sourceFile.lastModified() > outputFile.lastModified()) {
630                 if (!outputFile.delete()) {
631                     throw new MojoExecutionException(
632                             "Unable to continue because the old message file "
633                                     + messageFile.getName()
634                                     + " could not be deleted");
635                 }
636 
637                 getLog().info(
638                         "Regenerating " + outputFile.getName() + " from "
639                                 + sourceFile.getName());
640             } else {
641                 getLog().info(outputFile.getName() + " is up to date");
642                 return;
643             }
644         } else {
645             final File packageDirectory = outputFile.getParentFile();
646             if (!packageDirectory.exists()) {
647                 if (!packageDirectory.mkdirs()) {
648                     throw new MojoExecutionException(
649                             "Unable to create message output directory: "
650                                     + packageDirectory.getPath());
651                 }
652             }
653             getLog().info(
654                     "Generating " + outputFile.getName() + " from "
655                             + sourceFile.getName());
656         }
657 
658         BufferedReader stubReader = null;
659         PrintWriter outputWriter = null;
660 
661         try {
662             stubReader = new BufferedReader(new InputStreamReader(
663                     getStubFile(), UTF_8));
664             outputWriter = new PrintWriter(outputFile, "UTF-8");
665 
666             final Properties properties = new Properties();
667             final FileInputStream propertiesFile = new FileInputStream(
668                     sourceFile);
669             try {
670                 properties.load(propertiesFile);
671             } finally {
672                 try {
673                     propertiesFile.close();
674                 } catch (Exception ignored) {
675                     // Ignore.
676                 }
677             }
678 
679             for (String stubLine = stubReader.readLine(); stubLine != null; stubLine = stubReader
680                     .readLine()) {
681                 if (stubLine.contains("${MESSAGES}")) {
682                     final Map<MessagePropertyKey, String> propertyMap =
683                             new TreeMap<MessagePropertyKey, String>();
684 
685                     for (final Map.Entry<Object, Object> property : properties
686                             .entrySet()) {
687                         final String propKey = property.getKey().toString();
688                         final MessagePropertyKey key = MessagePropertyKey
689                                 .valueOf(propKey);
690                         propertyMap.put(key, property.getValue().toString());
691                     }
692 
693                     int usesOfGenericDescriptor = 0;
694 
695                     for (final Map.Entry<MessagePropertyKey, String> property : propertyMap
696                             .entrySet()) {
697                         final MessageDescriptorDeclaration message =
698                                 new MessageDescriptorDeclaration(property.getKey(),
699                                         property.getValue());
700 
701                         message.setConstructorArguments(
702                                 messageFile.getClassName() + ".class",
703                                 "RESOURCE",
704                                 quote(property.getKey().toString()),
705                                 String.valueOf(property.getKey().getOrdinal()));
706                         outputWriter.println(message.toString());
707                         outputWriter.println();
708 
709                         // Keep track of when we use the generic descriptor so
710                         // that we can report it later
711                         if (message.useGenericMessageTypeClass()) {
712                             usesOfGenericDescriptor++;
713                         }
714                     }
715 
716                     getLog().debug(
717                             "  Generated " + propertyMap.size()
718                                     + " LocalizableMessage");
719                     getLog().debug(
720                             "  Number of LocalizableMessageDescriptor.ArgN: "
721                                     + usesOfGenericDescriptor);
722                 } else {
723                     stubLine = stubLine.replace("${PACKAGE}",
724                             messageFile.getPackageName());
725                     stubLine = stubLine.replace("${CLASS_NAME}",
726                             messageFile.getClassName());
727                     stubLine = stubLine.replace("${FILE_NAME}",
728                             messageFile.getName());
729                     stubLine = stubLine.replace("${RESOURCE_BUNDLE_NAME}",
730                             messageFile.getResourceBundleName());
731                     outputWriter.println(stubLine);
732                 }
733             }
734         } catch (final IOException e) {
735             // Don't leave a malformed file laying around. Delete it so it will
736             // be forced to be regenerated.
737             if (outputFile.exists()) {
738                 outputFile.deleteOnExit();
739             }
740             throw new MojoExecutionException(
741                     "An IO error occurred while generating the message file: "
742                             + e);
743         } finally {
744             if (stubReader != null) {
745                 try {
746                     stubReader.close();
747                 } catch (final Exception e) {
748                     // Ignore.
749                 }
750             }
751 
752             if (outputWriter != null) {
753                 try {
754                     outputWriter.close();
755                 } catch (final Exception e) {
756                     // Ignore.
757                 }
758             }
759         }
760 
761     }
762 
763     private String quote(final String s) {
764         return new StringBuilder().append("\"").append(s).append("\"")
765                 .toString();
766     }
767 
768     /**
769      * Returns the Maven project associated with this Mojo.
770      *
771      * @return The Maven project associated with this Mojo.
772      */
773     protected MavenProject getMavenProject() {
774         return project;
775     }
776 }