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