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