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