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