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