/* * Copyright (C) 2013 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.tools.lint.checks; import static com.android.SdkConstants.ATTR_QUANTITY; import static com.android.SdkConstants.TAG_ITEM; import static com.android.SdkConstants.TAG_PLURALS; import com.android.annotations.NonNull; import com.android.ide.common.resources.configuration.LocaleQualifier; import com.android.resources.ResourceFolderType; import com.android.tools.lint.checks.PluralsDatabase.Quantity; import com.android.tools.lint.detector.api.Category; import com.android.tools.lint.detector.api.Implementation; import com.android.tools.lint.detector.api.Issue; import com.android.tools.lint.detector.api.LintUtils; import com.android.tools.lint.detector.api.ResourceXmlDetector; import com.android.tools.lint.detector.api.Scope; import com.android.tools.lint.detector.api.Severity; import com.android.tools.lint.detector.api.XmlContext; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; /** * Checks for issues with quantity strings * <p> * https://code.google.com/p/android/issues/detail?id=53015 * 53015: lint could report incorrect usage of Resource.getQuantityString */ public class PluralsDetector extends ResourceXmlDetector { private static final Implementation IMPLEMENTATION = new Implementation( PluralsDetector.class, Scope.RESOURCE_FILE_SCOPE); /** This locale should define a quantity string for the given quantity */ public static final Issue MISSING = Issue.create( "MissingQuantity", //$NON-NLS-1$ "Missing quantity translation", "Different languages have different rules for grammatical agreement with " + "quantity. In English, for example, the quantity 1 is a special case. " + "We write \"1 book\", but for any other quantity we'd write \"n books\". " + "This distinction between singular and plural is very common, but other " + "languages make finer distinctions.\n" + "\n" + "This lint check looks at each translation of a `<plural>` and makes sure " + "that all the quantity strings considered by the given language are provided " + "by this translation.\n" + "\n" + "For example, an English translation must provide a string for `quantity=\"one\"`. " + "Similarly, a Czech translation must provide a string for `quantity=\"few\"`.", Category.MESSAGES, 8, Severity.ERROR, IMPLEMENTATION).addMoreInfo( "http://developer.android.com/guide/topics/resources/string-resource.html#Plurals"); /** This translation is not needed in this locale */ public static final Issue EXTRA = Issue.create( "UnusedQuantity", //$NON-NLS-1$ "Unused quantity translations", "Android defines a number of different quantity strings, such as `zero`, `one`, " + "`few` and `many`. However, many languages do not distinguish grammatically " + "between all these different quantities.\n" + "\n" + "This lint check looks at the quantity strings defined for each translation and " + "flags any quantity strings that are unused (because the language does not make that " + "quantity distinction, and Android will therefore not look it up.).\n" + "\n" + "For example, in Chinese, only the `other` quantity is used, so even if you " + "provide translations for `zero` and `one`, these strings will *not* be returned " + "when `getQuantityString()` is called, even with `0` or `1`.", Category.MESSAGES, 3, Severity.WARNING, IMPLEMENTATION).addMoreInfo( "http://developer.android.com/guide/topics/resources/string-resource.html#Plurals"); /** This plural does not use the quantity value */ public static final Issue IMPLIED_QUANTITY = Issue.create( "ImpliedQuantity", //$NON-NLS-1$ "Implied Quantities", "Plural strings should generally include a `%s` or `%d` formatting argument. " + "In locales like English, the `one` quantity only applies to a single value, " + "1, but that's not true everywhere. For example, in Slovene, the `one` quantity " + "will apply to 1, 101, 201, 301, and so on. Similarly, there are locales where " + "multiple values match the `zero` and `two` quantities.\n" + "\n" + "In these locales, it is usually an error to have a message which does not " + "include a formatting argument (such as '%d'), since it will not be clear from " + "the grammar what quantity the quantity string is describing.", Category.MESSAGES, 5, Severity.ERROR, IMPLEMENTATION).addMoreInfo( "http://developer.android.com/guide/topics/resources/string-resource.html#Plurals"); /** Constructs a new {@link PluralsDetector} */ public PluralsDetector() { } @Override public boolean appliesTo(@NonNull ResourceFolderType folderType) { return folderType == ResourceFolderType.VALUES; } @Override public Collection<String> getApplicableElements() { return Collections.singletonList(TAG_PLURALS); } @Override public void visitElement(@NonNull XmlContext context, @NonNull Element element) { int count = LintUtils.getChildCount(element); if (count == 0) { context.report(MISSING, element, context.getLocation(element), "There should be at least one quantity string in this `<plural>` definition"); return; } LocaleQualifier locale = LintUtils.getLocale(context); if (locale == null || !locale.hasLanguage()) { return; } String language = locale.getLanguage(); PluralsDatabase plurals = PluralsDatabase.get(); EnumSet<Quantity> relevant = plurals.getRelevant(language); if (relevant == null) { return; } EnumSet<Quantity> defined = EnumSet.noneOf(Quantity.class); NodeList children = element.getChildNodes(); for (int i = 0, n = children.getLength(); i < n; i++) { Node noe = children.item(i); if (noe.getNodeType() != Node.ELEMENT_NODE) { continue; } Element child = (Element) noe; if (!TAG_ITEM.equals(child.getTagName())) { continue; } String quantityString = child.getAttribute(ATTR_QUANTITY); if (quantityString == null || quantityString.isEmpty()) { continue; } Quantity quantity = Quantity.get(quantityString); if (quantity == null || quantity == Quantity.other) { // Not stored in the database continue; } defined.add(quantity); if (plurals.hasMultipleValuesForQuantity(language, quantity) && !haveFormattingParameter(child) && context.isEnabled(IMPLIED_QUANTITY)) { String example = plurals.findIntegerExamples(language, quantity); String append; if (example == null) { append = ""; } else { append = " (" + example + ")"; } String message = String.format("The quantity `'%1$s'` matches more than one " + "specific number in this locale%2$s, but the message did " + "not include a formatting argument (such as `%%d`). " + "This is usually an internationalization error. See full issue " + "explanation for more.", quantity, append); context.report(IMPLIED_QUANTITY, child, context.getLocation(child), message); } } if (relevant.equals(defined)) { return; } // Look for missing EnumSet<Quantity> missing = relevant.clone(); missing.removeAll(defined); if (!missing.isEmpty()) { String message = String.format( "For locale %1$s the following quantities should also be defined: %2$s", TranslationDetector.getLanguageDescription(language), Quantity.formatSet(missing)); context.report(MISSING, element, context.getLocation(element), message); } // Look for irrelevant EnumSet<Quantity> extra = defined.clone(); extra.removeAll(relevant); if (!extra.isEmpty()) { String message = String.format( "For language %1$s the following quantities are not relevant: %2$s", TranslationDetector.getLanguageDescription(language), Quantity.formatSet(extra)); context.report(EXTRA, element, context.getLocation(element), message); } } /** * Returns true if the given string/plurals item element contains a formatting parameter, * possibly within HTML markup or xliff metadata tags */ private static boolean haveFormattingParameter(@NonNull Element element) { NodeList children = element.getChildNodes(); for (int i = 0, n = children.getLength(); i < n; i++) { Node child = children.item(i); short nodeType = child.getNodeType(); if (nodeType == Node.ELEMENT_NODE) { if (haveFormattingParameter((Element)child)) { return true; } } else if (nodeType == Node.TEXT_NODE) { String text = child.getNodeValue(); if (text.indexOf('%') == -1) { continue; } if (StringFormatDetector.getFormatArgumentCount(text, null) >= 1) { return true; } } } return false; } }