blob: b13d0b0c97124dcb45ad2c13ea809c615f85f925 [file] [log] [blame]
/*
* Copyright (C) 2011 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_NAME;
import static com.android.SdkConstants.CLASS_CONTEXT;
import static com.android.SdkConstants.CLASS_FRAGMENT;
import static com.android.SdkConstants.CLASS_RESOURCES;
import static com.android.SdkConstants.CLASS_V4_FRAGMENT;
import static com.android.SdkConstants.FORMAT_METHOD;
import static com.android.SdkConstants.GET_STRING_METHOD;
import static com.android.SdkConstants.TAG_STRING;
import static com.android.tools.lint.client.api.JavaEvaluatorKt.TYPE_BOOLEAN_WRAPPER;
import static com.android.tools.lint.client.api.JavaEvaluatorKt.TYPE_BYTE_WRAPPER;
import static com.android.tools.lint.client.api.JavaEvaluatorKt.TYPE_CHARACTER_WRAPPER;
import static com.android.tools.lint.client.api.JavaEvaluatorKt.TYPE_DOUBLE_WRAPPER;
import static com.android.tools.lint.client.api.JavaEvaluatorKt.TYPE_FLOAT_WRAPPER;
import static com.android.tools.lint.client.api.JavaEvaluatorKt.TYPE_INTEGER_WRAPPER;
import static com.android.tools.lint.client.api.JavaEvaluatorKt.TYPE_LONG_WRAPPER;
import static com.android.tools.lint.client.api.JavaEvaluatorKt.TYPE_OBJECT;
import static com.android.tools.lint.client.api.JavaEvaluatorKt.TYPE_SHORT_WRAPPER;
import static com.android.tools.lint.client.api.JavaEvaluatorKt.TYPE_STRING;
import static com.android.utils.CharSequences.indexOf;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.rendering.api.ResourceNamespace;
import com.android.ide.common.rendering.api.ResourceValue;
import com.android.ide.common.resources.ResourceItem;
import com.android.ide.common.resources.ResourceRepository;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.resources.ResourceUrl;
import com.android.tools.lint.client.api.JavaEvaluator;
import com.android.tools.lint.client.api.LintClient;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.JavaContext;
import com.android.tools.lint.detector.api.Lint;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Location.Handle;
import com.android.tools.lint.detector.api.Position;
import com.android.tools.lint.detector.api.ResourceEvaluator;
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.SourceCodeScanner;
import com.android.tools.lint.detector.api.XmlContext;
import com.android.utils.Pair;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.intellij.psi.PsiClassType;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiMethod;
import com.intellij.psi.PsiParameterList;
import com.intellij.psi.PsiType;
import com.intellij.psi.PsiVariable;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jetbrains.uast.UCallExpression;
import org.jetbrains.uast.UExpression;
import org.jetbrains.uast.ULiteralExpression;
import org.jetbrains.uast.UReferenceExpression;
import org.jetbrains.uast.UastFacade;
import org.jetbrains.uast.util.UastExpressionUtils;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* Check which looks for problems with formatting strings such as inconsistencies between
* translations or between string declaration and string usage in Java.
*
* <p>TODO: Handle Resources.getQuantityString as well
*/
public class StringFormatDetector extends ResourceXmlDetector implements SourceCodeScanner {
private static final Implementation IMPLEMENTATION_XML =
new Implementation(StringFormatDetector.class, Scope.ALL_RESOURCES_SCOPE);
@SuppressWarnings("unchecked")
private static final Implementation IMPLEMENTATION_XML_AND_JAVA =
new Implementation(
StringFormatDetector.class,
EnumSet.of(Scope.ALL_RESOURCE_FILES, Scope.JAVA_FILE),
Scope.JAVA_FILE_SCOPE);
/** Whether formatting strings are invalid */
public static final Issue INVALID =
Issue.create(
"StringFormatInvalid",
"Invalid format string",
"If a string contains a '%' character, then the string may be a formatting string "
+ "which will be passed to `String.format` from Java code to replace each '%' "
+ "occurrence with specific values.\n"
+ "\n"
+ "This lint warning checks for two related problems:\n"
+ "(1) Formatting strings that are invalid, meaning that `String.format` will throw "
+ "exceptions at runtime when attempting to use the format string.\n"
+ "(2) Strings containing '%' that are not formatting strings getting passed to "
+ "a `String.format` call. In this case the '%' will need to be escaped as '%%'.\n"
+ "\n"
+ "NOTE: Not all Strings which look like formatting strings are intended for "
+ "use by `String.format`; for example, they may contain date formats intended "
+ "for `android.text.format.Time#format()`. Lint cannot always figure out that "
+ "a String is a date format, so you may get false warnings in those scenarios. "
+ "See the suppress help topic for information on how to suppress errors in "
+ "that case.",
Category.MESSAGES,
9,
Severity.ERROR,
IMPLEMENTATION_XML);
/** Whether formatting argument types are consistent across translations */
public static final Issue ARG_COUNT =
Issue.create(
"StringFormatCount",
"Formatting argument types incomplete or inconsistent",
"When a formatted string takes arguments, it usually needs to reference the "
+ "same arguments in all translations (or all arguments if there are no "
+ "translations.\n"
+ "\n"
+ "There are cases where this is not the case, so this issue is a warning rather "
+ "than an error by default. However, this usually happens when a language is not "
+ "translated or updated correctly.",
Category.MESSAGES,
5,
Severity.WARNING,
IMPLEMENTATION_XML);
/** Whether the string format supplied in a call to String.format matches the format string */
public static final Issue ARG_TYPES =
Issue.create(
"StringFormatMatches",
"`String.format` string doesn't match the XML format string",
"This lint check ensures the following:\n"
+ "(1) If there are multiple translations of the format string, then all translations "
+ "use the same type for the same numbered arguments\n"
+ "(2) The usage of the format string in Java is consistent with the format string, "
+ "meaning that the parameter types passed to String.format matches those in the "
+ "format string.",
Category.MESSAGES,
9,
Severity.ERROR,
IMPLEMENTATION_XML_AND_JAVA);
/** This plural does not use the quantity value */
public static final Issue POTENTIAL_PLURAL =
Issue.create(
"PluralsCandidate",
"Potential Plurals",
"This lint check looks for potential errors in internationalization where you have "
+ "translated a message which involves a quantity and it looks like other parts of "
+ "the string may need grammatical changes.\n"
+ "\n"
+ "For example, rather than something like this:\n"
+ "```xml\n"
+ " <string name=\"try_again\">Try again in %d seconds.</string>\n"
+ "```\n"
+ "you should be using a plural:\n"
+ "```xml\n"
+ " <plurals name=\"try_again\">\n"
+ " <item quantity=\"one\">Try again in %d second</item>\n"
+ " <item quantity=\"other\">Try again in %d seconds</item>\n"
+ " </plurals>\n"
+ "```\n"
+ "This will ensure that in other languages the right set of translations are "
+ "provided for the different quantity classes.\n"
+ "\n"
+ "(This check depends on some heuristics, so it may not accurately determine whether "
+ "a string really should be a quantity. You can use tools:ignore to filter out false "
+ "positives.",
Category.MESSAGES,
5,
Severity.WARNING,
IMPLEMENTATION_XML)
.addMoreInfo(
"https://developer.android.com/guide/topics/resources/string-resource.html#Plurals");
/**
* Map from a format string name to a list of declaration file and actual formatting string
* content. We're using a list since a format string can be defined multiple times, usually for
* different translations.
*/
private Map<String, List<Pair<Handle, String>>> mFormatStrings;
/** Map of strings that do not contain any formatting. */
private final Map<String, Handle> mNotFormatStrings = new HashMap<>();
/**
* Set of strings that have an unknown format such as date formatting; we should not flag these
* as invalid when used from a String#format call
*/
private Set<String> mIgnoreStrings;
/** Constructs a new {@link StringFormatDetector} check */
public StringFormatDetector() {}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == ResourceFolderType.VALUES;
}
@Override
public Collection<String> getApplicableElements() {
return Collections.singletonList(TAG_STRING);
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
NodeList childNodes = element.getChildNodes();
if (childNodes.getLength() > 0) {
if (childNodes.getLength() == 1) {
Node child = childNodes.item(0);
if (child.getNodeType() == Node.TEXT_NODE) {
checkTextNode(context, element, stripQuotes(child.getNodeValue()));
}
} else {
// Concatenate children and build up a plain string.
// This is needed to handle xliff localization documents,
// but this needs more work so ignore compound XML documents as
// string values for now:
StringBuilder sb = new StringBuilder();
addText(sb, element);
if (sb.length() > 0) {
checkTextNode(context, element, sb.toString());
}
}
}
}
static void addText(StringBuilder sb, Node node) {
short nodeType = node.getNodeType();
if (nodeType == Node.TEXT_NODE || nodeType == Node.CDATA_SECTION_NODE) {
sb.append(stripQuotes(node.getNodeValue().trim()));
} else {
NodeList childNodes = node.getChildNodes();
for (int i = 0, n = childNodes.getLength(); i < n; i++) {
addText(sb, childNodes.item(i));
}
}
}
/**
* Removes all the unescaped quotes. See <a
* href="http://developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling">Escaping
* apostrophes and quotes</a>
*/
static String stripQuotes(String s) {
StringBuilder sb = new StringBuilder();
boolean isEscaped = false;
boolean isQuotedBlock = false;
for (int i = 0, len = s.length(); i < len; i++) {
char current = s.charAt(i);
if (isEscaped) {
sb.append(current);
isEscaped = false;
} else {
isEscaped = current == '\\'; // Next char will be escaped so we will just copy it
if (current == '"') {
isQuotedBlock = !isQuotedBlock;
} else if (current == '\'') {
if (isQuotedBlock) {
// We only add single quotes when they are within a quoted block
sb.append(current);
}
} else {
sb.append(current);
}
}
}
return sb.toString();
}
private void checkTextNode(XmlContext context, Element element, String text) {
String name = element.getAttribute(ATTR_NAME);
boolean found = false;
boolean foundPlural = false;
// Look at the String and see if it's a format string (contains
// positional %'s)
for (int j = 0, m = text.length(); j < m; j++) {
char c = text.charAt(j);
if (c == '\\') {
j++;
}
if (c == '%') {
// Also make sure this String isn't an unformatted String
String formatted = element.getAttribute("formatted");
if (!formatted.isEmpty() && !Boolean.parseBoolean(formatted)) {
if (!mNotFormatStrings.containsKey(name)) {
Handle handle = context.createLocationHandle(element);
handle.setClientData(element);
mNotFormatStrings.put(name, handle);
}
return;
}
// See if it's not a format string, e.g. "Battery charge is 100%!".
// If so we want to record this name in a special list such that we can
// make sure you don't attempt to reference this string from a String.format
// call.
Matcher matcher = FORMAT.matcher(text);
if (!matcher.find(j)) {
if (!mNotFormatStrings.containsKey(name)) {
Handle handle = context.createLocationHandle(element);
handle.setClientData(element);
mNotFormatStrings.put(name, handle);
}
return;
}
String conversion = matcher.group(6);
int conversionClass = getConversionClass(conversion.charAt(0));
if (conversionClass == CONVERSION_CLASS_UNKNOWN || matcher.group(5) != null) {
if (mIgnoreStrings == null) {
mIgnoreStrings = new HashSet<>();
}
mIgnoreStrings.add(name);
// Don't process any other strings here; some of them could
// accidentally look like a string, e.g. "%H" is a hash code conversion
// in String.format (and hour in Time formatting).
return;
}
if (conversionClass == CONVERSION_CLASS_INTEGER && !foundPlural) {
// See if there appears to be further text content here.
// Look for whitespace followed by a letter, with no punctuation in between
for (int k = matcher.end(); k < m; k++) {
char nc = text.charAt(k);
if (!Character.isWhitespace(nc)) {
if (Character.isLetter(nc)) {
foundPlural = checkPotentialPlural(context, element, text, k);
}
break;
}
}
}
found = true;
j++; // Ensure that when we process a "%%" we don't separately check the second %
}
}
if (!context.getProject().getReportIssues()) {
// If this is a library project not being analyzed, ignore it
return;
}
if (name != null) {
Handle handle = context.createLocationHandle(element);
handle.setClientData(element);
if (found) {
// Record it for analysis when seen in Java code
if (mFormatStrings == null) {
mFormatStrings = new HashMap<>();
}
List<Pair<Handle, String>> list = mFormatStrings.get(name);
if (list == null) {
list = new ArrayList<>();
mFormatStrings.put(name, list);
}
list.add(Pair.of(handle, text));
} else {
if (!isReference(text)) {
mNotFormatStrings.put(name, handle);
}
}
}
}
private static boolean isReference(@NonNull String text) {
for (int i = 0, n = text.length(); i < n; i++) {
char c = text.charAt(i);
if (!Character.isWhitespace(c)) {
return c == '@' || c == '?';
}
}
return false;
}
/**
* Checks whether the text begins with a non-unit word, pointing to a string that should
* probably be a plural instead. This
*/
private static boolean checkPotentialPlural(
XmlContext context, Element element, String text, int wordBegin) {
// This method should only be called if the text is known to start with a word
assert Character.isLetter(text.charAt(wordBegin));
int wordEnd = wordBegin;
while (wordEnd < text.length()) {
if (!Character.isLetter(text.charAt(wordEnd))) {
break;
}
wordEnd++;
}
// Eliminate units, since those are not sentences you need to use plurals for, e.g.
// "Elevation gain: %1$d m (%2$d ft)"
// We'll determine whether something is a unit by looking for
// (1) Multiple uppercase characters (e.g. KB, or MiB), or better yet, uppercase characters
// anywhere but as the first letter
// (2) No vowels (e.g. ft)
// (3) Adjacent consonants (e.g. ft); this one can eliminate some legitimate
// English words as well (e.g. "the") so we should really limit this to
// letter pairs that are not common in English. This is probably overkill
// so not handled yet. Instead we use a simpler heuristic:
// (4) Very short "words" (1-2 letters)
if (wordEnd - wordBegin <= 2) {
// Very short word (1-2 chars): possible unit, e.g. "m", "ft", "kb", etc
return false;
}
boolean hasVowel = false;
for (int i = wordBegin; i < wordEnd; i++) {
// Uppercase character anywhere but first character: probably a unit (e.g. KB)
char c = text.charAt(i);
if (i > wordBegin && Character.isUpperCase(c)) {
return false;
}
if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u' || c == 'y') {
hasVowel = true;
}
}
if (!hasVowel) {
// No vowels: likely unit
return false;
}
String word = text.substring(wordBegin, wordEnd);
// Some other known abbreviations that we don't want to count:
if (word.equals("min")) {
return false;
}
// This heuristic only works in English!
if (Lint.isEnglishResource(context, true)) {
String message =
String.format(
"Formatting %%d followed by words (\"%1$s\"): "
+ "This should probably be a plural rather than a string",
word);
context.report(POTENTIAL_PLURAL, element, context.getLocation(element), message);
// Avoid reporting multiple errors on the same string
// (if it contains more than one %d)
return true;
}
return false;
}
@Override
public void afterCheckRootProject(@NonNull Context context) {
if (mFormatStrings != null) {
boolean checkCount = context.isEnabled(ARG_COUNT);
boolean checkValid = context.isEnabled(INVALID);
boolean checkTypes = context.isEnabled(ARG_TYPES);
// Ensure that all the format strings are consistent with respect to each other;
// e.g. they all have the same number of arguments, they all use all the
// arguments, and they all use the same types for all the numbered arguments
for (Map.Entry<String, List<Pair<Handle, String>>> entry : mFormatStrings.entrySet()) {
String name = entry.getKey();
List<Pair<Handle, String>> list = entry.getValue();
// Check argument counts
if (checkCount) {
Handle notFormatted = mNotFormatStrings.get(name);
if (notFormatted != null) {
list =
ImmutableList.<Pair<Handle, String>>builder()
.add(Pair.of(notFormatted, name))
.addAll(list)
.build();
}
checkArity(context, name, list);
}
// Check argument types (and also make sure that the formatting strings are valid)
if (checkValid || checkTypes) {
checkTypes(context, checkValid, checkTypes, name, list);
}
}
}
}
private static void checkTypes(
Context context,
boolean checkValid,
boolean checkTypes,
String name,
List<Pair<Handle, String>> list) {
Map<Integer, String> types = new HashMap<>();
Map<Integer, Handle> typeDefinition = new HashMap<>();
for (Pair<Handle, String> pair : list) {
Handle handle = pair.getFirst();
String formatString = pair.getSecond();
//boolean warned = false;
Matcher matcher = FORMAT.matcher(formatString);
int index = 0;
int prevIndex = 0;
int nextNumber = 1;
while (true) {
if (matcher.find(index)) {
int matchStart = matcher.start();
// Make sure this is not an escaped '%'
for (; prevIndex < matchStart; prevIndex++) {
char c = formatString.charAt(prevIndex);
if (c == '\\') {
prevIndex++;
}
}
if (prevIndex > matchStart) {
// We're in an escape, ignore this result
index = prevIndex;
continue;
}
index = matcher.end(); // Ensure loop proceeds
String str = formatString.substring(matchStart, matcher.end());
if (str.equals("%%") || str.equals("%n")) {
// Just an escaped %
continue;
}
if (checkValid) {
// Make sure it's a valid format string
if (str.length() > 2 && str.charAt(str.length() - 2) == ' ') {
char last = str.charAt(str.length() - 1);
// If you forget to include the conversion character, e.g.
// "Weight=%1$ g" instead of "Weight=%1$d g", then
// you're going to end up with a format string interpreted as
// "%1$ g". This means that the space character is interpreted
// as a flag character, but it can only be a flag character
// when used in conjunction with the numeric conversion
// formats (d, o, x, X). If that's not the case, make a
// dedicated error message
if (last != 'd' && last != 'o' && last != 'x' && last != 'X') {
Object clientData = handle.getClientData();
if (clientData instanceof Node) {
if (context.getDriver()
.isSuppressed(null, INVALID, (Node) clientData)) {
return;
}
}
Location location = handle.resolve();
String message =
String.format(
"Incorrect formatting string `%1$s`; missing conversion "
+ "character in '`%2$s`' ?",
name, str);
context.report(INVALID, location, message);
//warned = true;
continue;
}
}
}
if (!checkTypes) {
continue;
}
// Shouldn't throw a number format exception since we've already
// matched the pattern in the regexp
int number;
String numberString = matcher.group(1);
if (numberString != null) {
// Strip off trailing $
numberString = numberString.substring(0, numberString.length() - 1);
number = Integer.parseInt(numberString);
nextNumber = number + 1;
} else {
number = nextNumber++;
}
String format = matcher.group(6);
String currentFormat = types.get(number);
if (currentFormat == null) {
types.put(number, format);
typeDefinition.put(number, handle);
} else if (!currentFormat.equals(format)
&& isIncompatible(currentFormat.charAt(0), format.charAt(0))) {
Object clientData = handle.getClientData();
if (clientData instanceof Node) {
if (context.getDriver()
.isSuppressed(null, ARG_TYPES, (Node) clientData)) {
return;
}
}
Location location = handle.resolve();
// Attempt to limit the location range to just the formatting
// string in question
location =
refineLocation(
context,
location,
formatString,
matcher.start(),
matcher.end());
Location otherLocation = typeDefinition.get(number).resolve();
otherLocation.setMessage("Conflicting argument type here");
location.setSecondary(otherLocation);
File f = otherLocation.getFile();
String message =
String.format(
"Inconsistent formatting types for argument #%1$d in "
+ "format string `%2$s` ('%3$s'): Found both '`%4$s`' and '`%5$s`' "
+ "(in %6$s)",
number,
name,
str,
currentFormat,
format,
Lint.getFileNameWithParent(context.getClient(), f));
//warned = true;
context.report(ARG_TYPES, location, message);
break;
}
} else {
break;
}
}
// Check that the format string is valid by actually attempting to instantiate
// it. We only do this if we haven't already complained about this string
// for other reasons.
/* Check disabled for now: it had many false reports due to conversion
* errors (which is expected since we just pass in strings), but once those
* are eliminated there aren't really any other valid error messages returned
* (for example, calling the formatter with bogus formatting flags always just
* returns a "conversion" error. It looks like we'd need to actually pass compatible
* arguments to trigger other types of formatting errors such as precision errors.
if (!warned && checkValid) {
try {
formatter.format(formatString, "", "", "", "", "", "", "",
"", "", "", "", "", "", "");
} catch (IllegalFormatException t) { // TODO: UnknownFormatConversionException
if (!t.getLocalizedMessage().contains(" != ")
&& !t.getLocalizedMessage().contains("Conversion")) {
Location location = handle.resolve();
context.report(INVALID, location,
String.format("Wrong format for %1$s: %2$s",
name, t.getLocalizedMessage()), null);
}
}
}
*/
}
}
/**
* Returns true if two String.format conversions are "incompatible" (meaning that using these
* two for the same argument across different translations is more likely an error than
* intentional. Some conversions are incompatible, e.g. "d" and "s" where one is a number and
* string, whereas others may work (e.g. float versus integer) but are probably not intentional.
*/
private static boolean isIncompatible(char conversion1, char conversion2) {
int class1 = getConversionClass(conversion1);
int class2 = getConversionClass(conversion2);
return class1 != class2
&& class1 != CONVERSION_CLASS_UNKNOWN
&& class2 != CONVERSION_CLASS_UNKNOWN;
}
private static final int CONVERSION_CLASS_UNKNOWN = 0;
private static final int CONVERSION_CLASS_STRING = 1;
private static final int CONVERSION_CLASS_CHARACTER = 2;
private static final int CONVERSION_CLASS_INTEGER = 3;
private static final int CONVERSION_CLASS_FLOAT = 4;
private static final int CONVERSION_CLASS_BOOLEAN = 5;
private static final int CONVERSION_CLASS_HASHCODE = 6;
private static final int CONVERSION_CLASS_PERCENT = 7;
private static final int CONVERSION_CLASS_NEWLINE = 8;
private static final int CONVERSION_CLASS_DATETIME = 9;
private static int getConversionClass(char conversion) {
// See http://developer.android.com/reference/java/util/Formatter.html
switch (conversion) {
case 't': // Time/date conversion
case 'T':
return CONVERSION_CLASS_DATETIME;
case 's': // string
case 'S': // Uppercase string
return CONVERSION_CLASS_STRING;
case 'c': // character
case 'C': // Uppercase character
return CONVERSION_CLASS_CHARACTER;
case 'd': // decimal
case 'o': // octal
case 'x': // hex
case 'X':
return CONVERSION_CLASS_INTEGER;
case 'f': // decimal float
case 'e': // exponential float
case 'E':
case 'g': // decimal or exponential depending on size
case 'G':
case 'a': // hex float
case 'A':
return CONVERSION_CLASS_FLOAT;
case 'b': // boolean
case 'B':
return CONVERSION_CLASS_BOOLEAN;
case 'h': // boolean
case 'H':
return CONVERSION_CLASS_HASHCODE;
case '%': // literal
return CONVERSION_CLASS_PERCENT;
case 'n': // literal
return CONVERSION_CLASS_NEWLINE;
}
return CONVERSION_CLASS_UNKNOWN;
}
private static Location refineLocation(
Context context,
Location location,
String formatString,
int substringStart,
int substringEnd) {
Position startLocation = location.getStart();
Position endLocation = location.getEnd();
if (startLocation != null && endLocation != null) {
int startOffset = startLocation.getOffset();
int endOffset = endLocation.getOffset();
if (startOffset >= 0) {
CharSequence contents = context.getClient().readFile(location.getFile());
if (endOffset <= contents.length() && startOffset < endOffset) {
int formatOffset = indexOf(contents, formatString, startOffset);
if (formatOffset != -1 && formatOffset <= endOffset) {
return Location.create(
location.getFile(),
contents,
formatOffset + substringStart,
formatOffset + substringEnd);
}
}
}
}
return location;
}
/**
* Check that the number of arguments in the format string is consistent across translations,
* and that all arguments are used
*/
private static void checkArity(Context context, String name, List<Pair<Handle, String>> list) {
// Check to make sure that the argument counts and types are consistent
int prevCount = -1;
for (Pair<Handle, String> pair : list) {
Set<Integer> indices = new HashSet<>();
int count = getFormatArgumentCount(pair.getSecond(), indices);
Handle handle = pair.getFirst();
if (prevCount != -1 && prevCount != count) {
Object clientData = handle.getClientData();
if (clientData instanceof Node) {
if (context.getDriver().isSuppressed(null, ARG_COUNT, (Node) clientData)) {
return;
}
}
Location location = handle.resolve();
Location secondary = list.get(0).getFirst().resolve();
secondary.setMessage("Conflicting number of arguments here");
location.setSecondary(secondary);
String message =
String.format(
"Inconsistent number of arguments in formatting string `%1$s`; "
+ "found both %2$d and %3$d",
name, prevCount, count);
context.report(ARG_COUNT, location, message);
break;
}
for (int i = 1; i <= count; i++) {
if (!indices.contains(i)) {
Object clientData = handle.getClientData();
if (clientData instanceof Node) {
if (context.getDriver().isSuppressed(null, ARG_COUNT, (Node) clientData)) {
return;
}
}
Set<Integer> all = new HashSet<>();
for (int j = 1; j < count; j++) {
all.add(j);
}
all.removeAll(indices);
List<Integer> sorted = new ArrayList<>(all);
Collections.sort(sorted);
Location location = handle.resolve();
String message =
String.format(
"Formatting string '`%1$s`' is not referencing numbered arguments %2$s",
name, sorted);
context.report(ARG_COUNT, location, message);
break;
}
}
prevCount = count;
}
}
// See java.util.Formatter docs
public static final Pattern FORMAT =
Pattern.compile(
// Generic format:
// %[argument_index$][flags][width][.precision]conversion
//
"%"
+
// Argument Index
"(\\d+\\$)?"
+
// Flags
"([-+#, 0(<]*)?"
+
// Width
"(\\d+)?"
+
// Precision
"(\\.\\d+)?"
+
// Conversion. These are all a single character, except date/time conversions
// which take a prefix of t/T:
"([tT])?"
+
// The current set of conversion characters are
// b,h,s,c,d,o,x,e,f,g,a,t (as well as all those as upper-case characters), plus
// n for newlines and % as a literal %. And then there are all the time/date
// characters: HIKLm etc. Just match on all characters here since there should
// be at least one.
"([a-zA-Z%])");
/** Given a format string returns the format type of the given argument */
@VisibleForTesting
@Nullable
static String getFormatArgumentType(String s, int argument) {
Matcher matcher = FORMAT.matcher(s);
int index = 0;
int prevIndex = 0;
int nextNumber = 1;
while (true) {
if (matcher.find(index)) {
String value = matcher.group(6);
if ("%".equals(value) || "n".equals(value)) {
index = matcher.end();
continue;
}
int matchStart = matcher.start();
// Make sure this is not an escaped '%'
for (; prevIndex < matchStart; prevIndex++) {
char c = s.charAt(prevIndex);
if (c == '\\') {
prevIndex++;
}
}
if (prevIndex > matchStart) {
// We're in an escape, ignore this result
index = prevIndex;
continue;
}
// Shouldn't throw a number format exception since we've already
// matched the pattern in the regexp
int number;
String numberString = matcher.group(1);
if (numberString != null) {
// Strip off trailing $
numberString = numberString.substring(0, numberString.length() - 1);
number = Integer.parseInt(numberString);
nextNumber = number + 1;
} else {
number = nextNumber++;
}
if (number == argument) {
return matcher.group(6);
}
index = matcher.end();
} else {
break;
}
}
return null;
}
/**
* Given a format string returns the number of required arguments. If the {@code seenArguments}
* parameter is not null, put the indices of any observed arguments into it.
*/
static int getFormatArgumentCount(@NonNull String s, @Nullable Set<Integer> seenArguments) {
Matcher matcher = FORMAT.matcher(s);
int index = 0;
int prevIndex = 0;
int nextNumber = 1;
int max = 0;
while (true) {
if (matcher.find(index)) {
String value = matcher.group(6);
if ("%".equals(value) || "n".equals(value)) {
index = matcher.end();
continue;
}
int matchStart = matcher.start();
// Make sure this is not an escaped '%'
for (; prevIndex < matchStart; prevIndex++) {
char c = s.charAt(prevIndex);
if (c == '\\') {
prevIndex++;
}
}
if (prevIndex > matchStart) {
// We're in an escape, ignore this result
index = prevIndex;
continue;
}
// Shouldn't throw a number format exception since we've already
// matched the pattern in the regexp
int number;
String numberString = matcher.group(1);
if (numberString != null) {
// Strip off trailing $
numberString = numberString.substring(0, numberString.length() - 1);
number = Integer.parseInt(numberString);
nextNumber = number + 1;
} else {
number = nextNumber++;
}
if (number > max) {
max = number;
}
if (seenArguments != null) {
seenArguments.add(number);
}
index = matcher.end();
} else {
break;
}
}
return max;
}
/**
* Determines whether the given {@link String#format(String, Object...)} formatting string is
* "locale dependent", meaning that its output depends on the locale. This is the case if it for
* example references decimal numbers of dates and times.
*
* @param format the format string
* @return true if the format is locale sensitive, false otherwise
*/
public static boolean isLocaleSpecific(@NonNull String format) {
if (format.indexOf('%') == -1) {
return false;
}
Matcher matcher = FORMAT.matcher(format);
int index = 0;
int prevIndex = 0;
while (true) {
if (matcher.find(index)) {
int matchStart = matcher.start();
// Make sure this is not an escaped '%'
for (; prevIndex < matchStart; prevIndex++) {
char c = format.charAt(prevIndex);
if (c == '\\') {
prevIndex++;
}
}
if (prevIndex > matchStart) {
// We're in an escape, ignore this result
index = prevIndex;
continue;
}
String type = matcher.group(6);
if (!type.isEmpty()) {
char t = type.charAt(0);
// The following formatting characters are locale sensitive:
switch (t) {
case 'd': // decimal integer
case 'e': // scientific
case 'E':
case 'f': // decimal float
case 'g': // general
case 'G':
case 't': // date/time
case 'T':
return true;
}
}
index = matcher.end();
} else {
break;
}
}
return false;
}
@Override
public List<String> getApplicableMethodNames() {
return Arrays.asList(FORMAT_METHOD, GET_STRING_METHOD);
}
@Override
public void visitMethodCall(
@NonNull JavaContext context,
@NonNull UCallExpression node,
@NonNull PsiMethod method) {
if (mFormatStrings == null && !context.getClient().supportsProjectResources()) {
return;
}
JavaEvaluator evaluator = context.getEvaluator();
String methodName = method.getName();
if (methodName.equals(FORMAT_METHOD)) {
if (evaluator.isMemberInClass(method, TYPE_STRING)) {
// Check formatting parameters for
// java.lang.String#format(String format, Object... formatArgs)
// java.lang.String#format(Locale locale, String format, Object... formatArgs)
checkStringFormatCall(
context, method, node, method.getParameterList().getParametersCount() == 3);
// TODO: Consider also enforcing
// java.util.Formatter#format(String string, Object... formatArgs)
}
} else {
// Look up any of these string formatting methods:
// android.content.res.Resources#getString(@StringRes int resId, Object... formatArgs)
// android.content.Context#getString(@StringRes int resId, Object... formatArgs)
// android.app.Fragment#getString(@StringRes int resId, Object... formatArgs)
// android.support.v4.app.Fragment#getString(@StringRes int resId, Object... formatArgs)
// Many of these also define a plain getString method:
// android.content.res.Resources#getString(@StringRes int resId)
// However, while it's possible that these contain formatting strings) it's
// also possible that they're looking up strings that are not intended to be used
// for formatting so while we may want to warn about this it's not necessarily
// an error.
if (method.getParameterList().getParametersCount() < 2) {
return;
}
if (evaluator.isMemberInSubClassOf(method, CLASS_RESOURCES, false)
|| evaluator.isMemberInSubClassOf(method, CLASS_CONTEXT, false)
|| evaluator.isMemberInSubClassOf(method, CLASS_FRAGMENT, false)
|| evaluator.isMemberInSubClassOf(method, CLASS_V4_FRAGMENT.oldName(), false)
|| evaluator.isMemberInSubClassOf(method, CLASS_V4_FRAGMENT.newName(), false)) {
checkStringFormatCall(context, method, node, false);
}
// TODO: Consider also looking up
// android.content.res.Resources#getQuantityString(@PluralsRes int id, int quantity,
// Object... formatArgs)
// though this will require being smarter about cross referencing formatting
// strings since we'll need to go via the quantity string definitions
}
}
/**
* Checks a String.format call that is using a string that doesn't contain format placeholders.
*
* @param context the context to report errors to
* @param call the AST node for the {@link String#format}
* @param name the string name
* @param handle the string location
*/
private static void checkNotFormattedHandle(
JavaContext context, UCallExpression call, String name, Handle handle) {
Object clientData = handle.getClientData();
if (clientData instanceof Node) {
if (context.getDriver().isSuppressed(null, INVALID, (Node) clientData)) {
return;
}
}
Location location = context.getLocation(call);
Location secondary = handle.resolve();
secondary.setMessage("This definition does not require arguments");
location.setSecondary(secondary);
String message =
String.format(
"Format string '`%1$s`' is not a valid format string so it should not be "
+ "passed to `String.format`",
name);
context.report(INVALID, call, location, message);
}
/**
* Check the given String.format call (with the given arguments) to see if the string format is
* being used correctly
*
* @param context the context to report errors to
* @param calledMethod the method being called
* @param call the AST node for the {@link String#format}
* @param specifiesLocale whether the first parameter is a locale string, shifting the
*/
private void checkStringFormatCall(
JavaContext context,
PsiMethod calledMethod,
UCallExpression call,
boolean specifiesLocale) {
int argIndex = specifiesLocale ? 1 : 0;
List<UExpression> args = call.getValueArguments();
if (args.size() <= argIndex) {
return;
}
UExpression argument = args.get(argIndex);
ResourceUrl resource = ResourceEvaluator.getResource(context.getEvaluator(), argument);
if (resource == null || resource.isFramework() || resource.type != ResourceType.STRING) {
return;
}
String name = resource.name;
if (mIgnoreStrings != null && mIgnoreStrings.contains(name)) {
return;
}
boolean passingVarArgsArray = false;
int callCount = args.size() - 1 - argIndex;
if (callCount == 1) {
// If instead of a varargs call like
// getString(R.string.foo, arg1, arg2, arg3)
// the code is calling the varargs method with a packed Object array, as in
// getString(R.string.foo, new Object[] { arg1, arg2, arg3 })
// we'll need to handle that such that we don't think this is a single
// argument
UExpression lastArg = args.get(args.size() - 1);
PsiParameterList parameterList = calledMethod.getParameterList();
int parameterCount = parameterList.getParametersCount();
if (parameterCount > 0
&& parameterList.getParameters()[parameterCount - 1].isVarArgs()) {
boolean knownArity = false;
boolean argWasReference = false;
if (lastArg instanceof UReferenceExpression) {
PsiElement resolved = ((UReferenceExpression) lastArg).resolve();
if (resolved instanceof PsiVariable) {
UExpression initializer =
UastFacade.INSTANCE.getInitializerBody((PsiVariable) resolved);
if (initializer != null
&& (UastExpressionUtils.isNewArray(initializer)
|| UastExpressionUtils.isArrayInitializer(initializer))) {
argWasReference = true;
// Now handled by check below
lastArg = initializer;
}
}
}
if (UastExpressionUtils.isNewArray(lastArg)
|| UastExpressionUtils.isArrayInitializer(lastArg)) {
UCallExpression arrayInitializer = (UCallExpression) lastArg;
if (UastExpressionUtils.isNewArrayWithInitializer(lastArg)
|| UastExpressionUtils.isArrayInitializer(lastArg)) {
callCount = arrayInitializer.getValueArgumentCount();
knownArity = true;
} else if (UastExpressionUtils.isNewArrayWithDimensions(lastArg)) {
List<UExpression> arrayDimensions = arrayInitializer.getValueArguments();
if (arrayDimensions.size() == 1) {
UExpression first = arrayDimensions.get(0);
if (first instanceof ULiteralExpression) {
Object o = ((ULiteralExpression) first).getValue();
if (o instanceof Integer) {
callCount = (Integer) o;
knownArity = true;
}
}
}
}
if (!knownArity) {
if (!argWasReference) {
return;
}
} else {
passingVarArgsArray = true;
}
}
}
}
if (callCount > 0 && mNotFormatStrings.containsKey(name)) {
checkNotFormattedHandle(context, call, name, mNotFormatStrings.get(name));
return;
}
List<Pair<Handle, String>> list = mFormatStrings != null ? mFormatStrings.get(name) : null;
if (list == null) {
LintClient client = context.getClient();
if (client.supportsProjectResources()
&& !context.getScope().contains(Scope.RESOURCE_FILE)) {
ResourceRepository resources =
client.getResourceRepository(context.getMainProject(), true, false);
List<ResourceItem> items;
if (resources != null) {
items =
resources.getResources(
ResourceNamespace.TODO(), ResourceType.STRING, name);
} else {
// Must be a non-Android module
items = null;
}
if (items != null) {
for (final ResourceItem item : items) {
ResourceValue v = item.getResourceValue();
if (v != null) {
String value = v.getRawXmlValue();
// Attempt to resolve indirection
if (value == null) {
continue;
}
if (isReference(value)) {
// Only resolve a few indirections
for (int i = 0; i < 3; i++) {
ResourceUrl url = ResourceUrl.parse(value);
if (url == null || url.isFramework()) {
break;
}
List<ResourceItem> l =
resources.getResources(
ResourceNamespace.TODO(), url.type, url.name);
if (l != null && !l.isEmpty()) {
v = l.get(0).getResourceValue();
if (v != null) {
value = v.getValue();
if (value == null || !isReference(value)) {
break;
}
} else {
break;
}
} else {
break;
}
}
}
if (value != null && !isReference(value)) {
// Make sure it's really a formatting string,
// not for example "Battery remaining: 90%"
boolean isFormattingString = value.indexOf('%') != -1;
for (int j = 0, m = value.length();
j < m && isFormattingString;
j++) {
char c = value.charAt(j);
if (c == '\\') {
j++;
} else if (c == '%') {
Matcher matcher = FORMAT.matcher(value);
if (!matcher.find(j)) {
isFormattingString = false;
} else {
String conversion = matcher.group(6);
int conversionClass =
getConversionClass(conversion.charAt(0));
if (conversionClass == CONVERSION_CLASS_UNKNOWN
|| matcher.group(5) != null) {
// Some date format etc - don't process
return;
}
}
j++; // Don't process second % in a %%
}
// If the user marked the string with
}
Handle handle = client.createResourceItemHandle(item);
if (isFormattingString) {
if (list == null) {
list = Lists.newArrayList();
if (mFormatStrings == null) {
mFormatStrings = Maps.newHashMap();
}
mFormatStrings.put(name, list);
}
list.add(Pair.of(handle, value));
} else if (callCount > 0) {
checkNotFormattedHandle(context, call, name, handle);
}
}
}
}
}
} else {
return;
}
}
if (list != null) {
Set<String> reported = null;
for (Pair<Handle, String> pair : list) {
String s = pair.getSecond();
if (reported != null && reported.contains(s)) {
continue;
}
int count = getFormatArgumentCount(s, null);
Handle handle = pair.getFirst();
if (count != callCount) {
Location location = context.getLocation(call);
Location secondary = handle.resolve();
secondary.setMessage(
String.format("This definition requires %1$d arguments", count));
location.setSecondary(secondary);
String message =
String.format(
"Wrong argument count, format string `%1$s` requires `%2$d` but format "
+ "call supplies `%3$d`",
name, count, callCount);
context.report(ARG_TYPES, call, location, message);
if (reported == null) {
reported = Sets.newHashSet();
}
reported.add(s);
} else {
if (passingVarArgsArray) {
// Can't currently check these: make sure we don't incorrectly
// flag parameters on the Object[] instead of the wrapped parameters
return;
}
for (int i = 1; i <= count; i++) {
int argumentIndex = i + argIndex;
PsiType type = args.get(argumentIndex).getExpressionType();
if (type != null) {
boolean valid = true;
String formatType = getFormatArgumentType(s, i);
if (formatType == null) {
continue;
}
char last = formatType.charAt(formatType.length() - 1);
if (formatType.length() >= 2
&& Character.toLowerCase(
formatType.charAt(formatType.length() - 2))
== 't') {
// Date time conversion.
// TODO
continue;
}
switch (last) {
// Booleans. It's okay to pass objects to these;
// it will print "true" if non-null, but it's
// unusual and probably not intended.
case 'b':
case 'B':
valid = isBooleanType(type);
break;
// Numeric: integer and floats in various formats
case 'x':
case 'X':
case 'd':
case 'o':
case 'e':
case 'E':
case 'f':
case 'g':
case 'G':
case 'a':
case 'A':
valid = isNumericType(type, true);
break;
case 'c':
case 'C':
// Unicode character
valid = isCharacterType(type);
break;
case 'h':
case 'H': // Hex print of hash code of objects
// From https://developer.android.com/reference/java/util/Formatter.html
// """The following general conversions may be applied to any
// argument type: 'b', 'B', 'h', 'H', 's', 'S' """
// We'll still warn about %s since you may have intended
// numeric formatting, but hex printing seems pretty well
// intended.
continue;
case 's':
case 'S':
// String. Can pass anything, but warn about
// numbers since you may have meant more
// specific formatting. Use special issue
// explanation for this?
valid = !isBooleanType(type) && !isNumericType(type, false);
break;
}
if (!valid) {
Location location = context.getLocation(args.get(argumentIndex));
Location secondary = handle.resolve();
secondary.setMessage("Conflicting argument declaration here");
location.setSecondary(secondary);
String suggestion = null;
if (isBooleanType(type)) {
suggestion = "`b`";
} else if (isCharacterType(type)) {
suggestion = "'c'";
} else if (PsiType.INT.equals(type)
|| PsiType.LONG.equals(type)
|| PsiType.BYTE.equals(type)
|| PsiType.SHORT.equals(type)) {
suggestion = "`d`, 'o' or `x`";
} else if (PsiType.FLOAT.equals(type)
|| PsiType.DOUBLE.equals(type)) {
suggestion = "`e`, 'f', 'g' or `a`";
} else if (type instanceof PsiClassType) {
String fqn = type.getCanonicalText();
if (TYPE_INTEGER_WRAPPER.equals(fqn)
|| TYPE_LONG_WRAPPER.equals(fqn)
|| TYPE_BYTE_WRAPPER.equals(fqn)
|| TYPE_SHORT_WRAPPER.equals(fqn)) {
suggestion = "`d`, 'o' or `x`";
} else if (TYPE_FLOAT_WRAPPER.equals(fqn)
|| TYPE_DOUBLE_WRAPPER.equals(fqn)) {
suggestion = "`d`, 'o' or `x`";
} else if (TYPE_OBJECT.equals(fqn)) {
suggestion = "'s' or 'h'";
}
}
if (suggestion != null) {
suggestion =
" (Did you mean formatting character "
+ suggestion
+ "?)";
} else {
suggestion = "";
}
String canonicalText = type.getCanonicalText();
canonicalText =
canonicalText.substring(canonicalText.lastIndexOf('.') + 1);
String message =
String.format(
"Wrong argument type for formatting argument '#%1$d' "
+ "in `%2$s`: conversion is '`%3$s`', received `%4$s` "
+ "(argument #%5$d in method call)%6$s",
i,
name,
formatType,
canonicalText,
argumentIndex + 1,
suggestion);
if ((last == 's' || last == 'S') && isNumericType(type, false)) {
message =
String.format(
"Suspicious argument type for formatting argument #%1$d "
+ "in `%2$s`: conversion is `%3$s`, received `%4$s` "
+ "(argument #%5$d in method call)%6$s",
i,
name,
formatType,
canonicalText,
argumentIndex + 1,
suggestion);
}
context.report(ARG_TYPES, call, location, message);
if (reported == null) {
reported = Sets.newHashSet();
}
reported.add(s);
}
}
}
}
}
}
}
private static boolean isCharacterType(PsiType type) {
//return PsiType.CHAR.isAssignableFrom(type);
if (type == PsiType.CHAR) {
return true;
}
if (type instanceof PsiClassType) {
String fqn = type.getCanonicalText();
return TYPE_CHARACTER_WRAPPER.equals(fqn);
}
return false;
}
private static boolean isBooleanType(PsiType type) {
//return PsiType.BOOLEAN.isAssignableFrom(type);
if (type == PsiType.BOOLEAN) {
return true;
}
if (type instanceof PsiClassType) {
String fqn = type.getCanonicalText();
return TYPE_BOOLEAN_WRAPPER.equals(fqn);
}
return false;
}
//PsiType:java.lang.Boolean
private static boolean isNumericType(@NonNull PsiType type, boolean allowBigNumbers) {
if (PsiType.INT.equals(type)
|| PsiType.FLOAT.equals(type)
|| PsiType.DOUBLE.equals(type)
|| PsiType.LONG.equals(type)
|| PsiType.BYTE.equals(type)
|| PsiType.SHORT.equals(type)) {
return true;
}
if (type instanceof PsiClassType) {
String fqn = type.getCanonicalText();
if (TYPE_INTEGER_WRAPPER.equals(fqn)
|| TYPE_FLOAT_WRAPPER.equals(fqn)
|| TYPE_DOUBLE_WRAPPER.equals(fqn)
|| TYPE_LONG_WRAPPER.equals(fqn)
|| TYPE_BYTE_WRAPPER.equals(fqn)
|| TYPE_SHORT_WRAPPER.equals(fqn)) {
return true;
}
if (allowBigNumbers) {
if ("java.math.BigInteger".equals(fqn) || "java.math.BigDecimal".equals(fqn)) {
return true;
}
}
}
return false;
}
}