001///////////////////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
003// Copyright (C) 2001-2025 the original author or authors.
004//
005// This library is free software; you can redistribute it and/or
006// modify it under the terms of the GNU Lesser General Public
007// License as published by the Free Software Foundation; either
008// version 2.1 of the License, or (at your option) any later version.
009//
010// This library is distributed in the hope that it will be useful,
011// but WITHOUT ANY WARRANTY; without even the implied warranty of
012// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
013// Lesser General Public License for more details.
014//
015// You should have received a copy of the GNU Lesser General Public
016// License along with this library; if not, write to the Free Software
017// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
018///////////////////////////////////////////////////////////////////////////////////////////////
019
020package com.puppycrawl.tools.checkstyle.checks;
021
022import java.io.File;
023import java.io.InputStream;
024import java.nio.file.Files;
025import java.nio.file.NoSuchFileException;
026import java.util.Arrays;
027import java.util.Collections;
028import java.util.HashSet;
029import java.util.Locale;
030import java.util.Map;
031import java.util.Map.Entry;
032import java.util.Optional;
033import java.util.Properties;
034import java.util.Set;
035import java.util.SortedSet;
036import java.util.TreeMap;
037import java.util.TreeSet;
038import java.util.regex.Matcher;
039import java.util.regex.Pattern;
040import java.util.stream.Collectors;
041
042import org.apache.commons.logging.Log;
043import org.apache.commons.logging.LogFactory;
044
045import com.puppycrawl.tools.checkstyle.Definitions;
046import com.puppycrawl.tools.checkstyle.GlobalStatefulCheck;
047import com.puppycrawl.tools.checkstyle.LocalizedMessage;
048import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck;
049import com.puppycrawl.tools.checkstyle.api.FileText;
050import com.puppycrawl.tools.checkstyle.api.MessageDispatcher;
051import com.puppycrawl.tools.checkstyle.api.Violation;
052import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
053
054/**
055 * <div>
056 * Ensures the correct translation of code by checking property files for consistency
057 * regarding their keys. Two property files describing one and the same context
058 * are consistent if they contain the same keys. TranslationCheck also can check
059 * an existence of required translations which must exist in project, if
060 * {@code requiredTranslations} option is used.
061 * </div>
062 *
063 * <p>
064 * Language code for the property {@code requiredTranslations} is composed of
065 * the lowercase, two-letter codes as defined by
066 * <a href="https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes">ISO 639-1</a>.
067 * Default value is empty String Set which means that only the existence of default
068 * translation is checked. Note, if you specify language codes (or just one
069 * language code) of required translations the check will also check for existence
070 * of default translation files in project.
071 * </p>
072 *
073 * <p>
074 * Note: If your project uses preprocessed translation files and the original files do not have the
075 * {@code properties} extension, you can specify additional file extensions
076 * via the {@code fileExtensions} property.
077 * </p>
078 *
079 * <p>
080 * Attention: the check will perform the validation of ISO codes if the option
081 * is used. So, if you specify, for example, "mm" for language code,
082 * TranslationCheck will rise violation that the language code is incorrect.
083 * </p>
084 *
085 * <p>
086 * Attention: this Check could produce false-positives if it is used with
087 * <a href="https://checkstyle.org/config.html#Checker">Checker</a> that use cache
088 * (property "cacheFile") This is known design problem, will be addressed at
089 * <a href="https://github.com/checkstyle/checkstyle/issues/3539">issue</a>.
090 * </p>
091 * <ul>
092 * <li>
093 * Property {@code baseName} - Specify
094 * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/ResourceBundle.html">
095 * Base name</a> of resource bundles which contain message resources.
096 * It helps the check to distinguish config and localization resources.
097 * Type is {@code java.util.regex.Pattern}.
098 * Default value is {@code "^messages.*$"}.
099 * </li>
100 * <li>
101 * Property {@code fileExtensions} - Specify the file extensions of the files to process.
102 * Type is {@code java.lang.String[]}.
103 * Default value is {@code .properties}.
104 * </li>
105 * <li>
106 * Property {@code requiredTranslations} - Specify language codes of required
107 * translations which must exist in project.
108 * Type is {@code java.lang.String[]}.
109 * Default value is {@code ""}.
110 * </li>
111 * </ul>
112 *
113 * <p>
114 * Parent is {@code com.puppycrawl.tools.checkstyle.Checker}
115 * </p>
116 *
117 * <p>
118 * Violation Message Keys:
119 * </p>
120 * <ul>
121 * <li>
122 * {@code translation.missingKey}
123 * </li>
124 * <li>
125 * {@code translation.missingTranslationFile}
126 * </li>
127 * </ul>
128 *
129 * @since 3.0
130 */
131@GlobalStatefulCheck
132public class TranslationCheck extends AbstractFileSetCheck {
133
134    /**
135     * A key is pointing to the warning message text for missing key
136     * in "messages.properties" file.
137     */
138    public static final String MSG_KEY = "translation.missingKey";
139
140    /**
141     * A key is pointing to the warning message text for missing translation file
142     * in "messages.properties" file.
143     */
144    public static final String MSG_KEY_MISSING_TRANSLATION_FILE =
145        "translation.missingTranslationFile";
146
147    /** Resource bundle which contains messages for TranslationCheck. */
148    private static final String TRANSLATION_BUNDLE =
149        "com.puppycrawl.tools.checkstyle.checks.messages";
150
151    /**
152     * A key is pointing to the warning message text for wrong language code
153     * in "messages.properties" file.
154     */
155    private static final String WRONG_LANGUAGE_CODE_KEY = "translation.wrongLanguageCode";
156
157    /**
158     * Regexp string for default translation files.
159     * For example, messages.properties.
160     */
161    private static final String DEFAULT_TRANSLATION_REGEXP = "^.+\\..+$";
162
163    /**
164     * Regexp pattern for bundles names which end with language code, followed by country code and
165     * variant suffix. For example, messages_es_ES_UNIX.properties.
166     */
167    private static final Pattern LANGUAGE_COUNTRY_VARIANT_PATTERN =
168        CommonUtil.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\_[A-Za-z]+\\..+$");
169    /**
170     * Regexp pattern for bundles names which end with language code, followed by country code
171     * suffix. For example, messages_es_ES.properties.
172     */
173    private static final Pattern LANGUAGE_COUNTRY_PATTERN =
174        CommonUtil.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\..+$");
175    /**
176     * Regexp pattern for bundles names which end with language code suffix.
177     * For example, messages_es.properties.
178     */
179    private static final Pattern LANGUAGE_PATTERN =
180        CommonUtil.createPattern("^.+\\_[a-z]{2}\\..+$");
181
182    /** File name format for default translation. */
183    private static final String DEFAULT_TRANSLATION_FILE_NAME_FORMATTER = "%s.%s";
184    /** File name format with language code. */
185    private static final String FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER = "%s_%s.%s";
186
187    /** Formatting string to form regexp to validate required translations file names. */
188    private static final String REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS =
189        "^%1$s\\_%2$s(\\_[A-Z]{2})?\\.%3$s$|^%1$s\\_%2$s\\_[A-Z]{2}\\_[A-Za-z]+\\.%3$s$";
190    /** Formatting string to form regexp to validate default translations file names. */
191    private static final String REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS = "^%s\\.%s$";
192
193    /** Logger for TranslationCheck. */
194    private final Log log;
195
196    /** The files to process. */
197    private final Set<File> filesToProcess = new HashSet<>();
198
199    /**
200     * Specify
201     * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/ResourceBundle.html">
202     * Base name</a> of resource bundles which contain message resources.
203     * It helps the check to distinguish config and localization resources.
204     */
205    private Pattern baseName;
206
207    /**
208     * Specify language codes of required translations which must exist in project.
209     */
210    private Set<String> requiredTranslations = new HashSet<>();
211
212    /**
213     * Creates a new {@code TranslationCheck} instance.
214     */
215    public TranslationCheck() {
216        setFileExtensions("properties");
217        baseName = CommonUtil.createPattern("^messages.*$");
218        log = LogFactory.getLog(TranslationCheck.class);
219    }
220
221    /**
222     * Setter to specify
223     * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/ResourceBundle.html">
224     * Base name</a> of resource bundles which contain message resources.
225     * It helps the check to distinguish config and localization resources.
226     *
227     * @param baseName base name regexp.
228     * @since 6.17
229     */
230    public void setBaseName(Pattern baseName) {
231        this.baseName = baseName;
232    }
233
234    /**
235     * Setter to specify language codes of required translations which must exist in project.
236     *
237     * @param translationCodes language codes.
238     * @since 6.11
239     */
240    public void setRequiredTranslations(String... translationCodes) {
241        requiredTranslations = Arrays.stream(translationCodes)
242            .collect(Collectors.toUnmodifiableSet());
243        validateUserSpecifiedLanguageCodes(requiredTranslations);
244    }
245
246    /**
247     * Validates the correctness of user specified language codes for the check.
248     *
249     * @param languageCodes user specified language codes for the check.
250     * @throws IllegalArgumentException when any item of languageCodes is not valid language code
251     */
252    private void validateUserSpecifiedLanguageCodes(Set<String> languageCodes) {
253        for (String code : languageCodes) {
254            if (!isValidLanguageCode(code)) {
255                final LocalizedMessage msg = new LocalizedMessage(TRANSLATION_BUNDLE,
256                        getClass(), WRONG_LANGUAGE_CODE_KEY, code);
257                throw new IllegalArgumentException(msg.getMessage());
258            }
259        }
260    }
261
262    /**
263     * Checks whether user specified language code is correct (is contained in available locales).
264     *
265     * @param userSpecifiedLanguageCode user specified language code.
266     * @return true if user specified language code is correct.
267     */
268    private static boolean isValidLanguageCode(final String userSpecifiedLanguageCode) {
269        boolean valid = false;
270        final Locale[] locales = Locale.getAvailableLocales();
271        for (Locale locale : locales) {
272            if (userSpecifiedLanguageCode.equals(locale.toString())) {
273                valid = true;
274                break;
275            }
276        }
277        return valid;
278    }
279
280    @Override
281    public void beginProcessing(String charset) {
282        filesToProcess.clear();
283    }
284
285    @Override
286    protected void processFiltered(File file, FileText fileText) {
287        // We are just collecting files for processing at finishProcessing()
288        filesToProcess.add(file);
289    }
290
291    @Override
292    public void finishProcessing() {
293        final Set<ResourceBundle> bundles = groupFilesIntoBundles(filesToProcess, baseName);
294        for (ResourceBundle currentBundle : bundles) {
295            checkExistenceOfDefaultTranslation(currentBundle);
296            checkExistenceOfRequiredTranslations(currentBundle);
297            checkTranslationKeys(currentBundle);
298        }
299    }
300
301    /**
302     * Checks an existence of default translation file in the resource bundle.
303     *
304     * @param bundle resource bundle.
305     */
306    private void checkExistenceOfDefaultTranslation(ResourceBundle bundle) {
307        getMissingFileName(bundle, null)
308            .ifPresent(fileName -> logMissingTranslation(bundle.getPath(), fileName));
309    }
310
311    /**
312     * Checks an existence of translation files in the resource bundle.
313     * The name of translation file begins with the base name of resource bundle which is followed
314     * by '_' and a language code (country and variant are optional), it ends with the extension
315     * suffix.
316     *
317     * @param bundle resource bundle.
318     */
319    private void checkExistenceOfRequiredTranslations(ResourceBundle bundle) {
320        for (String languageCode : requiredTranslations) {
321            getMissingFileName(bundle, languageCode)
322                .ifPresent(fileName -> logMissingTranslation(bundle.getPath(), fileName));
323        }
324    }
325
326    /**
327     * Returns the name of translation file which is absent in resource bundle or Guava's Optional,
328     * if there is not missing translation.
329     *
330     * @param bundle resource bundle.
331     * @param languageCode language code.
332     * @return the name of translation file which is absent in resource bundle or Guava's Optional,
333     *         if there is not missing translation.
334     */
335    private static Optional<String> getMissingFileName(ResourceBundle bundle, String languageCode) {
336        final String fileNameRegexp;
337        final boolean searchForDefaultTranslation;
338        final String extension = bundle.getExtension();
339        final String baseName = bundle.getBaseName();
340        if (languageCode == null) {
341            searchForDefaultTranslation = true;
342            fileNameRegexp = String.format(Locale.ROOT, REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS,
343                    baseName, extension);
344        }
345        else {
346            searchForDefaultTranslation = false;
347            fileNameRegexp = String.format(Locale.ROOT,
348                REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS, baseName, languageCode, extension);
349        }
350        Optional<String> missingFileName = Optional.empty();
351        if (!bundle.containsFile(fileNameRegexp)) {
352            if (searchForDefaultTranslation) {
353                missingFileName = Optional.of(String.format(Locale.ROOT,
354                        DEFAULT_TRANSLATION_FILE_NAME_FORMATTER, baseName, extension));
355            }
356            else {
357                missingFileName = Optional.of(String.format(Locale.ROOT,
358                        FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER, baseName, languageCode, extension));
359            }
360        }
361        return missingFileName;
362    }
363
364    /**
365     * Logs that translation file is missing.
366     *
367     * @param filePath file path.
368     * @param fileName file name.
369     */
370    private void logMissingTranslation(String filePath, String fileName) {
371        final MessageDispatcher dispatcher = getMessageDispatcher();
372        dispatcher.fireFileStarted(filePath);
373        log(1, MSG_KEY_MISSING_TRANSLATION_FILE, fileName);
374        fireErrors(filePath);
375        dispatcher.fireFileFinished(filePath);
376    }
377
378    /**
379     * Groups a set of files into bundles.
380     * Only files, which names match base name regexp pattern will be grouped.
381     *
382     * @param files set of files.
383     * @param baseNameRegexp base name regexp pattern.
384     * @return set of ResourceBundles.
385     */
386    private static Set<ResourceBundle> groupFilesIntoBundles(Set<File> files,
387                                                             Pattern baseNameRegexp) {
388        final Set<ResourceBundle> resourceBundles = new HashSet<>();
389        for (File currentFile : files) {
390            final String fileName = currentFile.getName();
391            final String baseName = extractBaseName(fileName);
392            final Matcher baseNameMatcher = baseNameRegexp.matcher(baseName);
393            if (baseNameMatcher.matches()) {
394                final String extension = CommonUtil.getFileExtension(fileName);
395                final String path = getPath(currentFile.getAbsolutePath());
396                final ResourceBundle newBundle = new ResourceBundle(baseName, path, extension);
397                final Optional<ResourceBundle> bundle = findBundle(resourceBundles, newBundle);
398                if (bundle.isPresent()) {
399                    bundle.orElseThrow().addFile(currentFile);
400                }
401                else {
402                    newBundle.addFile(currentFile);
403                    resourceBundles.add(newBundle);
404                }
405            }
406        }
407        return resourceBundles;
408    }
409
410    /**
411     * Searches for specific resource bundle in a set of resource bundles.
412     *
413     * @param bundles set of resource bundles.
414     * @param targetBundle target bundle to search for.
415     * @return Guava's Optional of resource bundle (present if target bundle is found).
416     */
417    private static Optional<ResourceBundle> findBundle(Set<ResourceBundle> bundles,
418                                                       ResourceBundle targetBundle) {
419        Optional<ResourceBundle> result = Optional.empty();
420        for (ResourceBundle currentBundle : bundles) {
421            if (targetBundle.getBaseName().equals(currentBundle.getBaseName())
422                    && targetBundle.getExtension().equals(currentBundle.getExtension())
423                    && targetBundle.getPath().equals(currentBundle.getPath())) {
424                result = Optional.of(currentBundle);
425                break;
426            }
427        }
428        return result;
429    }
430
431    /**
432     * Extracts the base name (the unique prefix) of resource bundle from translation file name.
433     * For example "messages" is the base name of "messages.properties",
434     * "messages_de_AT.properties", "messages_en.properties", etc.
435     *
436     * @param fileName the fully qualified name of the translation file.
437     * @return the extracted base name.
438     */
439    private static String extractBaseName(String fileName) {
440        final String regexp;
441        final Matcher languageCountryVariantMatcher =
442            LANGUAGE_COUNTRY_VARIANT_PATTERN.matcher(fileName);
443        final Matcher languageCountryMatcher = LANGUAGE_COUNTRY_PATTERN.matcher(fileName);
444        final Matcher languageMatcher = LANGUAGE_PATTERN.matcher(fileName);
445        if (languageCountryVariantMatcher.matches()) {
446            regexp = LANGUAGE_COUNTRY_VARIANT_PATTERN.pattern();
447        }
448        else if (languageCountryMatcher.matches()) {
449            regexp = LANGUAGE_COUNTRY_PATTERN.pattern();
450        }
451        else if (languageMatcher.matches()) {
452            regexp = LANGUAGE_PATTERN.pattern();
453        }
454        else {
455            regexp = DEFAULT_TRANSLATION_REGEXP;
456        }
457        // We use substring(...) instead of replace(...), so that the regular expression does
458        // not have to be compiled each time it is used inside 'replace' method.
459        final String removePattern = regexp.substring("^.+".length());
460        return fileName.replaceAll(removePattern, "");
461    }
462
463    /**
464     * Extracts path from a file name which contains the path.
465     * For example, if the file name is /xyz/messages.properties,
466     * then the method will return /xyz/.
467     *
468     * @param fileNameWithPath file name which contains the path.
469     * @return file path.
470     */
471    private static String getPath(String fileNameWithPath) {
472        return fileNameWithPath
473            .substring(0, fileNameWithPath.lastIndexOf(File.separator));
474    }
475
476    /**
477     * Checks resource files in bundle for consistency regarding their keys.
478     * All files in bundle must have the same key set. If this is not the case
479     * an audit event message is posted giving information which key misses in which file.
480     *
481     * @param bundle resource bundle.
482     */
483    private void checkTranslationKeys(ResourceBundle bundle) {
484        final Set<File> filesInBundle = bundle.getFiles();
485        // build a map from files to the keys they contain
486        final Set<String> allTranslationKeys = new HashSet<>();
487        final Map<File, Set<String>> filesAssociatedWithKeys = new TreeMap<>();
488        for (File currentFile : filesInBundle) {
489            final Set<String> keysInCurrentFile = getTranslationKeys(currentFile);
490            allTranslationKeys.addAll(keysInCurrentFile);
491            filesAssociatedWithKeys.put(currentFile, keysInCurrentFile);
492        }
493        checkFilesForConsistencyRegardingTheirKeys(filesAssociatedWithKeys, allTranslationKeys);
494    }
495
496    /**
497     * Compares th the specified key set with the key sets of the given translation files (arranged
498     * in a map). All missing keys are reported.
499     *
500     * @param fileKeys a Map from translation files to their key sets.
501     * @param keysThatMustExist the set of keys to compare with.
502     */
503    private void checkFilesForConsistencyRegardingTheirKeys(Map<File, Set<String>> fileKeys,
504                                                            Set<String> keysThatMustExist) {
505        for (Entry<File, Set<String>> fileKey : fileKeys.entrySet()) {
506            final Set<String> currentFileKeys = fileKey.getValue();
507            final Set<String> missingKeys = keysThatMustExist.stream()
508                .filter(key -> !currentFileKeys.contains(key))
509                .collect(Collectors.toUnmodifiableSet());
510            if (!missingKeys.isEmpty()) {
511                final MessageDispatcher dispatcher = getMessageDispatcher();
512                final String path = fileKey.getKey().getAbsolutePath();
513                dispatcher.fireFileStarted(path);
514                for (Object key : missingKeys) {
515                    log(1, MSG_KEY, key);
516                }
517                fireErrors(path);
518                dispatcher.fireFileFinished(path);
519            }
520        }
521    }
522
523    /**
524     * Loads the keys from the specified translation file into a set.
525     *
526     * @param file translation file.
527     * @return a Set object which holds the loaded keys.
528     */
529    private Set<String> getTranslationKeys(File file) {
530        Set<String> keys = new HashSet<>();
531        try (InputStream inStream = Files.newInputStream(file.toPath())) {
532            final Properties translations = new Properties();
533            translations.load(inStream);
534            keys = translations.stringPropertyNames();
535        }
536        // -@cs[IllegalCatch] It is better to catch all exceptions since it can throw
537        // a runtime exception.
538        catch (final Exception ex) {
539            logException(ex, file);
540        }
541        return keys;
542    }
543
544    /**
545     * Helper method to log an exception.
546     *
547     * @param exception the exception that occurred
548     * @param file the file that could not be processed
549     */
550    private void logException(Exception exception, File file) {
551        final String[] args;
552        final String key;
553        if (exception instanceof NoSuchFileException) {
554            args = null;
555            key = "general.fileNotFound";
556        }
557        else {
558            args = new String[] {exception.getMessage()};
559            key = "general.exception";
560        }
561        final Violation message =
562            new Violation(
563                0,
564                Definitions.CHECKSTYLE_BUNDLE,
565                key,
566                args,
567                getId(),
568                getClass(), null);
569        final SortedSet<Violation> messages = new TreeSet<>();
570        messages.add(message);
571        getMessageDispatcher().fireErrors(file.getPath(), messages);
572        log.debug("Exception occurred.", exception);
573    }
574
575    /** Class which represents a resource bundle. */
576    private static final class ResourceBundle {
577
578        /** Bundle base name. */
579        private final String baseName;
580        /** Common extension of files which are included in the resource bundle. */
581        private final String extension;
582        /** Common path of files which are included in the resource bundle. */
583        private final String path;
584        /** Set of files which are included in the resource bundle. */
585        private final Set<File> files;
586
587        /**
588         * Creates a ResourceBundle object with specific base name, common files extension.
589         *
590         * @param baseName bundle base name.
591         * @param path common path of files which are included in the resource bundle.
592         * @param extension common extension of files which are included in the resource bundle.
593         */
594        private ResourceBundle(String baseName, String path, String extension) {
595            this.baseName = baseName;
596            this.path = path;
597            this.extension = extension;
598            files = new HashSet<>();
599        }
600
601        /**
602         * Returns the bundle base name.
603         *
604         * @return the bundle base name
605         */
606        public String getBaseName() {
607            return baseName;
608        }
609
610        /**
611         * Returns the common path of files which are included in the resource bundle.
612         *
613         * @return the common path of files
614         */
615        public String getPath() {
616            return path;
617        }
618
619        /**
620         * Returns the common extension of files which are included in the resource bundle.
621         *
622         * @return the common extension of files
623         */
624        public String getExtension() {
625            return extension;
626        }
627
628        /**
629         * Returns the set of files which are included in the resource bundle.
630         *
631         * @return the set of files
632         */
633        public Set<File> getFiles() {
634            return Collections.unmodifiableSet(files);
635        }
636
637        /**
638         * Adds a file into resource bundle.
639         *
640         * @param file file which should be added into resource bundle.
641         */
642        public void addFile(File file) {
643            files.add(file);
644        }
645
646        /**
647         * Checks whether a resource bundle contains a file which name matches file name regexp.
648         *
649         * @param fileNameRegexp file name regexp.
650         * @return true if a resource bundle contains a file which name matches file name regexp.
651         */
652        public boolean containsFile(String fileNameRegexp) {
653            boolean containsFile = false;
654            for (File currentFile : files) {
655                if (Pattern.matches(fileNameRegexp, currentFile.getName())) {
656                    containsFile = true;
657                    break;
658                }
659            }
660            return containsFile;
661        }
662
663    }
664
665}