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