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 * &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        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}