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