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