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