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