blob: 36dce1bc388596d1e27abc9e111cc4033ee89409 [file] [log] [blame]
/*
* 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.Lint;
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 java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* 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",
"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",
"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",
"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 = Lint.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 = Lint.getLocale(context);
if (locale == null || !locale.hasLanguage()) {
return;
}
String language = locale.getLanguage();
assert language != null; // checked hasLanguage().
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 (containsExpandTemplate(text)) {
return true;
}
if (text.indexOf('%') == -1) {
continue;
}
if (StringFormatDetector.getFormatArgumentCount(text, null) >= 1) {
return true;
}
}
}
return false;
}
private static boolean containsExpandTemplate(@NonNull String text) {
// Checks to see if the string has a template parameter
// processed by android.text.TextUtils#expandTemplate
int index = 0;
while (true) {
index = text.indexOf('^', index);
if (index == -1 || index == text.length() - 1) {
return false;
}
if (Character.isDigit(text.charAt(index + 1))) {
return true;
}
index++;
}
}
}