1 ///////////////////////////////////////////////////////////////////////////////////////////////
2 // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3 // Copyright (C) 2001-2026 the original author or authors.
4 //
5 // This library is free software; you can redistribute it and/or
6 // modify it under the terms of the GNU Lesser General Public
7 // License as published by the Free Software Foundation; either
8 // version 2.1 of the License, or (at your option) any later version.
9 //
10 // This library is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13 // Lesser General Public License for more details.
14 //
15 // You should have received a copy of the GNU Lesser General Public
16 // License along with this library; if not, write to the Free Software
17 // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
18 ///////////////////////////////////////////////////////////////////////////////////////////////
19
20 package com.puppycrawl.tools.checkstyle.checks;
21
22 import java.io.File;
23 import java.io.InputStream;
24 import java.nio.file.Files;
25 import java.nio.file.NoSuchFileException;
26 import java.util.Arrays;
27 import java.util.Collections;
28 import java.util.HashSet;
29 import java.util.Locale;
30 import java.util.Map;
31 import java.util.Map.Entry;
32 import java.util.Optional;
33 import java.util.Properties;
34 import java.util.Set;
35 import java.util.SortedSet;
36 import java.util.TreeMap;
37 import java.util.TreeSet;
38 import java.util.regex.Matcher;
39 import java.util.regex.Pattern;
40 import java.util.stream.Collectors;
41
42 import org.apache.commons.logging.Log;
43 import org.apache.commons.logging.LogFactory;
44
45 import com.puppycrawl.tools.checkstyle.Definitions;
46 import com.puppycrawl.tools.checkstyle.GlobalStatefulCheck;
47 import com.puppycrawl.tools.checkstyle.LocalizedMessage;
48 import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck;
49 import com.puppycrawl.tools.checkstyle.api.FileText;
50 import com.puppycrawl.tools.checkstyle.api.MessageDispatcher;
51 import com.puppycrawl.tools.checkstyle.api.Violation;
52 import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
53
54 /**
55 * <div>
56 * Ensures the correct translation of code by checking property files for consistency
57 * regarding their keys. Two property files describing one and the same context
58 * are consistent if they contain the same keys. TranslationCheck also can check
59 * an existence of required translations which must exist in project, if
60 * {@code requiredTranslations} option is used.
61 * </div>
62 *
63 * <p>
64 * Notes:
65 * Language code for the property {@code requiredTranslations} is composed of
66 * the lowercase, two-letter codes as defined by
67 * <a href="https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes">ISO 639-1</a>.
68 * Default value is empty String Set which means that only the existence of default
69 * translation is checked. Note, if you specify language codes (or just one
70 * language code) of required translations the check will also check for existence
71 * of default translation files in project.
72 * </p>
73 *
74 * <p>
75 * Note: If your project uses preprocessed translation files and the original files do not have the
76 * {@code properties} extension, you can specify additional file extensions
77 * via the {@code fileExtensions} property.
78 * </p>
79 *
80 * <p>
81 * Attention: the check will perform the validation of ISO codes if the option
82 * is used. So, if you specify, for example, "mm" for language code,
83 * TranslationCheck will rise violation that the language code is incorrect.
84 * </p>
85 *
86 * <p>
87 * Attention: this Check could produce false-positives if it is used with
88 * <a href="https://checkstyle.org/config.html#Checker">Checker</a> that use cache
89 * (property "cacheFile") This is known design problem, will be addressed at
90 * <a href="https://github.com/checkstyle/checkstyle/issues/3539">issue</a>.
91 * </p>
92 *
93 * @since 3.0
94 */
95 @GlobalStatefulCheck
96 public final class TranslationCheck extends AbstractFileSetCheck {
97
98 /**
99 * A key is pointing to the warning message text for missing key
100 * in "messages.properties" file.
101 */
102 public static final String MSG_KEY = "translation.missingKey";
103
104 /**
105 * A key is pointing to the warning message text for missing translation file
106 * in "messages.properties" file.
107 */
108 public static final String MSG_KEY_MISSING_TRANSLATION_FILE =
109 "translation.missingTranslationFile";
110
111 /** Resource bundle which contains messages for TranslationCheck. */
112 private static final String TRANSLATION_BUNDLE =
113 "com.puppycrawl.tools.checkstyle.checks.messages";
114
115 /**
116 * A key is pointing to the warning message text for wrong language code
117 * in "messages.properties" file.
118 */
119 private static final String WRONG_LANGUAGE_CODE_KEY = "translation.wrongLanguageCode";
120
121 /**
122 * Regexp string for default translation files.
123 * For example, messages.properties.
124 */
125 private static final String DEFAULT_TRANSLATION_REGEXP = "^.+\\..+$";
126
127 /**
128 * Regexp pattern for bundles names which end with language code, followed by country code and
129 * variant suffix. For example, messages_es_ES_UNIX.properties.
130 */
131 private static final Pattern LANGUAGE_COUNTRY_VARIANT_PATTERN =
132 CommonUtil.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\_[A-Za-z]+\\..+$");
133 /**
134 * Regexp pattern for bundles names which end with language code, followed by country code
135 * suffix. For example, messages_es_ES.properties.
136 */
137 private static final Pattern LANGUAGE_COUNTRY_PATTERN =
138 CommonUtil.createPattern("^.+\\_[a-z]{2}\\_[A-Z]{2}\\..+$");
139 /**
140 * Regexp pattern for bundles names which end with language code suffix.
141 * For example, messages_es.properties.
142 */
143 private static final Pattern LANGUAGE_PATTERN =
144 CommonUtil.createPattern("^.+\\_[a-z]{2}\\..+$");
145
146 /** File name format for default translation. */
147 private static final String DEFAULT_TRANSLATION_FILE_NAME_FORMATTER = "%s.%s";
148 /** File name format with language code. */
149 private static final String FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER = "%s_%s.%s";
150
151 /** Formatting string to form regexp to validate required translations file names. */
152 private static final String REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS =
153 "^%1$s\\_%2$s(\\_[A-Z]{2})?\\.%3$s$|^%1$s\\_%2$s\\_[A-Z]{2}\\_[A-Za-z]+\\.%3$s$";
154 /** Formatting string to form regexp to validate default translations file names. */
155 private static final String REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS = "^%s\\.%s$";
156
157 /** Logger for TranslationCheck. */
158 private final Log log;
159
160 /** The files to process. */
161 private final Set<File> filesToProcess = new HashSet<>();
162
163 /**
164 * Specify
165 * <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/ResourceBundle.html">
166 * Base name</a> of resource bundles which contain message resources.
167 * It helps the check to distinguish config and localization resources.
168 */
169 private Pattern baseName;
170
171 /**
172 * Specify language codes of required translations which must exist in project.
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 * Setter to specify
187 * <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/ResourceBundle.html">
188 * Base name</a> of resource bundles which contain message resources.
189 * It helps the check to distinguish config and localization resources.
190 *
191 * @param baseName base name regexp.
192 * @since 6.17
193 */
194 public void setBaseName(Pattern baseName) {
195 this.baseName = baseName;
196 }
197
198 /**
199 * Setter to specify language codes of required translations which must exist in project.
200 *
201 * @param translationCodes language codes.
202 * @since 6.11
203 */
204 public void setRequiredTranslations(String... translationCodes) {
205 requiredTranslations = Arrays.stream(translationCodes)
206 .collect(Collectors.toUnmodifiableSet());
207 validateUserSpecifiedLanguageCodes(requiredTranslations);
208 }
209
210 /**
211 * Validates the correctness of user specified language codes for the check.
212 *
213 * @param languageCodes user specified language codes for the check.
214 * @throws IllegalArgumentException when any item of languageCodes is not valid language code
215 */
216 private void validateUserSpecifiedLanguageCodes(Set<String> languageCodes) {
217 for (String code : languageCodes) {
218 if (!isValidLanguageCode(code)) {
219 final LocalizedMessage msg = new LocalizedMessage(TRANSLATION_BUNDLE,
220 getClass(), WRONG_LANGUAGE_CODE_KEY, code);
221 throw new IllegalArgumentException(msg.getMessage());
222 }
223 }
224 }
225
226 /**
227 * Checks whether user specified language code is correct (is contained in available locales).
228 *
229 * @param userSpecifiedLanguageCode user specified language code.
230 * @return true if user specified language code is correct.
231 */
232 private static boolean isValidLanguageCode(final String userSpecifiedLanguageCode) {
233 boolean valid = false;
234 final Locale[] locales = Locale.getAvailableLocales();
235 for (Locale locale : locales) {
236 if (userSpecifiedLanguageCode.equals(locale.toString())) {
237 valid = true;
238 break;
239 }
240 }
241 return valid;
242 }
243
244 @Override
245 public void beginProcessing(String charset) {
246 filesToProcess.clear();
247 }
248
249 @Override
250 protected void processFiltered(File file, FileText fileText) {
251 // We are just collecting files for processing at finishProcessing()
252 filesToProcess.add(file);
253 }
254
255 @Override
256 public void finishProcessing() {
257 final Set<ResourceBundle> bundles = groupFilesIntoBundles(filesToProcess, baseName);
258 for (ResourceBundle currentBundle : bundles) {
259 checkExistenceOfDefaultTranslation(currentBundle);
260 checkExistenceOfRequiredTranslations(currentBundle);
261 checkTranslationKeys(currentBundle);
262 }
263 }
264
265 /**
266 * Checks an existence of default translation file in the resource bundle.
267 *
268 * @param bundle resource bundle.
269 */
270 private void checkExistenceOfDefaultTranslation(ResourceBundle bundle) {
271 getMissingFileName(bundle, null)
272 .ifPresent(fileName -> logMissingTranslation(bundle.getPath(), fileName));
273 }
274
275 /**
276 * Checks an existence of translation files in the resource bundle.
277 * The name of translation file begins with the base name of resource bundle which is followed
278 * by '_' and a language code (country and variant are optional), it ends with the extension
279 * suffix.
280 *
281 * @param bundle resource bundle.
282 */
283 private void checkExistenceOfRequiredTranslations(ResourceBundle bundle) {
284 for (String languageCode : requiredTranslations) {
285 getMissingFileName(bundle, languageCode)
286 .ifPresent(fileName -> logMissingTranslation(bundle.getPath(), fileName));
287 }
288 }
289
290 /**
291 * Returns the name of translation file which is absent in resource bundle or Guava's Optional,
292 * if there is not missing translation.
293 *
294 * @param bundle resource bundle.
295 * @param languageCode language code.
296 * @return the name of translation file which is absent in resource bundle or Guava's Optional,
297 * if there is not missing translation.
298 */
299 private static Optional<String> getMissingFileName(ResourceBundle bundle, String languageCode) {
300 final String fileNameRegexp;
301 final boolean searchForDefaultTranslation;
302 final String extension = bundle.getExtension();
303 final String baseName = bundle.getBaseName();
304 if (languageCode == null) {
305 searchForDefaultTranslation = true;
306 fileNameRegexp = String.format(Locale.ROOT, REGEXP_FORMAT_TO_CHECK_DEFAULT_TRANSLATIONS,
307 baseName, extension);
308 }
309 else {
310 searchForDefaultTranslation = false;
311 fileNameRegexp = String.format(Locale.ROOT,
312 REGEXP_FORMAT_TO_CHECK_REQUIRED_TRANSLATIONS, baseName, languageCode, extension);
313 }
314 Optional<String> missingFileName = Optional.empty();
315 if (!bundle.containsFile(fileNameRegexp)) {
316 if (searchForDefaultTranslation) {
317 missingFileName = Optional.of(String.format(Locale.ROOT,
318 DEFAULT_TRANSLATION_FILE_NAME_FORMATTER, baseName, extension));
319 }
320 else {
321 missingFileName = Optional.of(String.format(Locale.ROOT,
322 FILE_NAME_WITH_LANGUAGE_CODE_FORMATTER, baseName, languageCode, extension));
323 }
324 }
325 return missingFileName;
326 }
327
328 /**
329 * Logs that translation file is missing.
330 *
331 * @param filePath file path.
332 * @param fileName file name.
333 */
334 private void logMissingTranslation(String filePath, String fileName) {
335 final MessageDispatcher dispatcher = getMessageDispatcher();
336 dispatcher.fireFileStarted(filePath);
337 log(1, MSG_KEY_MISSING_TRANSLATION_FILE, fileName);
338 fireErrors(filePath);
339 dispatcher.fireFileFinished(filePath);
340 }
341
342 /**
343 * Groups a set of files into bundles.
344 * Only files, which names match base name regexp pattern will be grouped.
345 *
346 * @param files set of files.
347 * @param baseNameRegexp base name regexp pattern.
348 * @return set of ResourceBundles.
349 */
350 private static Set<ResourceBundle> groupFilesIntoBundles(Set<File> files,
351 Pattern baseNameRegexp) {
352 final Set<ResourceBundle> resourceBundles = new HashSet<>();
353 for (File currentFile : files) {
354 final String fileName = currentFile.getName();
355 final String baseName = extractBaseName(fileName);
356 final Matcher baseNameMatcher = baseNameRegexp.matcher(baseName);
357 if (baseNameMatcher.matches()) {
358 final String extension = CommonUtil.getFileExtension(fileName);
359 final String path = getPath(currentFile.getAbsolutePath());
360 final ResourceBundle newBundle = new ResourceBundle(baseName, path, extension);
361 final Optional<ResourceBundle> bundle = findBundle(resourceBundles, newBundle);
362 if (bundle.isPresent()) {
363 bundle.orElseThrow().addFile(currentFile);
364 }
365 else {
366 newBundle.addFile(currentFile);
367 resourceBundles.add(newBundle);
368 }
369 }
370 }
371 return resourceBundles;
372 }
373
374 /**
375 * Searches for specific resource bundle in a set of resource bundles.
376 *
377 * @param bundles set of resource bundles.
378 * @param targetBundle target bundle to search for.
379 * @return Guava's Optional of resource bundle (present if target bundle is found).
380 */
381 private static Optional<ResourceBundle> findBundle(Set<ResourceBundle> bundles,
382 ResourceBundle targetBundle) {
383 Optional<ResourceBundle> result = Optional.empty();
384 for (ResourceBundle currentBundle : bundles) {
385 if (targetBundle.getBaseName().equals(currentBundle.getBaseName())
386 && targetBundle.getExtension().equals(currentBundle.getExtension())
387 && targetBundle.getPath().equals(currentBundle.getPath())) {
388 result = Optional.of(currentBundle);
389 break;
390 }
391 }
392 return result;
393 }
394
395 /**
396 * Extracts the base name (the unique prefix) of resource bundle from translation file name.
397 * For example "messages" is the base name of "messages.properties",
398 * "messages_de_AT.properties", "messages_en.properties", etc.
399 *
400 * @param fileName the fully qualified name of the translation file.
401 * @return the extracted base name.
402 */
403 private static String extractBaseName(String fileName) {
404 final String regexp;
405 final Matcher languageCountryVariantMatcher =
406 LANGUAGE_COUNTRY_VARIANT_PATTERN.matcher(fileName);
407 final Matcher languageCountryMatcher = LANGUAGE_COUNTRY_PATTERN.matcher(fileName);
408 final Matcher languageMatcher = LANGUAGE_PATTERN.matcher(fileName);
409 if (languageCountryVariantMatcher.matches()) {
410 regexp = LANGUAGE_COUNTRY_VARIANT_PATTERN.pattern();
411 }
412 else if (languageCountryMatcher.matches()) {
413 regexp = LANGUAGE_COUNTRY_PATTERN.pattern();
414 }
415 else if (languageMatcher.matches()) {
416 regexp = LANGUAGE_PATTERN.pattern();
417 }
418 else {
419 regexp = DEFAULT_TRANSLATION_REGEXP;
420 }
421 // We use substring(...) instead of replace(...), so that the regular expression does
422 // not have to be compiled each time it is used inside 'replace' method.
423 final String removePattern = regexp.substring("^.+".length());
424 return fileName.replaceAll(removePattern, "");
425 }
426
427 /**
428 * Extracts path from a file name which contains the path.
429 * For example, if the file name is /xyz/messages.properties,
430 * then the method will return /xyz/.
431 *
432 * @param fileNameWithPath file name which contains the path.
433 * @return file path.
434 */
435 private static String getPath(String fileNameWithPath) {
436 return fileNameWithPath
437 .substring(0, fileNameWithPath.lastIndexOf(File.separator));
438 }
439
440 /**
441 * Checks resource files in bundle for consistency regarding their keys.
442 * All files in bundle must have the same key set. If this is not the case
443 * an audit event message is posted giving information which key misses in which file.
444 *
445 * @param bundle resource bundle.
446 */
447 private void checkTranslationKeys(ResourceBundle bundle) {
448 final Set<File> filesInBundle = bundle.getFiles();
449 // build a map from files to the keys they contain
450 final Set<String> allTranslationKeys = new HashSet<>();
451 final Map<File, Set<String>> filesAssociatedWithKeys = new TreeMap<>();
452 for (File currentFile : filesInBundle) {
453 final Set<String> keysInCurrentFile = getTranslationKeys(currentFile);
454 allTranslationKeys.addAll(keysInCurrentFile);
455 filesAssociatedWithKeys.put(currentFile, keysInCurrentFile);
456 }
457 checkFilesForConsistencyRegardingTheirKeys(filesAssociatedWithKeys, allTranslationKeys);
458 }
459
460 /**
461 * Compares th the specified key set with the key sets of the given translation files (arranged
462 * in a map). All missing keys are reported.
463 *
464 * @param fileKeys a Map from translation files to their key sets.
465 * @param keysThatMustExist the set of keys to compare with.
466 */
467 private void checkFilesForConsistencyRegardingTheirKeys(Map<File, Set<String>> fileKeys,
468 Set<String> keysThatMustExist) {
469 for (Entry<File, Set<String>> fileKey : fileKeys.entrySet()) {
470 final Set<String> currentFileKeys = fileKey.getValue();
471 final Set<String> missingKeys = keysThatMustExist.stream()
472 .filter(key -> !currentFileKeys.contains(key))
473 .collect(Collectors.toUnmodifiableSet());
474 if (!missingKeys.isEmpty()) {
475 final MessageDispatcher dispatcher = getMessageDispatcher();
476 final String path = fileKey.getKey().getAbsolutePath();
477 dispatcher.fireFileStarted(path);
478 for (Object key : missingKeys) {
479 log(1, MSG_KEY, key);
480 }
481 fireErrors(path);
482 dispatcher.fireFileFinished(path);
483 }
484 }
485 }
486
487 /**
488 * Loads the keys from the specified translation file into a set.
489 *
490 * @param file translation file.
491 * @return a Set object which holds the loaded keys.
492 */
493 private Set<String> getTranslationKeys(File file) {
494 Set<String> keys = new HashSet<>();
495 try (InputStream inStream = Files.newInputStream(file.toPath())) {
496 final Properties translations = new Properties();
497 translations.load(inStream);
498 keys = translations.stringPropertyNames();
499 }
500 // -@cs[IllegalCatch] It is better to catch all exceptions since it can throw
501 // a runtime exception.
502 catch (final Exception exc) {
503 logException(exc, file);
504 }
505 return keys;
506 }
507
508 /**
509 * Helper method to log an exception.
510 *
511 * @param exception the exception that occurred
512 * @param file the file that could not be processed
513 */
514 private void logException(Exception exception, File file) {
515 final String[] args;
516 final String key;
517 if (exception instanceof NoSuchFileException) {
518 args = null;
519 key = "general.fileNotFound";
520 }
521 else {
522 args = new String[] {exception.getMessage()};
523 key = "general.exception";
524 }
525 final Violation message =
526 new Violation(
527 0,
528 Definitions.CHECKSTYLE_BUNDLE,
529 key,
530 args,
531 getId(),
532 getClass(), null);
533 final SortedSet<Violation> messages = new TreeSet<>();
534 messages.add(message);
535 getMessageDispatcher().fireErrors(file.getPath(), messages);
536 log.debug("Exception occurred.", exception);
537 }
538
539 /** Class which represents a resource bundle. */
540 private static final class ResourceBundle {
541
542 /** Bundle base name. */
543 private final String baseName;
544 /** Common extension of files which are included in the resource bundle. */
545 private final String extension;
546 /** Common path of files which are included in the resource bundle. */
547 private final String path;
548 /** Set of files which are included in the resource bundle. */
549 private final Set<File> files;
550
551 /**
552 * Creates a ResourceBundle object with specific base name, common files extension.
553 *
554 * @param baseName bundle base name.
555 * @param path common path of files which are included in the resource bundle.
556 * @param extension common extension of files which are included in the resource bundle.
557 */
558 private ResourceBundle(String baseName, String path, String extension) {
559 this.baseName = baseName;
560 this.path = path;
561 this.extension = extension;
562 files = new HashSet<>();
563 }
564
565 /**
566 * Returns the bundle base name.
567 *
568 * @return the bundle base name
569 */
570 public String getBaseName() {
571 return baseName;
572 }
573
574 /**
575 * Returns the common path of files which are included in the resource bundle.
576 *
577 * @return the common path of files
578 */
579 public String getPath() {
580 return path;
581 }
582
583 /**
584 * Returns the common extension of files which are included in the resource bundle.
585 *
586 * @return the common extension of files
587 */
588 public String getExtension() {
589 return extension;
590 }
591
592 /**
593 * Returns the set of files which are included in the resource bundle.
594 *
595 * @return the set of files
596 */
597 public Set<File> getFiles() {
598 return Collections.unmodifiableSet(files);
599 }
600
601 /**
602 * Adds a file into resource bundle.
603 *
604 * @param file file which should be added into resource bundle.
605 */
606 public void addFile(File file) {
607 files.add(file);
608 }
609
610 /**
611 * Checks whether a resource bundle contains a file which name matches file name regexp.
612 *
613 * @param fileNameRegexp file name regexp.
614 * @return true if a resource bundle contains a file which name matches file name regexp.
615 */
616 public boolean containsFile(String fileNameRegexp) {
617 boolean containsFile = false;
618 for (File currentFile : files) {
619 if (Pattern.matches(fileNameRegexp, currentFile.getName())) {
620 containsFile = true;
621 break;
622 }
623 }
624 return containsFile;
625 }
626
627 }
628
629 }