1   ////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code for adherence to a set of rules.
3   // Copyright (C) 2001-2019 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18  ////////////////////////////////////////////////////////////////////////////////
19  
20  package com.puppycrawl.tools.checkstyle.checks;
21  
22  import java.io.File;
23  import java.io.InputStream;
24  import java.nio.file.Files;
25  import java.nio.file.NoSuchFileException;
26  import java.util.Arrays;
27  import java.util.Collections;
28  import java.util.HashMap;
29  import java.util.HashSet;
30  import java.util.Locale;
31  import java.util.Map;
32  import java.util.Map.Entry;
33  import java.util.Optional;
34  import java.util.Properties;
35  import java.util.Set;
36  import java.util.SortedSet;
37  import java.util.TreeSet;
38  import java.util.concurrent.ConcurrentHashMap;
39  import java.util.regex.Matcher;
40  import java.util.regex.Pattern;
41  import java.util.stream.Collectors;
42  
43  import org.apache.commons.logging.Log;
44  import org.apache.commons.logging.LogFactory;
45  
46  import com.puppycrawl.tools.checkstyle.Definitions;
47  import com.puppycrawl.tools.checkstyle.GlobalStatefulCheck;
48  import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck;
49  import com.puppycrawl.tools.checkstyle.api.FileText;
50  import com.puppycrawl.tools.checkstyle.api.LocalizedMessage;
51  import com.puppycrawl.tools.checkstyle.api.MessageDispatcher;
52  import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
53  
54  /**
55   * <p>
56   * The TranslationCheck class helps to ensure the correct translation of code by
57   * checking locale-specific resource files for consistency regarding their keys.
58   * Two locale-specific resource files describing one and the same context are consistent if they
59   * contain the same keys. TranslationCheck also can check an existence of required translations
60   * which must exist in project, if 'requiredTranslations' option is used.
61   * </p>
62   * <p>
63   * An example of how to configure the check is:
64   * </p>
65   * <pre>
66   * &lt;module name="Translation"/&gt;
67   * </pre>
68   * Check has the following options:
69   *
70   * <p><b>baseName</b> - a base name regexp for resource bundles which contain message resources. It
71   * helps the check to distinguish config and localization resources. Default value is
72   * <b>^messages.*$</b>
73   * <p>An example of how to configure the check to validate only bundles which base names start with
74   * "ButtonLabels":
75   * </p>
76   * <pre>
77   * &lt;module name="Translation"&gt;
78   *     &lt;property name="baseName" value="^ButtonLabels.*$"/&gt;
79   * &lt;/module&gt;
80   * </pre>
81   * <p>To configure the check to check only files which have '.properties' and '.translations'
82   * extensions:
83   * </p>
84   * <pre>
85   * &lt;module name="Translation"&gt;
86   *     &lt;property name="fileExtensions" value="properties, translations"/&gt;
87   * &lt;/module&gt;
88   * </pre>
89   *
90   * <p><b>requiredTranslations</b> which allows to specify language codes of required translations
91   * which must exist in project. Language code is composed of the lowercase, two-letter codes as
92   * defined by <a href="https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes">ISO 639-1</a>.
93   * Default value is <b>empty String Set</b> which means that only the existence of
94   * default translation is checked. Note, if you specify language codes (or just one language
95   * code) of required translations the check will also check for existence of default translation
96   * files in project. ATTENTION: the check will perform the validation of ISO codes if the option
97   * is used. So, if you specify, for example, "mm" for language code, TranslationCheck will rise
98   * violation that the language code is incorrect.
99   * <br>
100  *
101  */
102 @GlobalStatefulCheck
103 public class TranslationCheck extends AbstractFileSetCheck {
104 
105     /**
106      * A key is pointing to the warning message text for missing key
107      * in "messages.properties" file.
108      */
109     public static final String MSG_KEY = "translation.missingKey";
110 
111     /**
112      * A key is pointing to the warning message text for missing translation file
113      * in "messages.properties" file.
114      */
115     public static final String MSG_KEY_MISSING_TRANSLATION_FILE =
116         "translation.missingTranslationFile";
117 
118     /** Resource bundle which contains messages for TranslationCheck. */
119     private static final String TRANSLATION_BUNDLE =
120         "com.puppycrawl.tools.checkstyle.checks.messages";
121 
122     /**
123      * A key is pointing to the warning message text for wrong language code
124      * in "messages.properties" file.
125      */
126     private static final String WRONG_LANGUAGE_CODE_KEY = "translation.wrongLanguageCode";
127 
128     /**
129      * Regexp string for default translation files.
130      * For example, messages.properties.
131      */
132     private static final String DEFAULT_TRANSLATION_REGEXP = "^.+\\..+$";
133 
134     /**
135      * Regexp pattern for bundles names which end with language code, followed by country code and
136      * variant suffix. For example, messages_es_ES_UNIX.properties.
137      */
138     private static final Pattern LANGUAGE_COUNTRY_VARIANT_PATTERN =
139         CommonUtil.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\_[A-Za-z]+\\..+$");
140     /**
141      * Regexp pattern for bundles names which end with language code, followed by country code
142      * suffix. For example, messages_es_ES.properties.
143      */
144     private static final Pattern LANGUAGE_COUNTRY_PATTERN =
145         CommonUtil.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\..+$");
146     /**
147      * Regexp pattern for bundles names which end with language code suffix.
148      * For example, messages_es.properties.
149      */
150     private static final Pattern LANGUAGE_PATTERN =
151         CommonUtil.createPattern("^.+\\_[a-z]{2}\\..+$");
152 
153     /** File name format for default translation. */
154     private static final String DEFAULT_TRANSLATION_FILE_NAME_FORMATTER = "%s.%s";
155     /** File name format with language code. */
156     private static final String FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER = "%s_%s.%s";
157 
158     /** Formatting string to form regexp to validate required translations file names. */
159     private static final String REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS =
160         "^%1$s\\_%2$s(\\_[A-Z]{2})?\\.%3$s$|^%1$s\\_%2$s\\_[A-Z]{2}\\_[A-Za-z]+\\.%3$s$";
161     /** Formatting string to form regexp to validate default translations file names. */
162     private static final String REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS = "^%s\\.%s$";
163 
164     /** Logger for TranslationCheck. */
165     private final Log log;
166 
167     /** The files to process. */
168     private final Set<File> filesToProcess = ConcurrentHashMap.newKeySet();
169 
170     /** The base name regexp pattern. */
171     private Pattern baseName;
172 
173     /**
174      * Language codes of required translations for the check (de, pt, ja, etc).
175      */
176     private Set<String> requiredTranslations = new HashSet<>();
177 
178     /**
179      * Creates a new {@code TranslationCheck} instance.
180      */
181     public TranslationCheck() {
182         setFileExtensions("properties");
183         baseName = CommonUtil.createPattern("^messages.*$");
184         log = LogFactory.getLog(TranslationCheck.class);
185     }
186 
187     /**
188      * Sets the base name regexp pattern.
189      * @param baseName base name regexp.
190      */
191     public void setBaseName(Pattern baseName) {
192         this.baseName = baseName;
193     }
194 
195     /**
196      * Sets language codes of required translations for the check.
197      * @param translationCodes a comma separated list of language codes.
198      */
199     public void setRequiredTranslations(String... translationCodes) {
200         requiredTranslations = Arrays.stream(translationCodes).collect(Collectors.toSet());
201         validateUserSpecifiedLanguageCodes(requiredTranslations);
202     }
203 
204     /**
205      * Validates the correctness of user specified language codes for the check.
206      * @param languageCodes user specified language codes for the check.
207      */
208     private void validateUserSpecifiedLanguageCodes(Set<String> languageCodes) {
209         for (String code : languageCodes) {
210             if (!isValidLanguageCode(code)) {
211                 final LocalizedMessage msg = new LocalizedMessage(1, TRANSLATION_BUNDLE,
212                         WRONG_LANGUAGE_CODE_KEY, new Object[] {code}, getId(), getClass(), null);
213                 final String exceptionMessage = String.format(Locale.ROOT,
214                         "%s [%s]", msg.getMessage(), TranslationCheck.class.getSimpleName());
215                 throw new IllegalArgumentException(exceptionMessage);
216             }
217         }
218     }
219 
220     /**
221      * Checks whether user specified language code is correct (is contained in available locales).
222      * @param userSpecifiedLanguageCode user specified language code.
223      * @return true if user specified language code is correct.
224      */
225     private static boolean isValidLanguageCode(final String userSpecifiedLanguageCode) {
226         boolean valid = false;
227         final Locale[] locales = Locale.getAvailableLocales();
228         for (Locale locale : locales) {
229             if (userSpecifiedLanguageCode.equals(locale.toString())) {
230                 valid = true;
231                 break;
232             }
233         }
234         return valid;
235     }
236 
237     @Override
238     public void beginProcessing(String charset) {
239         filesToProcess.clear();
240     }
241 
242     @Override
243     protected void processFiltered(File file, FileText fileText) {
244         // We just collecting files for processing at finishProcessing()
245         filesToProcess.add(file);
246     }
247 
248     @Override
249     public void finishProcessing() {
250         final Set<ResourceBundle> bundles = groupFilesIntoBundles(filesToProcess, baseName);
251         for (ResourceBundle currentBundle : bundles) {
252             checkExistenceOfDefaultTranslation(currentBundle);
253             checkExistenceOfRequiredTranslations(currentBundle);
254             checkTranslationKeys(currentBundle);
255         }
256     }
257 
258     /**
259      * Checks an existence of default translation file in the resource bundle.
260      * @param bundle resource bundle.
261      */
262     private void checkExistenceOfDefaultTranslation(ResourceBundle bundle) {
263         final Optional<String> fileName = getMissingFileName(bundle, null);
264         if (fileName.isPresent()) {
265             logMissingTranslation(bundle.getPath(), fileName.get());
266         }
267     }
268 
269     /**
270      * Checks an existence of translation files in the resource bundle.
271      * The name of translation file begins with the base name of resource bundle which is followed
272      * by '_' and a language code (country and variant are optional), it ends with the extension
273      * suffix.
274      * @param bundle resource bundle.
275      */
276     private void checkExistenceOfRequiredTranslations(ResourceBundle bundle) {
277         for (String languageCode : requiredTranslations) {
278             final Optional<String> fileName = getMissingFileName(bundle, languageCode);
279             if (fileName.isPresent()) {
280                 logMissingTranslation(bundle.getPath(), fileName.get());
281             }
282         }
283     }
284 
285     /**
286      * Returns the name of translation file which is absent in resource bundle or Guava's Optional,
287      * if there is not missing translation.
288      * @param bundle resource bundle.
289      * @param languageCode language code.
290      * @return the name of translation file which is absent in resource bundle or Guava's Optional,
291      *         if there is not missing translation.
292      */
293     private static Optional<String> getMissingFileName(ResourceBundle bundle, String languageCode) {
294         final String fileNameRegexp;
295         final boolean searchForDefaultTranslation;
296         final String extension = bundle.getExtension();
297         final String baseName = bundle.getBaseName();
298         if (languageCode == null) {
299             searchForDefaultTranslation = true;
300             fileNameRegexp = String.format(Locale.ROOT, REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS,
301                     baseName, extension);
302         }
303         else {
304             searchForDefaultTranslation = false;
305             fileNameRegexp = String.format(Locale.ROOT,
306                 REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS, baseName, languageCode, extension);
307         }
308         Optional<String> missingFileName = Optional.empty();
309         if (!bundle.containsFile(fileNameRegexp)) {
310             if (searchForDefaultTranslation) {
311                 missingFileName = Optional.of(String.format(Locale.ROOT,
312                         DEFAULT_TRANSLATION_FILE_NAME_FORMATTER, baseName, extension));
313             }
314             else {
315                 missingFileName = Optional.of(String.format(Locale.ROOT,
316                         FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER, baseName, languageCode, extension));
317             }
318         }
319         return missingFileName;
320     }
321 
322     /**
323      * Logs that translation file is missing.
324      * @param filePath file path.
325      * @param fileName file name.
326      */
327     private void logMissingTranslation(String filePath, String fileName) {
328         final MessageDispatcher dispatcher = getMessageDispatcher();
329         dispatcher.fireFileStarted(filePath);
330         log(1, MSG_KEY_MISSING_TRANSLATION_FILE, fileName);
331         fireErrors(filePath);
332         dispatcher.fireFileFinished(filePath);
333     }
334 
335     /**
336      * Groups a set of files into bundles.
337      * Only files, which names match base name regexp pattern will be grouped.
338      * @param files set of files.
339      * @param baseNameRegexp base name regexp pattern.
340      * @return set of ResourceBundles.
341      */
342     private static Set<ResourceBundle> groupFilesIntoBundles(Set<File> files,
343                                                              Pattern baseNameRegexp) {
344         final Set<ResourceBundle> resourceBundles = new HashSet<>();
345         for (File currentFile : files) {
346             final String fileName = currentFile.getName();
347             final String baseName = extractBaseName(fileName);
348             final Matcher baseNameMatcher = baseNameRegexp.matcher(baseName);
349             if (baseNameMatcher.matches()) {
350                 final String extension = CommonUtil.getFileExtension(fileName);
351                 final String path = getPath(currentFile.getAbsolutePath());
352                 final ResourceBundle newBundle = new ResourceBundle(baseName, path, extension);
353                 final Optional<ResourceBundle> bundle = findBundle(resourceBundles, newBundle);
354                 if (bundle.isPresent()) {
355                     bundle.get().addFile(currentFile);
356                 }
357                 else {
358                     newBundle.addFile(currentFile);
359                     resourceBundles.add(newBundle);
360                 }
361             }
362         }
363         return resourceBundles;
364     }
365 
366     /**
367      * Searches for specific resource bundle in a set of resource bundles.
368      * @param bundles set of resource bundles.
369      * @param targetBundle target bundle to search for.
370      * @return Guava's Optional of resource bundle (present if target bundle is found).
371      */
372     private static Optional<ResourceBundle> findBundle(Set<ResourceBundle> bundles,
373                                                        ResourceBundle targetBundle) {
374         Optional<ResourceBundle> result = Optional.empty();
375         for (ResourceBundle currentBundle : bundles) {
376             if (targetBundle.getBaseName().equals(currentBundle.getBaseName())
377                     && targetBundle.getExtension().equals(currentBundle.getExtension())
378                     && targetBundle.getPath().equals(currentBundle.getPath())) {
379                 result = Optional.of(currentBundle);
380                 break;
381             }
382         }
383         return result;
384     }
385 
386     /**
387      * Extracts the base name (the unique prefix) of resource bundle from translation file name.
388      * For example "messages" is the base name of "messages.properties",
389      * "messages_de_AT.properties", "messages_en.properties", etc.
390      * @param fileName the fully qualified name of the translation file.
391      * @return the extracted base name.
392      */
393     private static String extractBaseName(String fileName) {
394         final String regexp;
395         final Matcher languageCountryVariantMatcher =
396             LANGUAGE_COUNTRY_VARIANT_PATTERN.matcher(fileName);
397         final Matcher languageCountryMatcher = LANGUAGE_COUNTRY_PATTERN.matcher(fileName);
398         final Matcher languageMatcher = LANGUAGE_PATTERN.matcher(fileName);
399         if (languageCountryVariantMatcher.matches()) {
400             regexp = LANGUAGE_COUNTRY_VARIANT_PATTERN.pattern();
401         }
402         else if (languageCountryMatcher.matches()) {
403             regexp = LANGUAGE_COUNTRY_PATTERN.pattern();
404         }
405         else if (languageMatcher.matches()) {
406             regexp = LANGUAGE_PATTERN.pattern();
407         }
408         else {
409             regexp = DEFAULT_TRANSLATION_REGEXP;
410         }
411         // We use substring(...) instead of replace(...), so that the regular expression does
412         // not have to be compiled each time it is used inside 'replace' method.
413         final String removePattern = regexp.substring("^.+".length());
414         return fileName.replaceAll(removePattern, "");
415     }
416 
417     /**
418      * Extracts path from a file name which contains the path.
419      * For example, if file nam is /xyz/messages.properties, then the method
420      * will return /xyz/.
421      * @param fileNameWithPath file name which contains the path.
422      * @return file path.
423      */
424     private static String getPath(String fileNameWithPath) {
425         return fileNameWithPath
426             .substring(0, fileNameWithPath.lastIndexOf(File.separator));
427     }
428 
429     /**
430      * Checks resource files in bundle for consistency regarding their keys.
431      * All files in bundle must have the same key set. If this is not the case
432      * an error message is posted giving information which key misses in which file.
433      * @param bundle resource bundle.
434      */
435     private void checkTranslationKeys(ResourceBundle bundle) {
436         final Set<File> filesInBundle = bundle.getFiles();
437         // build a map from files to the keys they contain
438         final Set<String> allTranslationKeys = new HashSet<>();
439         final Map<File, Set<String>> filesAssociatedWithKeys = new HashMap<>();
440         for (File currentFile : filesInBundle) {
441             final Set<String> keysInCurrentFile = getTranslationKeys(currentFile);
442             allTranslationKeys.addAll(keysInCurrentFile);
443             filesAssociatedWithKeys.put(currentFile, keysInCurrentFile);
444         }
445         checkFilesForConsistencyRegardingTheirKeys(filesAssociatedWithKeys, allTranslationKeys);
446     }
447 
448     /**
449      * Compares th the specified key set with the key sets of the given translation files (arranged
450      * in a map). All missing keys are reported.
451      * @param fileKeys a Map from translation files to their key sets.
452      * @param keysThatMustExist the set of keys to compare with.
453      */
454     private void checkFilesForConsistencyRegardingTheirKeys(Map<File, Set<String>> fileKeys,
455                                                             Set<String> keysThatMustExist) {
456         for (Entry<File, Set<String>> fileKey : fileKeys.entrySet()) {
457             final MessageDispatcher dispatcher = getMessageDispatcher();
458             final String path = fileKey.getKey().getPath();
459             dispatcher.fireFileStarted(path);
460             final Set<String> currentFileKeys = fileKey.getValue();
461             final Set<String> missingKeys = keysThatMustExist.stream()
462                 .filter(key -> !currentFileKeys.contains(key)).collect(Collectors.toSet());
463             for (Object key : missingKeys) {
464                 log(1, MSG_KEY, key);
465             }
466             fireErrors(path);
467             dispatcher.fireFileFinished(path);
468         }
469     }
470 
471     /**
472      * Loads the keys from the specified translation file into a set.
473      * @param file translation file.
474      * @return a Set object which holds the loaded keys.
475      */
476     private Set<String> getTranslationKeys(File file) {
477         Set<String> keys = new HashSet<>();
478         try (InputStream inStream = Files.newInputStream(file.toPath())) {
479             final Properties translations = new Properties();
480             translations.load(inStream);
481             keys = translations.stringPropertyNames();
482         }
483         // -@cs[IllegalCatch] It is better to catch all exceptions since it can throw
484         // a runtime exception.
485         catch (final Exception ex) {
486             logException(ex, file);
487         }
488         return keys;
489     }
490 
491     /**
492      * Helper method to log an exception.
493      * @param exception the exception that occurred
494      * @param file the file that could not be processed
495      */
496     private void logException(Exception exception, File file) {
497         final String[] args;
498         final String key;
499         if (exception instanceof NoSuchFileException) {
500             args = null;
501             key = "general.fileNotFound";
502         }
503         else {
504             args = new String[] {exception.getMessage()};
505             key = "general.exception";
506         }
507         final LocalizedMessage message =
508             new LocalizedMessage(
509                 0,
510                 Definitions.CHECKSTYLE_BUNDLE,
511                 key,
512                 args,
513                 getId(),
514                 getClass(), null);
515         final SortedSet<LocalizedMessage> messages = new TreeSet<>();
516         messages.add(message);
517         getMessageDispatcher().fireErrors(file.getPath(), messages);
518         log.debug("Exception occurred.", exception);
519     }
520 
521     /** Class which represents a resource bundle. */
522     private static class ResourceBundle {
523 
524         /** Bundle base name. */
525         private final String baseName;
526         /** Common extension of files which are included in the resource bundle. */
527         private final String extension;
528         /** Common path of files which are included in the resource bundle. */
529         private final String path;
530         /** Set of files which are included in the resource bundle. */
531         private final Set<File> files;
532 
533         /**
534          * Creates a ResourceBundle object with specific base name, common files extension.
535          * @param baseName bundle base name.
536          * @param path common path of files which are included in the resource bundle.
537          * @param extension common extension of files which are included in the resource bundle.
538          */
539         /* package */ ResourceBundle(String baseName, String path, String extension) {
540             this.baseName = baseName;
541             this.path = path;
542             this.extension = extension;
543             files = new HashSet<>();
544         }
545 
546         public String getBaseName() {
547             return baseName;
548         }
549 
550         public String getPath() {
551             return path;
552         }
553 
554         public String getExtension() {
555             return extension;
556         }
557 
558         public Set<File> getFiles() {
559             return Collections.unmodifiableSet(files);
560         }
561 
562         /**
563          * Adds a file into resource bundle.
564          * @param file file which should be added into resource bundle.
565          */
566         public void addFile(File file) {
567             files.add(file);
568         }
569 
570         /**
571          * Checks whether a resource bundle contains a file which name matches file name regexp.
572          * @param fileNameRegexp file name regexp.
573          * @return true if a resource bundle contains a file which name matches file name regexp.
574          */
575         public boolean containsFile(String fileNameRegexp) {
576             boolean containsFile = false;
577             for (File currentFile : files) {
578                 if (Pattern.matches(fileNameRegexp, currentFile.getName())) {
579                     containsFile = true;
580                     break;
581                 }
582             }
583             return containsFile;
584         }
585 
586     }
587 
588 }