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 * &lt;module name="Translation"/&gt;
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}