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