001//////////////////////////////////////////////////////////////////////////////// 002// checkstyle: Checks Java source code for adherence to a set of rules. 003// Copyright (C) 2001-2016 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.Collections; 028import java.util.Enumeration; 029import java.util.List; 030import java.util.Locale; 031import java.util.Properties; 032import java.util.Set; 033import java.util.SortedSet; 034import java.util.regex.Matcher; 035import java.util.regex.Pattern; 036 037import org.apache.commons.logging.Log; 038import org.apache.commons.logging.LogFactory; 039 040import com.google.common.base.Splitter; 041import com.google.common.collect.HashMultimap; 042import com.google.common.collect.ImmutableSortedSet; 043import com.google.common.collect.Lists; 044import com.google.common.collect.SetMultimap; 045import com.google.common.collect.Sets; 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; 051 052/** 053 * <p> 054 * The TranslationCheck class helps to ensure the correct translation of code by 055 * checking property files for consistency regarding their keys. 056 * Two property files describing one and the same context are consistent if they 057 * contain the same keys. 058 * </p> 059 * <p> 060 * An example of how to configure the check is: 061 * </p> 062 * <pre> 063 * <module name="Translation"/> 064 * </pre> 065 * Check has the following properties: 066 * 067 * <p><b>basenameSeparator</b> which allows setting separator in file names, 068 * default value is '_'. 069 * <p> 070 * E.g.: 071 * </p> 072 * <p> 073 * messages_test.properties //separator is '_' 074 * </p> 075 * <p> 076 * app-dev.properties //separator is '-' 077 * </p> 078 * 079 * <p><b>requiredTranslations</b> which allows to specify language codes of 080 * required translations which must exist in project. The check looks only for 081 * messages bundles which names contain the word 'messages'. 082 * Language code is composed of the lowercase, two-letter codes as defined by 083 * <a href="http://www.fatbellyman.com/webstuff/language_codes_639-1/">ISO 639-1</a>. 084 * Default value is <b>empty String Set</b> which means that only the existence of 085 * default translation is checked. 086 * Note, if you specify language codes (or just one language code) of required translations 087 * the check will also check for existence of default translation files in project. 088 * <br> 089 * @author Alexandra Bunge 090 * @author lkuehne 091 * @author Andrei Selkin 092 */ 093public class TranslationCheck 094 extends AbstractFileSetCheck { 095 096 /** 097 * A key is pointing to the warning message text for missing key 098 * in "messages.properties" file. 099 */ 100 public static final String MSG_KEY = "translation.missingKey"; 101 102 /** 103 * A key is pointing to the warning message text for missing translation file 104 * in "messages.properties" file. 105 */ 106 public static final String MSG_KEY_MISSING_TRANSLATION_FILE = 107 "translation.missingTranslationFile"; 108 109 /** Logger for TranslationCheck. */ 110 private static final Log LOG = LogFactory.getLog(TranslationCheck.class); 111 112 /** The property files to process. */ 113 private final List<File> propertyFiles = Lists.newArrayList(); 114 115 /** The separator string used to separate translation files. */ 116 private String basenameSeparator; 117 118 /** 119 * Language codes of required translations for the check (de, pt, ja, etc). 120 */ 121 private SortedSet<String> requiredTranslations = ImmutableSortedSet.of(); 122 123 /** 124 * Creates a new {@code TranslationCheck} instance. 125 */ 126 public TranslationCheck() { 127 setFileExtensions("properties"); 128 basenameSeparator = "_"; 129 } 130 131 /** 132 * Sets language codes of required translations for the check. 133 * @param translationCodes a comma separated list of language codes. 134 */ 135 public void setRequiredTranslations(String translationCodes) { 136 requiredTranslations = Sets.newTreeSet(Splitter.on(',') 137 .trimResults().omitEmptyStrings().split(translationCodes)); 138 } 139 140 @Override 141 public void beginProcessing(String charset) { 142 super.beginProcessing(charset); 143 propertyFiles.clear(); 144 } 145 146 @Override 147 protected void processFiltered(File file, List<String> lines) { 148 propertyFiles.add(file); 149 } 150 151 @Override 152 public void finishProcessing() { 153 super.finishProcessing(); 154 final SetMultimap<String, File> propFilesMap = 155 arrangePropertyFiles(propertyFiles, basenameSeparator); 156 checkExistenceOfTranslations(propFilesMap); 157 checkPropertyFileSets(propFilesMap); 158 } 159 160 /** 161 * Checks existence of translation files (arranged in a map) 162 * for each resource bundle in project. 163 * @param translations the translation files bundles organized as Map. 164 */ 165 private void checkExistenceOfTranslations(SetMultimap<String, File> translations) { 166 for (String fullyQualifiedBundleName : translations.keySet()) { 167 final String bundleBaseName = extractName(fullyQualifiedBundleName); 168 if (bundleBaseName.contains("messages")) { 169 final Set<File> filesInBundle = translations.get(fullyQualifiedBundleName); 170 checkExistenceOfDefaultTranslation(filesInBundle); 171 checkExistenceOfRequiredTranslations(filesInBundle); 172 } 173 } 174 } 175 176 /** 177 * Checks an existence of default translation file in 178 * a set of files in resource bundle. The name of this file 179 * begins with the full name of the resource bundle and ends 180 * with the extension suffix. 181 * @param filesInResourceBundle a set of files in resource bundle. 182 */ 183 private void checkExistenceOfDefaultTranslation(Set<File> filesInResourceBundle) { 184 final String fullBundleName = getFullBundleName(filesInResourceBundle); 185 final String extension = getFileExtensions()[0]; 186 final String defaultTranslationFileName = fullBundleName + extension; 187 188 final boolean missing = isMissing(defaultTranslationFileName, filesInResourceBundle); 189 if (missing) { 190 logMissingTranslation(defaultTranslationFileName); 191 } 192 } 193 194 /** 195 * Checks existence of translation files in a set of files 196 * in resource bundle. If there is no translation file 197 * with required language code, there will be a violation. 198 * The name of translation file begins with the full name 199 * of resource bundle which is followed by '_' and language code, 200 * it ends with the extension suffix. 201 * @param filesInResourceBundle a set of files in resource bundle. 202 */ 203 private void checkExistenceOfRequiredTranslations(Set<File> filesInResourceBundle) { 204 final String fullBundleName = getFullBundleName(filesInResourceBundle); 205 206 for (String languageCode : requiredTranslations) { 207 final String translationFileName = fullBundleName + '_' + languageCode; 208 209 final boolean missing = isMissing(translationFileName, filesInResourceBundle); 210 if (missing) { 211 final String missingTranslationFileName = 212 formMissingTranslationName(fullBundleName, languageCode); 213 logMissingTranslation(missingTranslationFileName); 214 } 215 } 216 } 217 218 /** 219 * Gets full name of resource bundle. 220 * Full name of resource bundle consists of bundle path and 221 * full base name. 222 * @param filesInResourceBundle a set of files in resource bundle. 223 * @return full name of resource bundle. 224 */ 225 private String getFullBundleName(Set<File> filesInResourceBundle) { 226 final String fullBundleName; 227 228 final File firstTranslationFile = Collections.min(filesInResourceBundle); 229 final String translationPath = firstTranslationFile.getPath(); 230 final String extension = getFileExtensions()[0]; 231 232 final Pattern pattern = Pattern.compile("^.+_[a-z]{2}" 233 + extension + "$"); 234 final Matcher matcher = pattern.matcher(translationPath); 235 if (matcher.matches()) { 236 fullBundleName = translationPath 237 .substring(0, translationPath.lastIndexOf('_')); 238 } 239 else { 240 fullBundleName = translationPath 241 .substring(0, translationPath.lastIndexOf('.')); 242 } 243 return fullBundleName; 244 } 245 246 /** 247 * Checks whether file is missing in resource bundle. 248 * @param fileName file name. 249 * @param filesInResourceBundle a set of files in resource bundle. 250 * @return true if file is missing. 251 */ 252 private static boolean isMissing(String fileName, Set<File> filesInResourceBundle) { 253 boolean missing = false; 254 for (File file : filesInResourceBundle) { 255 final String currentFileName = file.getPath(); 256 missing = !currentFileName.contains(fileName); 257 if (!missing) { 258 break; 259 } 260 } 261 return missing; 262 } 263 264 /** 265 * Forms a name of translation file which is missing. 266 * @param fullBundleName full bundle name. 267 * @param languageCode language code. 268 * @return name of translation file which is missing. 269 */ 270 private String formMissingTranslationName(String fullBundleName, String languageCode) { 271 final String extension = getFileExtensions()[0]; 272 return String.format(Locale.ROOT, "%s_%s%s", fullBundleName, languageCode, extension); 273 } 274 275 /** 276 * Logs that translation file is missing. 277 * @param fullyQualifiedFileName fully qualified file name. 278 */ 279 private void logMissingTranslation(String fullyQualifiedFileName) { 280 final String filePath = extractPath(fullyQualifiedFileName); 281 282 final MessageDispatcher dispatcher = getMessageDispatcher(); 283 dispatcher.fireFileStarted(filePath); 284 285 log(0, MSG_KEY_MISSING_TRANSLATION_FILE, extractName(fullyQualifiedFileName)); 286 287 fireErrors(filePath); 288 dispatcher.fireFileFinished(filePath); 289 } 290 291 /** 292 * Extracts path from fully qualified file name. 293 * @param fullyQualifiedFileName fully qualified file name. 294 * @return file path. 295 */ 296 private static String extractPath(String fullyQualifiedFileName) { 297 return fullyQualifiedFileName 298 .substring(0, fullyQualifiedFileName.lastIndexOf(File.separator)); 299 } 300 301 /** 302 * Extracts short file name from fully qualified file name. 303 * @param fullyQualifiedFileName fully qualified file name. 304 * @return short file name. 305 */ 306 private static String extractName(String fullyQualifiedFileName) { 307 return fullyQualifiedFileName 308 .substring(fullyQualifiedFileName.lastIndexOf(File.separator) + 1); 309 } 310 311 /** 312 * Gets the basename (the unique prefix) of a property file. For example 313 * "xyz/messages" is the basename of "xyz/messages.properties", 314 * "xyz/messages_de_AT.properties", "xyz/messages_en.properties", etc. 315 * 316 * @param file the file 317 * @param basenameSeparator the basename separator 318 * @return the extracted basename 319 */ 320 private static String extractPropertyIdentifier(File file, String basenameSeparator) { 321 final String filePath = file.getPath(); 322 final int dirNameEnd = filePath.lastIndexOf(File.separatorChar); 323 final int baseNameStart = dirNameEnd + 1; 324 final int underscoreIdx = filePath.indexOf(basenameSeparator, 325 baseNameStart); 326 final int dotIdx = filePath.indexOf('.', baseNameStart); 327 final int cutoffIdx; 328 329 if (underscoreIdx == -1) { 330 cutoffIdx = dotIdx; 331 } 332 else { 333 cutoffIdx = underscoreIdx; 334 } 335 return filePath.substring(0, cutoffIdx); 336 } 337 338 /** 339 * Sets the separator used to determine the basename of a property file. 340 * This defaults to "_" 341 * 342 * @param basenameSeparator the basename separator 343 */ 344 public final void setBasenameSeparator(String basenameSeparator) { 345 this.basenameSeparator = basenameSeparator; 346 } 347 348 /** 349 * Arranges a set of property files by their prefix. 350 * The method returns a Map object. The filename prefixes 351 * work as keys each mapped to a set of files. 352 * @param propFiles the set of property files 353 * @param basenameSeparator the basename separator 354 * @return a Map object which holds the arranged property file sets 355 */ 356 private static SetMultimap<String, File> arrangePropertyFiles( 357 List<File> propFiles, String basenameSeparator) { 358 final SetMultimap<String, File> propFileMap = HashMultimap.create(); 359 360 for (final File file : propFiles) { 361 final String identifier = extractPropertyIdentifier(file, 362 basenameSeparator); 363 364 final Set<File> fileSet = propFileMap.get(identifier); 365 fileSet.add(file); 366 } 367 return propFileMap; 368 } 369 370 /** 371 * Loads the keys of the specified property file into a set. 372 * @param file the property file 373 * @return a Set object which holds the loaded keys 374 */ 375 private Set<Object> loadKeys(File file) { 376 final Set<Object> keys = Sets.newHashSet(); 377 InputStream inStream = null; 378 379 try { 380 // Load file and properties. 381 inStream = new FileInputStream(file); 382 final Properties props = new Properties(); 383 props.load(inStream); 384 385 // Gather the keys and put them into a set 386 final Enumeration<?> element = props.propertyNames(); 387 while (element.hasMoreElements()) { 388 keys.add(element.nextElement()); 389 } 390 } 391 catch (final IOException ex) { 392 logIoException(ex, file); 393 } 394 finally { 395 Closeables.closeQuietly(inStream); 396 } 397 return keys; 398 } 399 400 /** 401 * Helper method to log an io exception. 402 * @param exception the exception that occurred 403 * @param file the file that could not be processed 404 */ 405 private void logIoException(IOException exception, File file) { 406 String[] args = null; 407 String key = "general.fileNotFound"; 408 if (!(exception instanceof FileNotFoundException)) { 409 args = new String[] {exception.getMessage()}; 410 key = "general.exception"; 411 } 412 final LocalizedMessage message = 413 new LocalizedMessage( 414 0, 415 Definitions.CHECKSTYLE_BUNDLE, 416 key, 417 args, 418 getId(), 419 getClass(), null); 420 final SortedSet<LocalizedMessage> messages = Sets.newTreeSet(); 421 messages.add(message); 422 getMessageDispatcher().fireErrors(file.getPath(), messages); 423 LOG.debug("IOException occurred.", exception); 424 } 425 426 /** 427 * Compares the key sets of the given property files (arranged in a map) 428 * with the specified key set. All missing keys are reported. 429 * @param keys the set of keys to compare with 430 * @param fileMap a Map from property files to their key sets 431 */ 432 private void compareKeySets(Set<Object> keys, 433 SetMultimap<File, Object> fileMap) { 434 435 for (File currentFile : fileMap.keySet()) { 436 final MessageDispatcher dispatcher = getMessageDispatcher(); 437 final String path = currentFile.getPath(); 438 dispatcher.fireFileStarted(path); 439 final Set<Object> currentKeys = fileMap.get(currentFile); 440 441 // Clone the keys so that they are not lost 442 final Set<Object> keysClone = Sets.newHashSet(keys); 443 keysClone.removeAll(currentKeys); 444 445 // Remaining elements in the key set are missing in the current file 446 if (!keysClone.isEmpty()) { 447 for (Object key : keysClone) { 448 log(0, MSG_KEY, key); 449 } 450 } 451 fireErrors(path); 452 dispatcher.fireFileFinished(path); 453 } 454 } 455 456 /** 457 * Tests whether the given property files (arranged by their prefixes 458 * in a Map) contain the proper keys. 459 * 460 * <p>Each group of files must have the same keys. If this is not the case 461 * an error message is posted giving information which key misses in 462 * which file. 463 * 464 * @param propFiles the property files organized as Map 465 */ 466 private void checkPropertyFileSets(SetMultimap<String, File> propFiles) { 467 468 for (String key : propFiles.keySet()) { 469 final Set<File> files = propFiles.get(key); 470 471 if (files.size() >= 2) { 472 // build a map from files to the keys they contain 473 final Set<Object> keys = Sets.newHashSet(); 474 final SetMultimap<File, Object> fileMap = HashMultimap.create(); 475 476 for (File file : files) { 477 final Set<Object> fileKeys = loadKeys(file); 478 keys.addAll(fileKeys); 479 fileMap.putAll(file, fileKeys); 480 } 481 482 // check the map for consistency 483 compareKeySets(keys, fileMap); 484 } 485 } 486 } 487}