001//////////////////////////////////////////////////////////////////////////////// 002// checkstyle: Checks Java source code for adherence to a set of rules. 003// Copyright (C) 2001-2015 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 final String extension = getFileExtensions()[0]; 206 207 for (String languageCode : requiredTranslations) { 208 final String translationFileName = 209 fullBundleName + '_' + languageCode + extension; 210 211 final boolean missing = isMissing(translationFileName, filesInResourceBundle); 212 if (missing) { 213 final String missingTranslationFileName = 214 formMissingTranslationName(fullBundleName, languageCode); 215 logMissingTranslation(missingTranslationFileName); 216 } 217 } 218 } 219 220 /** 221 * Gets full name of resource bundle. 222 * Full name of resource bundle consists of bundle path and 223 * full base name. 224 * @param filesInResourceBundle a set of files in resource bundle. 225 * @return full name of resource bundle. 226 */ 227 private String getFullBundleName(Set<File> filesInResourceBundle) { 228 final String fullBundleName; 229 230 final File firstTranslationFile = Collections.min(filesInResourceBundle); 231 final String translationPath = firstTranslationFile.getPath(); 232 final String extension = getFileExtensions()[0]; 233 234 final Pattern pattern = Pattern.compile("^.+_[a-z]{2}" 235 + extension + "$"); 236 final Matcher matcher = pattern.matcher(translationPath); 237 if (matcher.matches()) { 238 fullBundleName = translationPath 239 .substring(0, translationPath.lastIndexOf('_')); 240 } 241 else { 242 fullBundleName = translationPath 243 .substring(0, translationPath.lastIndexOf('.')); 244 } 245 return fullBundleName; 246 } 247 248 /** 249 * Checks whether file is missing in resource bundle. 250 * @param fileName file name. 251 * @param filesInResourceBundle a set of files in resource bundle. 252 * @return true if file is missing. 253 */ 254 private static boolean isMissing(String fileName, Set<File> filesInResourceBundle) { 255 boolean missing = false; 256 for (File file : filesInResourceBundle) { 257 final String currentFileName = file.getPath(); 258 missing = !currentFileName.equals(fileName); 259 if (!missing) { 260 break; 261 } 262 } 263 return missing; 264 } 265 266 /** 267 * Forms a name of translation file which is missing. 268 * @param fullBundleName full bundle name. 269 * @param languageCode language code. 270 * @return name of translation file which is missing. 271 */ 272 private String formMissingTranslationName(String fullBundleName, String languageCode) { 273 final String extension = getFileExtensions()[0]; 274 return String.format(Locale.ROOT, "%s_%s%s", fullBundleName, languageCode, extension); 275 } 276 277 /** 278 * Logs that translation file is missing. 279 * @param fullyQualifiedFileName fully qualified file name. 280 */ 281 private void logMissingTranslation(String fullyQualifiedFileName) { 282 final String filePath = extractPath(fullyQualifiedFileName); 283 284 final MessageDispatcher dispatcher = getMessageDispatcher(); 285 dispatcher.fireFileStarted(filePath); 286 287 log(0, MSG_KEY_MISSING_TRANSLATION_FILE, extractName(fullyQualifiedFileName)); 288 289 fireErrors(filePath); 290 dispatcher.fireFileFinished(filePath); 291 } 292 293 /** 294 * Extracts path from fully qualified file name. 295 * @param fullyQualifiedFileName fully qualified file name. 296 * @return file path. 297 */ 298 private static String extractPath(String fullyQualifiedFileName) { 299 return fullyQualifiedFileName 300 .substring(0, fullyQualifiedFileName.lastIndexOf(File.separator)); 301 } 302 303 /** 304 * Extracts short file name from fully qualified file name. 305 * @param fullyQualifiedFileName fully qualified file name. 306 * @return short file name. 307 */ 308 private static String extractName(String fullyQualifiedFileName) { 309 return fullyQualifiedFileName 310 .substring(fullyQualifiedFileName.lastIndexOf(File.separator) + 1); 311 } 312 313 /** 314 * Gets the basename (the unique prefix) of a property file. For example 315 * "xyz/messages" is the basename of "xyz/messages.properties", 316 * "xyz/messages_de_AT.properties", "xyz/messages_en.properties", etc. 317 * 318 * @param file the file 319 * @param basenameSeparator the basename separator 320 * @return the extracted basename 321 */ 322 private static String extractPropertyIdentifier(File file, String basenameSeparator) { 323 final String filePath = file.getPath(); 324 final int dirNameEnd = filePath.lastIndexOf(File.separatorChar); 325 final int baseNameStart = dirNameEnd + 1; 326 final int underscoreIdx = filePath.indexOf(basenameSeparator, 327 baseNameStart); 328 final int dotIdx = filePath.indexOf('.', baseNameStart); 329 final int cutoffIdx; 330 331 if (underscoreIdx == -1) { 332 cutoffIdx = dotIdx; 333 } 334 else { 335 cutoffIdx = underscoreIdx; 336 } 337 return filePath.substring(0, cutoffIdx); 338 } 339 340 /** 341 * Sets the separator used to determine the basename of a property file. 342 * This defaults to "_" 343 * 344 * @param basenameSeparator the basename separator 345 */ 346 public final void setBasenameSeparator(String basenameSeparator) { 347 this.basenameSeparator = basenameSeparator; 348 } 349 350 /** 351 * Arranges a set of property files by their prefix. 352 * The method returns a Map object. The filename prefixes 353 * work as keys each mapped to a set of files. 354 * @param propFiles the set of property files 355 * @param basenameSeparator the basename separator 356 * @return a Map object which holds the arranged property file sets 357 */ 358 private static SetMultimap<String, File> arrangePropertyFiles( 359 List<File> propFiles, String basenameSeparator) { 360 final SetMultimap<String, File> propFileMap = HashMultimap.create(); 361 362 for (final File file : propFiles) { 363 final String identifier = extractPropertyIdentifier(file, 364 basenameSeparator); 365 366 final Set<File> fileSet = propFileMap.get(identifier); 367 fileSet.add(file); 368 } 369 return propFileMap; 370 } 371 372 /** 373 * Loads the keys of the specified property file into a set. 374 * @param file the property file 375 * @return a Set object which holds the loaded keys 376 */ 377 private Set<Object> loadKeys(File file) { 378 final Set<Object> keys = Sets.newHashSet(); 379 InputStream inStream = null; 380 381 try { 382 // Load file and properties. 383 inStream = new FileInputStream(file); 384 final Properties props = new Properties(); 385 props.load(inStream); 386 387 // Gather the keys and put them into a set 388 final Enumeration<?> element = props.propertyNames(); 389 while (element.hasMoreElements()) { 390 keys.add(element.nextElement()); 391 } 392 } 393 catch (final IOException e) { 394 logIoException(e, file); 395 } 396 finally { 397 Closeables.closeQuietly(inStream); 398 } 399 return keys; 400 } 401 402 /** 403 * Helper method to log an io exception. 404 * @param ex the exception that occurred 405 * @param file the file that could not be processed 406 */ 407 private void logIoException(IOException ex, File file) { 408 String[] args = null; 409 String key = "general.fileNotFound"; 410 if (!(ex instanceof FileNotFoundException)) { 411 args = new String[] {ex.getMessage()}; 412 key = "general.exception"; 413 } 414 final LocalizedMessage message = 415 new LocalizedMessage( 416 0, 417 Definitions.CHECKSTYLE_BUNDLE, 418 key, 419 args, 420 getId(), 421 getClass(), null); 422 final SortedSet<LocalizedMessage> messages = Sets.newTreeSet(); 423 messages.add(message); 424 getMessageDispatcher().fireErrors(file.getPath(), messages); 425 LOG.debug("IOException occurred.", ex); 426 } 427 428 /** 429 * Compares the key sets of the given property files (arranged in a map) 430 * with the specified key set. All missing keys are reported. 431 * @param keys the set of keys to compare with 432 * @param fileMap a Map from property files to their key sets 433 */ 434 private void compareKeySets(Set<Object> keys, 435 SetMultimap<File, Object> fileMap) { 436 437 for (File currentFile : fileMap.keySet()) { 438 final MessageDispatcher dispatcher = getMessageDispatcher(); 439 final String path = currentFile.getPath(); 440 dispatcher.fireFileStarted(path); 441 final Set<Object> currentKeys = fileMap.get(currentFile); 442 443 // Clone the keys so that they are not lost 444 final Set<Object> keysClone = Sets.newHashSet(keys); 445 keysClone.removeAll(currentKeys); 446 447 // Remaining elements in the key set are missing in the current file 448 if (!keysClone.isEmpty()) { 449 for (Object key : keysClone) { 450 log(0, MSG_KEY, key); 451 } 452 } 453 fireErrors(path); 454 dispatcher.fireFileFinished(path); 455 } 456 } 457 458 /** 459 * Tests whether the given property files (arranged by their prefixes 460 * in a Map) contain the proper keys. 461 * 462 * <p>Each group of files must have the same keys. If this is not the case 463 * an error message is posted giving information which key misses in 464 * which file. 465 * 466 * @param propFiles the property files organized as Map 467 */ 468 private void checkPropertyFileSets(SetMultimap<String, File> propFiles) { 469 470 for (String key : propFiles.keySet()) { 471 final Set<File> files = propFiles.get(key); 472 473 if (files.size() >= 2) { 474 // build a map from files to the keys they contain 475 final Set<Object> keys = Sets.newHashSet(); 476 final SetMultimap<File, Object> fileMap = HashMultimap.create(); 477 478 for (File file : files) { 479 final Set<Object> fileKeys = loadKeys(file); 480 keys.addAll(fileKeys); 481 fileMap.putAll(file, fileKeys); 482 } 483 484 // check the map for consistency 485 compareKeySets(keys, fileMap); 486 } 487 } 488 } 489}