001////////////////////////////////////////////////////////////////////////////////
002// checkstyle: Checks Java source code for adherence to a set of rules.
003// Copyright (C) 2001-2020 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 * Ensures the correct translation of code by checking property files for consistency
057 * regarding their keys. Two property files describing one and the same context
058 * are consistent if they contain the same keys. TranslationCheck also can check
059 * an existence of required translations which must exist in project, if
060 * {@code requiredTranslations} option is used.
061 * </p>
062 * <p>
063 * Consider the following properties file in the same directory:
064 * </p>
065 * <pre>
066 * #messages.properties
067 * hello=Hello
068 * cancel=Cancel
069 *
070 * #messages_de.properties
071 * hell=Hallo
072 * ok=OK
073 * </pre>
074 * <p>
075 * The Translation check will find the typo in the German {@code hello} key,
076 * the missing {@code ok} key in the default resource file and the missing
077 * {@code cancel} key in the German resource file:
078 * </p>
079 * <pre>
080 * messages_de.properties: Key 'hello' missing.
081 * messages_de.properties: Key 'cancel' missing.
082 * messages.properties: Key 'hell' missing.
083 * messages.properties: Key 'ok' missing.
084 * </pre>
085 * <p>
086 * Language code for the property {@code requiredTranslations} is composed of
087 * the lowercase, two-letter codes as defined by
088 * <a href="https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes">ISO 639-1</a>.
089 * Default value is empty String Set which means that only the existence of default
090 * translation is checked. Note, if you specify language codes (or just one
091 * language code) of required translations the check will also check for existence
092 * of default translation files in project.
093 * </p>
094 * <p>
095 * 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,
097 * TranslationCheck will rise violation that the language code is incorrect.
098 * </p>
099 * <p>
100 * Attention: this Check could produce false-positives if it is used with
101 * <a href="https://checkstyle.org/config.html#Checker">Checker</a> that use cache
102 * (property "cacheFile") This is known design problem, will be addressed at
103 * <a href="https://github.com/checkstyle/checkstyle/issues/3539">issue</a>.
104 * </p>
105 * <ul>
106 * <li>
107 * Property {@code fileExtensions} - Specify file type extension to identify
108 * translation files. Setting this property is typically only required if your
109 * translation files are preprocessed and the original files do not have
110 * the extension {@code .properties}
111 * Type is {@code java.lang.String[]}.
112 * Default value is {@code .properties}.
113 * </li>
114 * <li>
115 * Property {@code baseName} - Specify
116 * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/ResourceBundle.html">
117 * Base name</a> of resource bundles which contain message resources.
118 * It helps the check to distinguish config and localization resources.
119 * Type is {@code java.util.regex.Pattern}.
120 * Default value is {@code "^messages.*$"}.
121 * </li>
122 * <li>
123 * Property {@code requiredTranslations} - Specify language codes of required
124 * translations which must exist in project.
125 * Type is {@code java.lang.String[]}.
126 * Default value is {@code {}}.
127 * </li>
128 * </ul>
129 * <p>
130 * To configure the check to check only files which have '.properties' and
131 * '.translations' extensions:
132 * </p>
133 * <pre>
134 * &lt;module name="Translation"&gt;
135 *   &lt;property name="fileExtensions" value="properties, translations"/&gt;
136 * &lt;/module&gt;
137 * </pre>
138 * <p>
139 * Note, that files with the same path and base name but which have different
140 * extensions will be considered as files that belong to different resource bundles.
141 * </p>
142 * <p>
143 * An example of how to configure the check to validate only bundles which base
144 * names start with "ButtonLabels":
145 * </p>
146 * <pre>
147 * &lt;module name="Translation"&gt;
148 *   &lt;property name="baseName" value="^ButtonLabels.*$"/&gt;
149 * &lt;/module&gt;
150 * </pre>
151 * <p>
152 * To configure the check to check existence of Japanese and French translations:
153 * </p>
154 * <pre>
155 * &lt;module name="Translation"&gt;
156 *   &lt;property name="requiredTranslations" value="ja, fr"/&gt;
157 * &lt;/module&gt;
158 * </pre>
159 * <p>
160 * The following example shows how the check works if there is a message bundle
161 * which element name contains language code, county code, platform name.
162 * Consider that we have the below configuration:
163 * </p>
164 * <pre>
165 * &lt;module name="Translation"&gt;
166 *   &lt;property name="requiredTranslations" value="es, fr, de"/&gt;
167 * &lt;/module&gt;
168 * </pre>
169 * <p>
170 * As we can see from the configuration, the TranslationCheck was configured
171 * to check an existence of 'es', 'fr' and 'de' translations. Lets assume that
172 * we have the resource bundle:
173 * </p>
174 * <pre>
175 * messages_home.properties
176 * messages_home_es_US.properties
177 * messages_home_fr_CA_UNIX.properties
178 * </pre>
179 * <p>
180 * Than the check will rise the following violation: "0: Properties file
181 * 'messages_home_de.properties' is missing."
182 * </p>
183 * <p>
184 * Parent is {@code com.puppycrawl.tools.checkstyle.Checker}
185 * </p>
186 * <p>
187 * Violation Message Keys:
188 * </p>
189 * <ul>
190 * <li>
191 * {@code translation.missingKey}
192 * </li>
193 * <li>
194 * {@code translation.missingTranslationFile}
195 * </li>
196 * </ul>
197 *
198 * @since 3.0
199 */
200@GlobalStatefulCheck
201public class TranslationCheck extends AbstractFileSetCheck {
202
203    /**
204     * A key is pointing to the warning message text for missing key
205     * in "messages.properties" file.
206     */
207    public static final String MSG_KEY = "translation.missingKey";
208
209    /**
210     * A key is pointing to the warning message text for missing translation file
211     * in "messages.properties" file.
212     */
213    public static final String MSG_KEY_MISSING_TRANSLATION_FILE =
214        "translation.missingTranslationFile";
215
216    /** Resource bundle which contains messages for TranslationCheck. */
217    private static final String TRANSLATION_BUNDLE =
218        "com.puppycrawl.tools.checkstyle.checks.messages";
219
220    /**
221     * A key is pointing to the warning message text for wrong language code
222     * in "messages.properties" file.
223     */
224    private static final String WRONG_LANGUAGE_CODE_KEY = "translation.wrongLanguageCode";
225
226    /**
227     * Regexp string for default translation files.
228     * For example, messages.properties.
229     */
230    private static final String DEFAULT_TRANSLATION_REGEXP = "^.+\\..+$";
231
232    /**
233     * Regexp pattern for bundles names which end with language code, followed by country code and
234     * variant suffix. For example, messages_es_ES_UNIX.properties.
235     */
236    private static final Pattern LANGUAGE_COUNTRY_VARIANT_PATTERN =
237        CommonUtil.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\_[A-Za-z]+\\..+$");
238    /**
239     * Regexp pattern for bundles names which end with language code, followed by country code
240     * suffix. For example, messages_es_ES.properties.
241     */
242    private static final Pattern LANGUAGE_COUNTRY_PATTERN =
243        CommonUtil.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\..+$");
244    /**
245     * Regexp pattern for bundles names which end with language code suffix.
246     * For example, messages_es.properties.
247     */
248    private static final Pattern LANGUAGE_PATTERN =
249        CommonUtil.createPattern("^.+\\_[a-z]{2}\\..+$");
250
251    /** File name format for default translation. */
252    private static final String DEFAULT_TRANSLATION_FILE_NAME_FORMATTER = "%s.%s";
253    /** File name format with language code. */
254    private static final String FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER = "%s_%s.%s";
255
256    /** Formatting string to form regexp to validate required translations file names. */
257    private static final String REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS =
258        "^%1$s\\_%2$s(\\_[A-Z]{2})?\\.%3$s$|^%1$s\\_%2$s\\_[A-Z]{2}\\_[A-Za-z]+\\.%3$s$";
259    /** Formatting string to form regexp to validate default translations file names. */
260    private static final String REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS = "^%s\\.%s$";
261
262    /** Logger for TranslationCheck. */
263    private final Log log;
264
265    /** The files to process. */
266    private final Set<File> filesToProcess = ConcurrentHashMap.newKeySet();
267
268    /**
269     * Specify
270     * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/ResourceBundle.html">
271     * Base name</a> of resource bundles which contain message resources.
272     * It helps the check to distinguish config and localization resources.
273     */
274    private Pattern baseName;
275
276    /**
277     * Specify language codes of required translations which must exist in project.
278     */
279    private Set<String> requiredTranslations = new HashSet<>();
280
281    /**
282     * Creates a new {@code TranslationCheck} instance.
283     */
284    public TranslationCheck() {
285        setFileExtensions("properties");
286        baseName = CommonUtil.createPattern("^messages.*$");
287        log = LogFactory.getLog(TranslationCheck.class);
288    }
289
290    /**
291     * Setter to specify
292     * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/ResourceBundle.html">
293     * Base name</a> of resource bundles which contain message resources.
294     * It helps the check to distinguish config and localization resources.
295     *
296     * @param baseName base name regexp.
297     */
298    public void setBaseName(Pattern baseName) {
299        this.baseName = baseName;
300    }
301
302    /**
303     * Setter to specify language codes of required translations which must exist in project.
304     *
305     * @param translationCodes a comma separated list of language codes.
306     */
307    public void setRequiredTranslations(String... translationCodes) {
308        requiredTranslations = Arrays.stream(translationCodes).collect(Collectors.toSet());
309        validateUserSpecifiedLanguageCodes(requiredTranslations);
310    }
311
312    /**
313     * Validates the correctness of user specified language codes for the check.
314     *
315     * @param languageCodes user specified language codes for the check.
316     * @throws IllegalArgumentException when any item of languageCodes is not valid language code
317     */
318    private void validateUserSpecifiedLanguageCodes(Set<String> languageCodes) {
319        for (String code : languageCodes) {
320            if (!isValidLanguageCode(code)) {
321                final LocalizedMessage msg = new LocalizedMessage(1, TRANSLATION_BUNDLE,
322                        WRONG_LANGUAGE_CODE_KEY, new Object[] {code}, getId(), getClass(), null);
323                final String exceptionMessage = String.format(Locale.ROOT,
324                        "%s [%s]", msg.getMessage(), TranslationCheck.class.getSimpleName());
325                throw new IllegalArgumentException(exceptionMessage);
326            }
327        }
328    }
329
330    /**
331     * Checks whether user specified language code is correct (is contained in available locales).
332     *
333     * @param userSpecifiedLanguageCode user specified language code.
334     * @return true if user specified language code is correct.
335     */
336    private static boolean isValidLanguageCode(final String userSpecifiedLanguageCode) {
337        boolean valid = false;
338        final Locale[] locales = Locale.getAvailableLocales();
339        for (Locale locale : locales) {
340            if (userSpecifiedLanguageCode.equals(locale.toString())) {
341                valid = true;
342                break;
343            }
344        }
345        return valid;
346    }
347
348    @Override
349    public void beginProcessing(String charset) {
350        filesToProcess.clear();
351    }
352
353    @Override
354    protected void processFiltered(File file, FileText fileText) {
355        // We just collecting files for processing at finishProcessing()
356        filesToProcess.add(file);
357    }
358
359    @Override
360    public void finishProcessing() {
361        final Set<ResourceBundle> bundles = groupFilesIntoBundles(filesToProcess, baseName);
362        for (ResourceBundle currentBundle : bundles) {
363            checkExistenceOfDefaultTranslation(currentBundle);
364            checkExistenceOfRequiredTranslations(currentBundle);
365            checkTranslationKeys(currentBundle);
366        }
367    }
368
369    /**
370     * Checks an existence of default translation file in the resource bundle.
371     *
372     * @param bundle resource bundle.
373     */
374    private void checkExistenceOfDefaultTranslation(ResourceBundle bundle) {
375        getMissingFileName(bundle, null)
376            .ifPresent(fileName -> logMissingTranslation(bundle.getPath(), fileName));
377    }
378
379    /**
380     * Checks an existence of translation files in the resource bundle.
381     * The name of translation file begins with the base name of resource bundle which is followed
382     * by '_' and a language code (country and variant are optional), it ends with the extension
383     * suffix.
384     *
385     * @param bundle resource bundle.
386     */
387    private void checkExistenceOfRequiredTranslations(ResourceBundle bundle) {
388        for (String languageCode : requiredTranslations) {
389            getMissingFileName(bundle, languageCode)
390                .ifPresent(fileName -> logMissingTranslation(bundle.getPath(), fileName));
391        }
392    }
393
394    /**
395     * Returns the name of translation file which is absent in resource bundle or Guava's Optional,
396     * if there is not missing translation.
397     *
398     * @param bundle resource bundle.
399     * @param languageCode language code.
400     * @return the name of translation file which is absent in resource bundle or Guava's Optional,
401     *         if there is not missing translation.
402     */
403    private static Optional<String> getMissingFileName(ResourceBundle bundle, String languageCode) {
404        final String fileNameRegexp;
405        final boolean searchForDefaultTranslation;
406        final String extension = bundle.getExtension();
407        final String baseName = bundle.getBaseName();
408        if (languageCode == null) {
409            searchForDefaultTranslation = true;
410            fileNameRegexp = String.format(Locale.ROOT, REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS,
411                    baseName, extension);
412        }
413        else {
414            searchForDefaultTranslation = false;
415            fileNameRegexp = String.format(Locale.ROOT,
416                REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS, baseName, languageCode, extension);
417        }
418        Optional<String> missingFileName = Optional.empty();
419        if (!bundle.containsFile(fileNameRegexp)) {
420            if (searchForDefaultTranslation) {
421                missingFileName = Optional.of(String.format(Locale.ROOT,
422                        DEFAULT_TRANSLATION_FILE_NAME_FORMATTER, baseName, extension));
423            }
424            else {
425                missingFileName = Optional.of(String.format(Locale.ROOT,
426                        FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER, baseName, languageCode, extension));
427            }
428        }
429        return missingFileName;
430    }
431
432    /**
433     * Logs that translation file is missing.
434     *
435     * @param filePath file path.
436     * @param fileName file name.
437     */
438    private void logMissingTranslation(String filePath, String fileName) {
439        final MessageDispatcher dispatcher = getMessageDispatcher();
440        dispatcher.fireFileStarted(filePath);
441        log(1, MSG_KEY_MISSING_TRANSLATION_FILE, fileName);
442        fireErrors(filePath);
443        dispatcher.fireFileFinished(filePath);
444    }
445
446    /**
447     * Groups a set of files into bundles.
448     * Only files, which names match base name regexp pattern will be grouped.
449     *
450     * @param files set of files.
451     * @param baseNameRegexp base name regexp pattern.
452     * @return set of ResourceBundles.
453     */
454    private static Set<ResourceBundle> groupFilesIntoBundles(Set<File> files,
455                                                             Pattern baseNameRegexp) {
456        final Set<ResourceBundle> resourceBundles = new HashSet<>();
457        for (File currentFile : files) {
458            final String fileName = currentFile.getName();
459            final String baseName = extractBaseName(fileName);
460            final Matcher baseNameMatcher = baseNameRegexp.matcher(baseName);
461            if (baseNameMatcher.matches()) {
462                final String extension = CommonUtil.getFileExtension(fileName);
463                final String path = getPath(currentFile.getAbsolutePath());
464                final ResourceBundle newBundle = new ResourceBundle(baseName, path, extension);
465                final Optional<ResourceBundle> bundle = findBundle(resourceBundles, newBundle);
466                if (bundle.isPresent()) {
467                    bundle.get().addFile(currentFile);
468                }
469                else {
470                    newBundle.addFile(currentFile);
471                    resourceBundles.add(newBundle);
472                }
473            }
474        }
475        return resourceBundles;
476    }
477
478    /**
479     * Searches for specific resource bundle in a set of resource bundles.
480     *
481     * @param bundles set of resource bundles.
482     * @param targetBundle target bundle to search for.
483     * @return Guava's Optional of resource bundle (present if target bundle is found).
484     */
485    private static Optional<ResourceBundle> findBundle(Set<ResourceBundle> bundles,
486                                                       ResourceBundle targetBundle) {
487        Optional<ResourceBundle> result = Optional.empty();
488        for (ResourceBundle currentBundle : bundles) {
489            if (targetBundle.getBaseName().equals(currentBundle.getBaseName())
490                    && targetBundle.getExtension().equals(currentBundle.getExtension())
491                    && targetBundle.getPath().equals(currentBundle.getPath())) {
492                result = Optional.of(currentBundle);
493                break;
494            }
495        }
496        return result;
497    }
498
499    /**
500     * Extracts the base name (the unique prefix) of resource bundle from translation file name.
501     * For example "messages" is the base name of "messages.properties",
502     * "messages_de_AT.properties", "messages_en.properties", etc.
503     *
504     * @param fileName the fully qualified name of the translation file.
505     * @return the extracted base name.
506     */
507    private static String extractBaseName(String fileName) {
508        final String regexp;
509        final Matcher languageCountryVariantMatcher =
510            LANGUAGE_COUNTRY_VARIANT_PATTERN.matcher(fileName);
511        final Matcher languageCountryMatcher = LANGUAGE_COUNTRY_PATTERN.matcher(fileName);
512        final Matcher languageMatcher = LANGUAGE_PATTERN.matcher(fileName);
513        if (languageCountryVariantMatcher.matches()) {
514            regexp = LANGUAGE_COUNTRY_VARIANT_PATTERN.pattern();
515        }
516        else if (languageCountryMatcher.matches()) {
517            regexp = LANGUAGE_COUNTRY_PATTERN.pattern();
518        }
519        else if (languageMatcher.matches()) {
520            regexp = LANGUAGE_PATTERN.pattern();
521        }
522        else {
523            regexp = DEFAULT_TRANSLATION_REGEXP;
524        }
525        // We use substring(...) instead of replace(...), so that the regular expression does
526        // not have to be compiled each time it is used inside 'replace' method.
527        final String removePattern = regexp.substring("^.+".length());
528        return fileName.replaceAll(removePattern, "");
529    }
530
531    /**
532     * Extracts path from a file name which contains the path.
533     * For example, if file nam is /xyz/messages.properties, then the method
534     * will return /xyz/.
535     *
536     * @param fileNameWithPath file name which contains the path.
537     * @return file path.
538     */
539    private static String getPath(String fileNameWithPath) {
540        return fileNameWithPath
541            .substring(0, fileNameWithPath.lastIndexOf(File.separator));
542    }
543
544    /**
545     * Checks resource files in bundle for consistency regarding their keys.
546     * All files in bundle must have the same key set. If this is not the case
547     * an audit event message is posted giving information which key misses in which file.
548     *
549     * @param bundle resource bundle.
550     */
551    private void checkTranslationKeys(ResourceBundle bundle) {
552        final Set<File> filesInBundle = bundle.getFiles();
553        // build a map from files to the keys they contain
554        final Set<String> allTranslationKeys = new HashSet<>();
555        final Map<File, Set<String>> filesAssociatedWithKeys = new HashMap<>();
556        for (File currentFile : filesInBundle) {
557            final Set<String> keysInCurrentFile = getTranslationKeys(currentFile);
558            allTranslationKeys.addAll(keysInCurrentFile);
559            filesAssociatedWithKeys.put(currentFile, keysInCurrentFile);
560        }
561        checkFilesForConsistencyRegardingTheirKeys(filesAssociatedWithKeys, allTranslationKeys);
562    }
563
564    /**
565     * Compares th the specified key set with the key sets of the given translation files (arranged
566     * in a map). All missing keys are reported.
567     *
568     * @param fileKeys a Map from translation files to their key sets.
569     * @param keysThatMustExist the set of keys to compare with.
570     */
571    private void checkFilesForConsistencyRegardingTheirKeys(Map<File, Set<String>> fileKeys,
572                                                            Set<String> keysThatMustExist) {
573        for (Entry<File, Set<String>> fileKey : fileKeys.entrySet()) {
574            final Set<String> currentFileKeys = fileKey.getValue();
575            final Set<String> missingKeys = keysThatMustExist.stream()
576                .filter(key -> !currentFileKeys.contains(key)).collect(Collectors.toSet());
577            if (!missingKeys.isEmpty()) {
578                final MessageDispatcher dispatcher = getMessageDispatcher();
579                final String path = fileKey.getKey().getAbsolutePath();
580                dispatcher.fireFileStarted(path);
581                for (Object key : missingKeys) {
582                    log(1, MSG_KEY, key);
583                }
584                fireErrors(path);
585                dispatcher.fireFileFinished(path);
586            }
587        }
588    }
589
590    /**
591     * Loads the keys from the specified translation file into a set.
592     *
593     * @param file translation file.
594     * @return a Set object which holds the loaded keys.
595     */
596    private Set<String> getTranslationKeys(File file) {
597        Set<String> keys = new HashSet<>();
598        try (InputStream inStream = Files.newInputStream(file.toPath())) {
599            final Properties translations = new Properties();
600            translations.load(inStream);
601            keys = translations.stringPropertyNames();
602        }
603        // -@cs[IllegalCatch] It is better to catch all exceptions since it can throw
604        // a runtime exception.
605        catch (final Exception ex) {
606            logException(ex, file);
607        }
608        return keys;
609    }
610
611    /**
612     * Helper method to log an exception.
613     *
614     * @param exception the exception that occurred
615     * @param file the file that could not be processed
616     */
617    private void logException(Exception exception, File file) {
618        final String[] args;
619        final String key;
620        if (exception instanceof NoSuchFileException) {
621            args = null;
622            key = "general.fileNotFound";
623        }
624        else {
625            args = new String[] {exception.getMessage()};
626            key = "general.exception";
627        }
628        final LocalizedMessage message =
629            new LocalizedMessage(
630                0,
631                Definitions.CHECKSTYLE_BUNDLE,
632                key,
633                args,
634                getId(),
635                getClass(), null);
636        final SortedSet<LocalizedMessage> messages = new TreeSet<>();
637        messages.add(message);
638        getMessageDispatcher().fireErrors(file.getPath(), messages);
639        log.debug("Exception occurred.", exception);
640    }
641
642    /** Class which represents a resource bundle. */
643    private static class ResourceBundle {
644
645        /** Bundle base name. */
646        private final String baseName;
647        /** Common extension of files which are included in the resource bundle. */
648        private final String extension;
649        /** Common path of files which are included in the resource bundle. */
650        private final String path;
651        /** Set of files which are included in the resource bundle. */
652        private final Set<File> files;
653
654        /**
655         * Creates a ResourceBundle object with specific base name, common files extension.
656         *
657         * @param baseName bundle base name.
658         * @param path common path of files which are included in the resource bundle.
659         * @param extension common extension of files which are included in the resource bundle.
660         */
661        /* package */ ResourceBundle(String baseName, String path, String extension) {
662            this.baseName = baseName;
663            this.path = path;
664            this.extension = extension;
665            files = new HashSet<>();
666        }
667
668        public String getBaseName() {
669            return baseName;
670        }
671
672        public String getPath() {
673            return path;
674        }
675
676        public String getExtension() {
677            return extension;
678        }
679
680        public Set<File> getFiles() {
681            return Collections.unmodifiableSet(files);
682        }
683
684        /**
685         * Adds a file into resource bundle.
686         *
687         * @param file file which should be added into resource bundle.
688         */
689        public void addFile(File file) {
690            files.add(file);
691        }
692
693        /**
694         * Checks whether a resource bundle contains a file which name matches file name regexp.
695         *
696         * @param fileNameRegexp file name regexp.
697         * @return true if a resource bundle contains a file which name matches file name regexp.
698         */
699        public boolean containsFile(String fileNameRegexp) {
700            boolean containsFile = false;
701            for (File currentFile : files) {
702                if (Pattern.matches(fileNameRegexp, currentFile.getName())) {
703                    containsFile = true;
704                    break;
705                }
706            }
707            return containsFile;
708        }
709
710    }
711
712}