001////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code for adherence to a set of rules.
003// Copyright (C) 2001-2017 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.FileInputStream;
024import java.io.FileNotFoundException;
025import java.io.IOException;
026import java.io.InputStream;
027import java.util.Arrays;
028import java.util.Collections;
029import java.util.HashSet;
030import java.util.List;
031import java.util.Locale;
032import java.util.Optional;
033import java.util.Properties;
034import java.util.Set;
035import java.util.SortedSet;
036import java.util.TreeSet;
037import java.util.regex.Matcher;
038import java.util.regex.Pattern;
039import java.util.stream.Collectors;
040
041import org.apache.commons.logging.Log;
042import org.apache.commons.logging.LogFactory;
043
044import com.google.common.collect.HashMultimap;
045import com.google.common.collect.SetMultimap;
046import com.google.common.io.Closeables;
047import com.puppycrawl.tools.checkstyle.Definitions;
048import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck;
049import com.puppycrawl.tools.checkstyle.api.LocalizedMessage;
050import com.puppycrawl.tools.checkstyle.api.MessageDispatcher;
051import com.puppycrawl.tools.checkstyle.utils.CommonUtils;
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 * @author Alexandra Bunge
101 * @author lkuehne
102 * @author Andrei Selkin
103 */
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    /** Logger for TranslationCheck. */
130    private static final Log LOG = LogFactory.getLog(TranslationCheck.class);
131
132    /**
133     * Regexp string for default translation files.
134     * For example, messages.properties.
135     */
136    private static final String DEFAULT_TRANSLATION_REGEXP = "^.+\\..+$";
137
138    /**
139     * Regexp pattern for bundles names wich end with language code, followed by country code and
140     * variant suffix. For example, messages_es_ES_UNIX.properties.
141     */
142    private static final Pattern LANGUAGE_COUNTRY_VARIANT_PATTERN =
143        CommonUtils.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\_[A-Za-z]+\\..+$");
144    /**
145     * Regexp pattern for bundles names wich end with language code, followed by country code
146     * suffix. For example, messages_es_ES.properties.
147     */
148    private static final Pattern LANGUAGE_COUNTRY_PATTERN =
149        CommonUtils.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\..+$");
150    /**
151     * Regexp pattern for bundles names wich end with language code suffix.
152     * For example, messages_es.properties.
153     */
154    private static final Pattern LANGUAGE_PATTERN =
155        CommonUtils.createPattern("^.+\\_[a-z]{2}\\..+$");
156
157    /** File name format for default translation. */
158    private static final String DEFAULT_TRANSLATION_FILE_NAME_FORMATTER = "%s.%s";
159    /** File name format with language code. */
160    private static final String FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER = "%s_%s.%s";
161
162    /** Formatting string to form regexp to validate required translations file names. */
163    private static final String REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS =
164        "^%1$s\\_%2$s(\\_[A-Z]{2})?\\.%3$s$|^%1$s\\_%2$s\\_[A-Z]{2}\\_[A-Za-z]+\\.%3$s$";
165    /** Formatting string to form regexp to validate default translations file names. */
166    private static final String REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS = "^%s\\.%s$";
167
168    /** The files to process. */
169    private final Set<File> filesToProcess = new HashSet<>();
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 = CommonUtils.createPattern("^messages.*$");
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(0, 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        super.beginProcessing(charset);
240        filesToProcess.clear();
241    }
242
243    @Override
244    protected void processFiltered(File file, List<String> lines) {
245        // We just collecting files for processing at finishProcessing()
246        filesToProcess.add(file);
247    }
248
249    @Override
250    public void finishProcessing() {
251        super.finishProcessing();
252
253        final Set<ResourceBundle> bundles = groupFilesIntoBundles(filesToProcess, baseName);
254        for (ResourceBundle currentBundle : bundles) {
255            checkExistenceOfDefaultTranslation(currentBundle);
256            checkExistenceOfRequiredTranslations(currentBundle);
257            checkTranslationKeys(currentBundle);
258        }
259    }
260
261    /**
262     * Checks an existence of default translation file in the resource bundle.
263     * @param bundle resource bundle.
264     */
265    private void checkExistenceOfDefaultTranslation(ResourceBundle bundle) {
266        final Optional<String> fileName = getMissingFileName(bundle, null);
267        if (fileName.isPresent()) {
268            logMissingTranslation(bundle.getPath(), fileName.get());
269        }
270    }
271
272    /**
273     * Checks an existence of translation files in the resource bundle.
274     * The name of translation file begins with the base name of resource bundle which is followed
275     * by '_' and a language code (country and variant are optional), it ends with the extension
276     * suffix.
277     * @param bundle resource bundle.
278     */
279    private void checkExistenceOfRequiredTranslations(ResourceBundle bundle) {
280        for (String languageCode : requiredTranslations) {
281            final Optional<String> fileName = getMissingFileName(bundle, languageCode);
282            if (fileName.isPresent()) {
283                logMissingTranslation(bundle.getPath(), fileName.get());
284            }
285        }
286    }
287
288    /**
289     * Returns the name of translation file which is absent in resource bundle or Guava's Optional,
290     * if there is not missing translation.
291     * @param bundle resource bundle.
292     * @param languageCode language code.
293     * @return the name of translation file which is absent in resource bundle or Guava's Optional,
294     *         if there is not missing translation.
295     */
296    private static Optional<String> getMissingFileName(ResourceBundle bundle, String languageCode) {
297        final String fileNameRegexp;
298        final boolean searchForDefaultTranslation;
299        final String extension = bundle.getExtension();
300        final String baseName = bundle.getBaseName();
301        if (languageCode == null) {
302            searchForDefaultTranslation = true;
303            fileNameRegexp = String.format(Locale.ROOT, REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS,
304                    baseName, extension);
305        }
306        else {
307            searchForDefaultTranslation = false;
308            fileNameRegexp = String.format(Locale.ROOT,
309                REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS, baseName, languageCode, extension);
310        }
311        Optional<String> missingFileName = Optional.empty();
312        if (!bundle.containsFile(fileNameRegexp)) {
313            if (searchForDefaultTranslation) {
314                missingFileName = Optional.of(String.format(Locale.ROOT,
315                        DEFAULT_TRANSLATION_FILE_NAME_FORMATTER, baseName, extension));
316            }
317            else {
318                missingFileName = Optional.of(String.format(Locale.ROOT,
319                        FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER, baseName, languageCode, extension));
320            }
321        }
322        return missingFileName;
323    }
324
325    /**
326     * Logs that translation file is missing.
327     * @param filePath file path.
328     * @param fileName file name.
329     */
330    private void logMissingTranslation(String filePath, String fileName) {
331        final MessageDispatcher dispatcher = getMessageDispatcher();
332        dispatcher.fireFileStarted(filePath);
333        log(0, MSG_KEY_MISSING_TRANSLATION_FILE, fileName);
334        fireErrors(filePath);
335        dispatcher.fireFileFinished(filePath);
336    }
337
338    /**
339     * Groups a set of files into bundles.
340     * Only files, which names match base name regexp pattern will be grouped.
341     * @param files set of files.
342     * @param baseNameRegexp base name regexp pattern.
343     * @return set of ResourceBundles.
344     */
345    private static Set<ResourceBundle> groupFilesIntoBundles(Set<File> files,
346                                                             Pattern baseNameRegexp) {
347        final Set<ResourceBundle> resourceBundles = new HashSet<>();
348        for (File currentFile : files) {
349            final String fileName = currentFile.getName();
350            final String baseName = extractBaseName(fileName);
351            final Matcher baseNameMatcher = baseNameRegexp.matcher(baseName);
352            if (baseNameMatcher.matches()) {
353                final String extension = CommonUtils.getFileExtension(fileName);
354                final String path = getPath(currentFile.getAbsolutePath());
355                final ResourceBundle newBundle = new ResourceBundle(baseName, path, extension);
356                final Optional<ResourceBundle> bundle = findBundle(resourceBundles, newBundle);
357                if (bundle.isPresent()) {
358                    bundle.get().addFile(currentFile);
359                }
360                else {
361                    newBundle.addFile(currentFile);
362                    resourceBundles.add(newBundle);
363                }
364            }
365        }
366        return resourceBundles;
367    }
368
369    /**
370     * Searches for specific resource bundle in a set of resource bundles.
371     * @param bundles set of resource bundles.
372     * @param targetBundle target bundle to search for.
373     * @return Guava's Optional of resource bundle (present if target bundle is found).
374     */
375    private static Optional<ResourceBundle> findBundle(Set<ResourceBundle> bundles,
376                                                       ResourceBundle targetBundle) {
377        Optional<ResourceBundle> result = Optional.empty();
378        for (ResourceBundle currentBundle : bundles) {
379            if (targetBundle.getBaseName().equals(currentBundle.getBaseName())
380                    && targetBundle.getExtension().equals(currentBundle.getExtension())
381                    && targetBundle.getPath().equals(currentBundle.getPath())) {
382                result = Optional.of(currentBundle);
383                break;
384            }
385        }
386        return result;
387    }
388
389    /**
390     * Extracts the base name (the unique prefix) of resource bundle from translation file name.
391     * For example "messages" is the base name of "messages.properties",
392     * "messages_de_AT.properties", "messages_en.properties", etc.
393     * @param fileName the fully qualified name of the translation file.
394     * @return the extracted base name.
395     */
396    private static String extractBaseName(String fileName) {
397        final String regexp;
398        final Matcher languageCountryVariantMatcher =
399            LANGUAGE_COUNTRY_VARIANT_PATTERN.matcher(fileName);
400        final Matcher languageCountryMatcher = LANGUAGE_COUNTRY_PATTERN.matcher(fileName);
401        final Matcher languageMatcher = LANGUAGE_PATTERN.matcher(fileName);
402        if (languageCountryVariantMatcher.matches()) {
403            regexp = LANGUAGE_COUNTRY_VARIANT_PATTERN.pattern();
404        }
405        else if (languageCountryMatcher.matches()) {
406            regexp = LANGUAGE_COUNTRY_PATTERN.pattern();
407        }
408        else if (languageMatcher.matches()) {
409            regexp = LANGUAGE_PATTERN.pattern();
410        }
411        else {
412            regexp = DEFAULT_TRANSLATION_REGEXP;
413        }
414        // We use substring(...) instead of replace(...), so that the regular expression does
415        // not have to be compiled each time it is used inside 'replace' method.
416        final String removePattern = regexp.substring("^.+".length(), regexp.length());
417        return fileName.replaceAll(removePattern, "");
418    }
419
420    /**
421     * Extracts path from a file name which contains the path.
422     * For example, if file nam is /xyz/messages.properties, then the method
423     * will return /xyz/.
424     * @param fileNameWithPath file name which contains the path.
425     * @return file path.
426     */
427    private static String getPath(String fileNameWithPath) {
428        return fileNameWithPath
429            .substring(0, fileNameWithPath.lastIndexOf(File.separator));
430    }
431
432    /**
433     * Checks resource files in bundle for consistency regarding their keys.
434     * All files in bundle must have the same key set. If this is not the case
435     * an error message is posted giving information which key misses in which file.
436     * @param bundle resource bundle.
437     */
438    private void checkTranslationKeys(ResourceBundle bundle) {
439        final Set<File> filesInBundle = bundle.getFiles();
440        if (filesInBundle.size() > 1) {
441            // build a map from files to the keys they contain
442            final Set<String> allTranslationKeys = new HashSet<>();
443            final SetMultimap<File, String> filesAssociatedWithKeys = HashMultimap.create();
444            for (File currentFile : filesInBundle) {
445                final Set<String> keysInCurrentFile = getTranslationKeys(currentFile);
446                allTranslationKeys.addAll(keysInCurrentFile);
447                filesAssociatedWithKeys.putAll(currentFile, keysInCurrentFile);
448            }
449            checkFilesForConsistencyRegardingTheirKeys(filesAssociatedWithKeys, allTranslationKeys);
450        }
451    }
452
453    /**
454     * Compares th the specified key set with the key sets of the given translation files (arranged
455     * in a map). All missing keys are reported.
456     * @param fileKeys a Map from translation files to their key sets.
457     * @param keysThatMustExist the set of keys to compare with.
458     */
459    private void checkFilesForConsistencyRegardingTheirKeys(SetMultimap<File, String> fileKeys,
460                                                            Set<String> keysThatMustExist) {
461        for (File currentFile : fileKeys.keySet()) {
462            final MessageDispatcher dispatcher = getMessageDispatcher();
463            final String path = currentFile.getPath();
464            dispatcher.fireFileStarted(path);
465            final Set<String> currentFileKeys = fileKeys.get(currentFile);
466            final Set<String> missingKeys = keysThatMustExist.stream()
467                .filter(e -> !currentFileKeys.contains(e)).collect(Collectors.toSet());
468            if (!missingKeys.isEmpty()) {
469                for (Object key : missingKeys) {
470                    log(0, MSG_KEY, key);
471                }
472            }
473            fireErrors(path);
474            dispatcher.fireFileFinished(path);
475        }
476    }
477
478    /**
479     * Loads the keys from the specified translation file into a set.
480     * @param file translation file.
481     * @return a Set object which holds the loaded keys.
482     */
483    private Set<String> getTranslationKeys(File file) {
484        Set<String> keys = new HashSet<>();
485        InputStream inStream = null;
486        try {
487            inStream = new FileInputStream(file);
488            final Properties translations = new Properties();
489            translations.load(inStream);
490            keys = translations.stringPropertyNames();
491        }
492        catch (final IOException ex) {
493            logIoException(ex, file);
494        }
495        finally {
496            Closeables.closeQuietly(inStream);
497        }
498        return keys;
499    }
500
501    /**
502     * Helper method to log an io exception.
503     * @param exception the exception that occurred
504     * @param file the file that could not be processed
505     */
506    private void logIoException(IOException exception, File file) {
507        String[] args = null;
508        String key = "general.fileNotFound";
509        if (!(exception instanceof FileNotFoundException)) {
510            args = new String[] {exception.getMessage()};
511            key = "general.exception";
512        }
513        final LocalizedMessage message =
514            new LocalizedMessage(
515                0,
516                Definitions.CHECKSTYLE_BUNDLE,
517                key,
518                args,
519                getId(),
520                getClass(), null);
521        final SortedSet<LocalizedMessage> messages = new TreeSet<>();
522        messages.add(message);
523        getMessageDispatcher().fireErrors(file.getPath(), messages);
524        LOG.debug("IOException occurred.", exception);
525    }
526
527    /** Class which represents a resource bundle. */
528    private static class ResourceBundle {
529        /** Bundle base name. */
530        private final String baseName;
531        /** Common extension of files which are included in the resource bundle. */
532        private final String extension;
533        /** Common path of files which are included in the resource bundle. */
534        private final String path;
535        /** Set of files which are included in the resource bundle. */
536        private final Set<File> files;
537
538        /**
539         * Creates a ResourceBundle object with specific base name, common files extension.
540         * @param baseName bundle base name.
541         * @param path common path of files which are included in the resource bundle.
542         * @param extension common extension of files which are included in the resource bundle.
543         */
544        ResourceBundle(String baseName, String path, String extension) {
545            this.baseName = baseName;
546            this.path = path;
547            this.extension = extension;
548            files = new HashSet<>();
549        }
550
551        public String getBaseName() {
552            return baseName;
553        }
554
555        public String getPath() {
556            return path;
557        }
558
559        public String getExtension() {
560            return extension;
561        }
562
563        public Set<File> getFiles() {
564            return Collections.unmodifiableSet(files);
565        }
566
567        /**
568         * Adds a file into resource bundle.
569         * @param file file which should be added into resource bundle.
570         */
571        public void addFile(File file) {
572            files.add(file);
573        }
574
575        /**
576         * Checks whether a resource bundle contains a file which name matches file name regexp.
577         * @param fileNameRegexp file name regexp.
578         * @return true if a resource bundle contains a file which name matches file name regexp.
579         */
580        public boolean containsFile(String fileNameRegexp) {
581            boolean containsFile = false;
582            for (File currentFile : files) {
583                if (Pattern.matches(fileNameRegexp, currentFile.getName())) {
584                    containsFile = true;
585                    break;
586                }
587            }
588            return containsFile;
589        }
590    }
591}