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