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