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.Enumeration;
028import java.util.List;
029import java.util.Properties;
030import java.util.Set;
031import java.util.SortedSet;
032import java.util.regex.Matcher;
033import java.util.regex.Pattern;
034
035import org.apache.commons.logging.Log;
036import org.apache.commons.logging.LogFactory;
037
038import com.google.common.base.Splitter;
039import com.google.common.collect.HashMultimap;
040import com.google.common.collect.ImmutableSortedSet;
041import com.google.common.collect.Lists;
042import com.google.common.collect.SetMultimap;
043import com.google.common.collect.Sets;
044import com.google.common.io.Closeables;
045import com.puppycrawl.tools.checkstyle.Definitions;
046import com.puppycrawl.tools.checkstyle.api.AbstractFileSetCheck;
047import com.puppycrawl.tools.checkstyle.api.LocalizedMessage;
048import com.puppycrawl.tools.checkstyle.api.MessageDispatcher;
049
050/**
051 * <p>
052 * The TranslationCheck class helps to ensure the correct translation of code by
053 * checking property files for consistency regarding their keys.
054 * Two property files describing one and the same context are consistent if they
055 * contain the same keys.
056 * </p>
057 * <p>
058 * An example of how to configure the check is:
059 * </p>
060 * <pre>
061 * &lt;module name="Translation"/&gt;
062 * </pre>
063 * Check has the following properties:
064 *
065 * <p><b>basenameSeparator</b> which allows setting separator in file names,
066 * default value is '_'.
067 * <p>
068 * E.g.:
069 * </p>
070 * <p>
071 * messages_test.properties //separator is '_'
072 * </p>
073 * <p>
074 * app-dev.properties //separator is '-'
075 * </p>
076 *
077 * <p><b>requiredTranslations</b> which allows to specify language codes of
078 * required translations which must exist in project. The check looks only for
079 * messages bundles which names contain the word 'messages'.
080 * Language code is composed of the lowercase, two-letter codes as defined by
081 * <a href="http://www.fatbellyman.com/webstuff/language_codes_639-1/">ISO 639-1</a>.
082 * Default value is <b>empty String Set</b> which means that only the existence of
083 * default translation is checked.
084 * Note, if you specify language codes (or just one language code) of required translations
085 * the check will also check for existence of default translation files in project.
086 * <br>
087 * @author Alexandra Bunge
088 * @author lkuehne
089 * @author Andrei Selkin
090 */
091public class TranslationCheck
092    extends AbstractFileSetCheck {
093
094    /**
095     * A key is pointing to the warning message text for missing key
096     * in "messages.properties" file.
097     */
098    public static final String MSG_KEY = "translation.missingKey";
099
100    /**
101     * A key is pointing to the warning message text for missing translation file
102     * in "messages.properties" file.
103     */
104    public static final String MSG_KEY_MISSING_TRANSLATION_FILE =
105        "translation.missingTranslationFile";
106
107    /** Logger for TranslationCheck. */
108    private static final Log LOG = LogFactory.getLog(TranslationCheck.class);
109
110    /** The property files to process. */
111    private final List<File> propertyFiles = Lists.newArrayList();
112
113    /** The separator string used to separate translation files. */
114    private String basenameSeparator;
115
116    /**
117     * Language codes of required translations for the check (de, pt, ja, etc).
118     */
119    private SortedSet<String> requiredTranslations = ImmutableSortedSet.of();
120
121    /**
122     * Creates a new {@code TranslationCheck} instance.
123     */
124    public TranslationCheck() {
125        setFileExtensions("properties");
126        basenameSeparator = "_";
127    }
128
129    /**
130     * Sets language codes of required translations for the check.
131     * @param translationCodes a comma separated list of language codes.
132     */
133    public void setRequiredTranslations(String translationCodes) {
134        requiredTranslations = Sets.newTreeSet(Splitter.on(',')
135            .trimResults().omitEmptyStrings().split(translationCodes));
136    }
137
138    @Override
139    public void beginProcessing(String charset) {
140        super.beginProcessing(charset);
141        propertyFiles.clear();
142    }
143
144    @Override
145    protected void processFiltered(File file, List<String> lines) {
146        propertyFiles.add(file);
147    }
148
149    @Override
150    public void finishProcessing() {
151        super.finishProcessing();
152        final SetMultimap<String, File> propFilesMap =
153            arrangePropertyFiles(propertyFiles, basenameSeparator);
154        checkExistenceOfTranslations(propFilesMap);
155        checkPropertyFileSets(propFilesMap);
156    }
157
158    /**
159     * Checks existence of translation files (arranged in a map)
160     * for each resource bundle in project.
161     * @param translations the translation files bundles organized as Map.
162     */
163    private void checkExistenceOfTranslations(SetMultimap<String, File> translations) {
164        for (String fullyQualifiedBundleName : translations.keySet()) {
165            final String bundleBaseName = extractName(fullyQualifiedBundleName);
166            if (bundleBaseName.contains("messages")) {
167                final Set<File> filesInBundle = translations.get(fullyQualifiedBundleName);
168                checkExistenceOfDefaultTranslation(filesInBundle);
169                checkExistenceOfRequiredTranslations(filesInBundle);
170            }
171        }
172    }
173
174    /**
175     * Checks an existence of default translation file in
176     * a set of files in resource bundle. The name of this file
177     * begins with the full name of the resource bundle and ends
178     * with the extension suffix.
179     * @param filesInResourceBundle a set of files in resource bundle.
180     */
181    private void checkExistenceOfDefaultTranslation(Set<File> filesInResourceBundle) {
182        final String fullBundleName = getFullBundleName(filesInResourceBundle);
183        final String extension = getFileExtensions()[0];
184        final String defaultTranslationFileName = fullBundleName + extension;
185
186        final boolean missing = isMissing(defaultTranslationFileName, filesInResourceBundle);
187        if (missing) {
188            logMissingTranslation(defaultTranslationFileName);
189        }
190    }
191
192    /**
193     * Checks existence of translation files in a set of files
194     * in resource bundle. If there is no translation file
195     * with required language code, there will be a violation.
196     * The name of translation file begins with the full name
197     * of resource bundle which is followed by '_' and language code,
198     * it ends with the extension suffix.
199     * @param filesInResourceBundle a set of files in resource bundle.
200     */
201    private void checkExistenceOfRequiredTranslations(Set<File> filesInResourceBundle) {
202        final String fullBundleName = getFullBundleName(filesInResourceBundle);
203        final String extension = getFileExtensions()[0];
204
205        for (String languageCode : requiredTranslations) {
206            final String translationFileName =
207                fullBundleName + '_' + languageCode + extension;
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 translationFile = filesInResourceBundle.iterator().next();
229        final String translationPath = translationFile.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.equals(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("%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<?> e = props.propertyNames();
387            while (e.hasMoreElements()) {
388                keys.add(e.nextElement());
389            }
390        }
391        catch (final IOException e) {
392            logIOException(e, 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 ex the exception that occurred
403     * @param file the file that could not be processed
404     */
405    private void logIOException(IOException ex, File file) {
406        String[] args = null;
407        String key = "general.fileNotFound";
408        if (!(ex instanceof FileNotFoundException)) {
409            args = new String[] {ex.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.", ex);
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}