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