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