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