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 * 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 class TranslationCheck extends AbstractFileSetCheck {
097
098    /**
099     * 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}