001//////////////////////////////////////////////////////////////////////////////// 002// checkstyle: Checks Java source code for adherence to a set of rules. 003// Copyright (C) 2001-2017 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.FileInputStream; 024import java.io.FileNotFoundException; 025import java.io.IOException; 026import java.io.InputStream; 027import java.util.Arrays; 028import java.util.Collections; 029import java.util.HashSet; 030import java.util.List; 031import java.util.Locale; 032import java.util.Optional; 033import java.util.Properties; 034import java.util.Set; 035import java.util.SortedSet; 036import java.util.TreeSet; 037import java.util.regex.Matcher; 038import java.util.regex.Pattern; 039import java.util.stream.Collectors; 040 041import org.apache.commons.logging.Log; 042import org.apache.commons.logging.LogFactory; 043 044import com.google.common.collect.HashMultimap; 045import com.google.common.collect.SetMultimap; 046import com.google.common.io.Closeables; 047import com.puppycrawl.tools.checkstyle.Definitions; 048import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck; 049import com.puppycrawl.tools.checkstyle.api.LocalizedMessage; 050import com.puppycrawl.tools.checkstyle.api.MessageDispatcher; 051import com.puppycrawl.tools.checkstyle.utils.CommonUtils; 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 * @author Alexandra Bunge 101 * @author lkuehne 102 * @author Andrei Selkin 103 */ 104public class TranslationCheck extends AbstractFileSetCheck { 105 106 /** 107 * A key is pointing to the warning message text for missing key 108 * in "messages.properties" file. 109 */ 110 public static final String MSG_KEY = "translation.missingKey"; 111 112 /** 113 * A key is pointing to the warning message text for missing translation file 114 * in "messages.properties" file. 115 */ 116 public static final String MSG_KEY_MISSING_TRANSLATION_FILE = 117 "translation.missingTranslationFile"; 118 119 /** Resource bundle which contains messages for TranslationCheck. */ 120 private static final String TRANSLATION_BUNDLE = 121 "com.puppycrawl.tools.checkstyle.checks.messages"; 122 123 /** 124 * A key is pointing to the warning message text for wrong language code 125 * in "messages.properties" file. 126 */ 127 private static final String WRONG_LANGUAGE_CODE_KEY = "translation.wrongLanguageCode"; 128 129 /** Logger for TranslationCheck. */ 130 private static final Log LOG = LogFactory.getLog(TranslationCheck.class); 131 132 /** 133 * Regexp string for default translation files. 134 * For example, messages.properties. 135 */ 136 private static final String DEFAULT_TRANSLATION_REGEXP = "^.+\\..+$"; 137 138 /** 139 * Regexp pattern for bundles names wich end with language code, followed by country code and 140 * variant suffix. For example, messages_es_ES_UNIX.properties. 141 */ 142 private static final Pattern LANGUAGE_COUNTRY_VARIANT_PATTERN = 143 CommonUtils.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\_[A-Za-z]+\\..+$"); 144 /** 145 * Regexp pattern for bundles names wich end with language code, followed by country code 146 * suffix. For example, messages_es_ES.properties. 147 */ 148 private static final Pattern LANGUAGE_COUNTRY_PATTERN = 149 CommonUtils.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\..+$"); 150 /** 151 * Regexp pattern for bundles names wich end with language code suffix. 152 * For example, messages_es.properties. 153 */ 154 private static final Pattern LANGUAGE_PATTERN = 155 CommonUtils.createPattern("^.+\\_[a-z]{2}\\..+$"); 156 157 /** File name format for default translation. */ 158 private static final String DEFAULT_TRANSLATION_FILE_NAME_FORMATTER = "%s.%s"; 159 /** File name format with language code. */ 160 private static final String FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER = "%s_%s.%s"; 161 162 /** Formatting string to form regexp to validate required translations file names. */ 163 private static final String REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS = 164 "^%1$s\\_%2$s(\\_[A-Z]{2})?\\.%3$s$|^%1$s\\_%2$s\\_[A-Z]{2}\\_[A-Za-z]+\\.%3$s$"; 165 /** Formatting string to form regexp to validate default translations file names. */ 166 private static final String REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS = "^%s\\.%s$"; 167 168 /** The files to process. */ 169 private final Set<File> filesToProcess = new HashSet<>(); 170 171 /** The base name regexp pattern. */ 172 private Pattern baseName; 173 174 /** 175 * Language codes of required translations for the check (de, pt, ja, etc). 176 */ 177 private Set<String> requiredTranslations = new HashSet<>(); 178 179 /** 180 * Creates a new {@code TranslationCheck} instance. 181 */ 182 public TranslationCheck() { 183 setFileExtensions("properties"); 184 baseName = CommonUtils.createPattern("^messages.*$"); 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(0, 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 super.beginProcessing(charset); 240 filesToProcess.clear(); 241 } 242 243 @Override 244 protected void processFiltered(File file, List<String> lines) { 245 // We just collecting files for processing at finishProcessing() 246 filesToProcess.add(file); 247 } 248 249 @Override 250 public void finishProcessing() { 251 super.finishProcessing(); 252 253 final Set<ResourceBundle> bundles = groupFilesIntoBundles(filesToProcess, baseName); 254 for (ResourceBundle currentBundle : bundles) { 255 checkExistenceOfDefaultTranslation(currentBundle); 256 checkExistenceOfRequiredTranslations(currentBundle); 257 checkTranslationKeys(currentBundle); 258 } 259 } 260 261 /** 262 * Checks an existence of default translation file in the resource bundle. 263 * @param bundle resource bundle. 264 */ 265 private void checkExistenceOfDefaultTranslation(ResourceBundle bundle) { 266 final Optional<String> fileName = getMissingFileName(bundle, null); 267 if (fileName.isPresent()) { 268 logMissingTranslation(bundle.getPath(), fileName.get()); 269 } 270 } 271 272 /** 273 * Checks an existence of translation files in the resource bundle. 274 * The name of translation file begins with the base name of resource bundle which is followed 275 * by '_' and a language code (country and variant are optional), it ends with the extension 276 * suffix. 277 * @param bundle resource bundle. 278 */ 279 private void checkExistenceOfRequiredTranslations(ResourceBundle bundle) { 280 for (String languageCode : requiredTranslations) { 281 final Optional<String> fileName = getMissingFileName(bundle, languageCode); 282 if (fileName.isPresent()) { 283 logMissingTranslation(bundle.getPath(), fileName.get()); 284 } 285 } 286 } 287 288 /** 289 * Returns the name of translation file which is absent in resource bundle or Guava's Optional, 290 * if there is not missing translation. 291 * @param bundle resource bundle. 292 * @param languageCode language code. 293 * @return the name of translation file which is absent in resource bundle or Guava's Optional, 294 * if there is not missing translation. 295 */ 296 private static Optional<String> getMissingFileName(ResourceBundle bundle, String languageCode) { 297 final String fileNameRegexp; 298 final boolean searchForDefaultTranslation; 299 final String extension = bundle.getExtension(); 300 final String baseName = bundle.getBaseName(); 301 if (languageCode == null) { 302 searchForDefaultTranslation = true; 303 fileNameRegexp = String.format(Locale.ROOT, REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS, 304 baseName, extension); 305 } 306 else { 307 searchForDefaultTranslation = false; 308 fileNameRegexp = String.format(Locale.ROOT, 309 REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS, baseName, languageCode, extension); 310 } 311 Optional<String> missingFileName = Optional.empty(); 312 if (!bundle.containsFile(fileNameRegexp)) { 313 if (searchForDefaultTranslation) { 314 missingFileName = Optional.of(String.format(Locale.ROOT, 315 DEFAULT_TRANSLATION_FILE_NAME_FORMATTER, baseName, extension)); 316 } 317 else { 318 missingFileName = Optional.of(String.format(Locale.ROOT, 319 FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER, baseName, languageCode, extension)); 320 } 321 } 322 return missingFileName; 323 } 324 325 /** 326 * Logs that translation file is missing. 327 * @param filePath file path. 328 * @param fileName file name. 329 */ 330 private void logMissingTranslation(String filePath, String fileName) { 331 final MessageDispatcher dispatcher = getMessageDispatcher(); 332 dispatcher.fireFileStarted(filePath); 333 log(0, MSG_KEY_MISSING_TRANSLATION_FILE, fileName); 334 fireErrors(filePath); 335 dispatcher.fireFileFinished(filePath); 336 } 337 338 /** 339 * Groups a set of files into bundles. 340 * Only files, which names match base name regexp pattern will be grouped. 341 * @param files set of files. 342 * @param baseNameRegexp base name regexp pattern. 343 * @return set of ResourceBundles. 344 */ 345 private static Set<ResourceBundle> groupFilesIntoBundles(Set<File> files, 346 Pattern baseNameRegexp) { 347 final Set<ResourceBundle> resourceBundles = new HashSet<>(); 348 for (File currentFile : files) { 349 final String fileName = currentFile.getName(); 350 final String baseName = extractBaseName(fileName); 351 final Matcher baseNameMatcher = baseNameRegexp.matcher(baseName); 352 if (baseNameMatcher.matches()) { 353 final String extension = CommonUtils.getFileExtension(fileName); 354 final String path = getPath(currentFile.getAbsolutePath()); 355 final ResourceBundle newBundle = new ResourceBundle(baseName, path, extension); 356 final Optional<ResourceBundle> bundle = findBundle(resourceBundles, newBundle); 357 if (bundle.isPresent()) { 358 bundle.get().addFile(currentFile); 359 } 360 else { 361 newBundle.addFile(currentFile); 362 resourceBundles.add(newBundle); 363 } 364 } 365 } 366 return resourceBundles; 367 } 368 369 /** 370 * Searches for specific resource bundle in a set of resource bundles. 371 * @param bundles set of resource bundles. 372 * @param targetBundle target bundle to search for. 373 * @return Guava's Optional of resource bundle (present if target bundle is found). 374 */ 375 private static Optional<ResourceBundle> findBundle(Set<ResourceBundle> bundles, 376 ResourceBundle targetBundle) { 377 Optional<ResourceBundle> result = Optional.empty(); 378 for (ResourceBundle currentBundle : bundles) { 379 if (targetBundle.getBaseName().equals(currentBundle.getBaseName()) 380 && targetBundle.getExtension().equals(currentBundle.getExtension()) 381 && targetBundle.getPath().equals(currentBundle.getPath())) { 382 result = Optional.of(currentBundle); 383 break; 384 } 385 } 386 return result; 387 } 388 389 /** 390 * Extracts the base name (the unique prefix) of resource bundle from translation file name. 391 * For example "messages" is the base name of "messages.properties", 392 * "messages_de_AT.properties", "messages_en.properties", etc. 393 * @param fileName the fully qualified name of the translation file. 394 * @return the extracted base name. 395 */ 396 private static String extractBaseName(String fileName) { 397 final String regexp; 398 final Matcher languageCountryVariantMatcher = 399 LANGUAGE_COUNTRY_VARIANT_PATTERN.matcher(fileName); 400 final Matcher languageCountryMatcher = LANGUAGE_COUNTRY_PATTERN.matcher(fileName); 401 final Matcher languageMatcher = LANGUAGE_PATTERN.matcher(fileName); 402 if (languageCountryVariantMatcher.matches()) { 403 regexp = LANGUAGE_COUNTRY_VARIANT_PATTERN.pattern(); 404 } 405 else if (languageCountryMatcher.matches()) { 406 regexp = LANGUAGE_COUNTRY_PATTERN.pattern(); 407 } 408 else if (languageMatcher.matches()) { 409 regexp = LANGUAGE_PATTERN.pattern(); 410 } 411 else { 412 regexp = DEFAULT_TRANSLATION_REGEXP; 413 } 414 // We use substring(...) instead of replace(...), so that the regular expression does 415 // not have to be compiled each time it is used inside 'replace' method. 416 final String removePattern = regexp.substring("^.+".length(), regexp.length()); 417 return fileName.replaceAll(removePattern, ""); 418 } 419 420 /** 421 * Extracts path from a file name which contains the path. 422 * For example, if file nam is /xyz/messages.properties, then the method 423 * will return /xyz/. 424 * @param fileNameWithPath file name which contains the path. 425 * @return file path. 426 */ 427 private static String getPath(String fileNameWithPath) { 428 return fileNameWithPath 429 .substring(0, fileNameWithPath.lastIndexOf(File.separator)); 430 } 431 432 /** 433 * Checks resource files in bundle for consistency regarding their keys. 434 * All files in bundle must have the same key set. If this is not the case 435 * an error message is posted giving information which key misses in which file. 436 * @param bundle resource bundle. 437 */ 438 private void checkTranslationKeys(ResourceBundle bundle) { 439 final Set<File> filesInBundle = bundle.getFiles(); 440 if (filesInBundle.size() > 1) { 441 // build a map from files to the keys they contain 442 final Set<String> allTranslationKeys = new HashSet<>(); 443 final SetMultimap<File, String> filesAssociatedWithKeys = HashMultimap.create(); 444 for (File currentFile : filesInBundle) { 445 final Set<String> keysInCurrentFile = getTranslationKeys(currentFile); 446 allTranslationKeys.addAll(keysInCurrentFile); 447 filesAssociatedWithKeys.putAll(currentFile, keysInCurrentFile); 448 } 449 checkFilesForConsistencyRegardingTheirKeys(filesAssociatedWithKeys, allTranslationKeys); 450 } 451 } 452 453 /** 454 * Compares th the specified key set with the key sets of the given translation files (arranged 455 * in a map). All missing keys are reported. 456 * @param fileKeys a Map from translation files to their key sets. 457 * @param keysThatMustExist the set of keys to compare with. 458 */ 459 private void checkFilesForConsistencyRegardingTheirKeys(SetMultimap<File, String> fileKeys, 460 Set<String> keysThatMustExist) { 461 for (File currentFile : fileKeys.keySet()) { 462 final MessageDispatcher dispatcher = getMessageDispatcher(); 463 final String path = currentFile.getPath(); 464 dispatcher.fireFileStarted(path); 465 final Set<String> currentFileKeys = fileKeys.get(currentFile); 466 final Set<String> missingKeys = keysThatMustExist.stream() 467 .filter(e -> !currentFileKeys.contains(e)).collect(Collectors.toSet()); 468 if (!missingKeys.isEmpty()) { 469 for (Object key : missingKeys) { 470 log(0, MSG_KEY, key); 471 } 472 } 473 fireErrors(path); 474 dispatcher.fireFileFinished(path); 475 } 476 } 477 478 /** 479 * Loads the keys from the specified translation file into a set. 480 * @param file translation file. 481 * @return a Set object which holds the loaded keys. 482 */ 483 private Set<String> getTranslationKeys(File file) { 484 Set<String> keys = new HashSet<>(); 485 InputStream inStream = null; 486 try { 487 inStream = new FileInputStream(file); 488 final Properties translations = new Properties(); 489 translations.load(inStream); 490 keys = translations.stringPropertyNames(); 491 } 492 catch (final IOException ex) { 493 logIoException(ex, file); 494 } 495 finally { 496 Closeables.closeQuietly(inStream); 497 } 498 return keys; 499 } 500 501 /** 502 * Helper method to log an io exception. 503 * @param exception the exception that occurred 504 * @param file the file that could not be processed 505 */ 506 private void logIoException(IOException exception, File file) { 507 String[] args = null; 508 String key = "general.fileNotFound"; 509 if (!(exception instanceof FileNotFoundException)) { 510 args = new String[] {exception.getMessage()}; 511 key = "general.exception"; 512 } 513 final LocalizedMessage message = 514 new LocalizedMessage( 515 0, 516 Definitions.CHECKSTYLE_BUNDLE, 517 key, 518 args, 519 getId(), 520 getClass(), null); 521 final SortedSet<LocalizedMessage> messages = new TreeSet<>(); 522 messages.add(message); 523 getMessageDispatcher().fireErrors(file.getPath(), messages); 524 LOG.debug("IOException occurred.", exception); 525 } 526 527 /** Class which represents a resource bundle. */ 528 private static class ResourceBundle { 529 /** Bundle base name. */ 530 private final String baseName; 531 /** Common extension of files which are included in the resource bundle. */ 532 private final String extension; 533 /** Common path of files which are included in the resource bundle. */ 534 private final String path; 535 /** Set of files which are included in the resource bundle. */ 536 private final Set<File> files; 537 538 /** 539 * Creates a ResourceBundle object with specific base name, common files extension. 540 * @param baseName bundle base name. 541 * @param path common path of files which are included in the resource bundle. 542 * @param extension common extension of files which are included in the resource bundle. 543 */ 544 ResourceBundle(String baseName, String path, String extension) { 545 this.baseName = baseName; 546 this.path = path; 547 this.extension = extension; 548 files = new HashSet<>(); 549 } 550 551 public String getBaseName() { 552 return baseName; 553 } 554 555 public String getPath() { 556 return path; 557 } 558 559 public String getExtension() { 560 return extension; 561 } 562 563 public Set<File> getFiles() { 564 return Collections.unmodifiableSet(files); 565 } 566 567 /** 568 * Adds a file into resource bundle. 569 * @param file file which should be added into resource bundle. 570 */ 571 public void addFile(File file) { 572 files.add(file); 573 } 574 575 /** 576 * Checks whether a resource bundle contains a file which name matches file name regexp. 577 * @param fileNameRegexp file name regexp. 578 * @return true if a resource bundle contains a file which name matches file name regexp. 579 */ 580 public boolean containsFile(String fileNameRegexp) { 581 boolean containsFile = false; 582 for (File currentFile : files) { 583 if (Pattern.matches(fileNameRegexp, currentFile.getName())) { 584 containsFile = true; 585 break; 586 } 587 } 588 return containsFile; 589 } 590 } 591}