| /* |
| * 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.DOT_JAVA; |
| import static com.android.SdkConstants.FORMAT_METHOD; |
| import static com.android.SdkConstants.GET_STRING_METHOD; |
| import static com.android.SdkConstants.R_CLASS; |
| import static com.android.SdkConstants.R_PREFIX; |
| import static com.android.SdkConstants.TAG_STRING; |
| import static com.android.tools.lint.checks.SharedPrefsDetector.ANDROID_CONTENT_SHARED_PREFERENCES; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_BOOLEAN; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_BYTE; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_CHAR; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_DOUBLE; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_FLOAT; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_INT; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_LONG; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_NULL; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_OBJECT; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_SHORT; |
| import static com.android.tools.lint.client.api.JavaParser.TYPE_STRING; |
| import static com.android.tools.lint.client.api.JavaParser.TypeDescriptor; |
| |
| import com.android.annotations.NonNull; |
| import com.android.annotations.Nullable; |
| import com.android.annotations.VisibleForTesting; |
| import com.android.ide.common.rendering.api.ResourceValue; |
| import com.android.ide.common.res2.AbstractResourceRepository; |
| import com.android.ide.common.res2.ResourceItem; |
| import com.android.resources.ResourceFolderType; |
| import com.android.resources.ResourceType; |
| import com.android.tools.lint.client.api.JavaParser; |
| 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.Detector; |
| 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.LintUtils; |
| 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.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 com.android.utils.Pair; |
| import com.google.common.collect.Lists; |
| import com.google.common.collect.Maps; |
| import com.google.common.collect.Sets; |
| |
| import org.w3c.dom.Element; |
| import org.w3c.dom.Node; |
| import org.w3c.dom.NodeList; |
| |
| 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.Iterator; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| import lombok.ast.AstVisitor; |
| import lombok.ast.BooleanLiteral; |
| import lombok.ast.CharLiteral; |
| import lombok.ast.ConstructorDeclaration; |
| import lombok.ast.ConstructorInvocation; |
| import lombok.ast.Expression; |
| import lombok.ast.FloatingPointLiteral; |
| import lombok.ast.ForwardingAstVisitor; |
| import lombok.ast.IntegralLiteral; |
| import lombok.ast.MethodDeclaration; |
| import lombok.ast.MethodInvocation; |
| import lombok.ast.NullLiteral; |
| import lombok.ast.Select; |
| import lombok.ast.StrictListAccessor; |
| import lombok.ast.StringLiteral; |
| import lombok.ast.VariableDefinitionEntry; |
| import lombok.ast.VariableReference; |
| |
| /** |
| * Check which looks for problems with formatting strings such as inconsistencies between |
| * translations or between string declaration and string usage in Java. |
| * <p> |
| * TODO: Verify booleans! |
| * TODO: Handle Resources.getQuantityString as well |
| */ |
| public class StringFormatDetector extends ResourceXmlDetector implements Detector.JavaScanner { |
| 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", //$NON-NLS-1$ |
| "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", //$NON-NLS-1$ |
| "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", //$NON-NLS-1$ |
| "`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", //$NON-NLS-1$ |
| "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" + |
| " <string name=\"try_again\">Try again in %d seconds.</string>\n" + |
| "you should be using a plural:\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" + |
| "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( |
| "http://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 contain percents that aren't formatting strings; these |
| * should not be passed to String.format. |
| */ |
| private final Map<String, Handle> mNotFormatStrings = new HashMap<String, Handle>(); |
| |
| /** |
| * 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 boolean appliesTo(@NonNull Context context, @NonNull File file) { |
| if (LintUtils.endsWith(file.getName(), DOT_JAVA)) { |
| return mFormatStrings != null; |
| } |
| |
| return super.appliesTo(context, file); |
| } |
| |
| @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, strip(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()); |
| } |
| } |
| } |
| } |
| |
| private static void addText(StringBuilder sb, Node node) { |
| if (node.getNodeType() == Node.TEXT_NODE) { |
| sb.append(strip(node.getNodeValue().trim())); |
| } else { |
| NodeList childNodes = node.getChildNodes(); |
| for (int i = 0, n = childNodes.getLength(); i < n; i++) { |
| addText(sb, childNodes.item(i)); |
| } |
| } |
| } |
| |
| private static String strip(String s) { |
| if (s.length() < 2) { |
| return s; |
| } |
| char first = s.charAt(0); |
| char last = s.charAt(s.length() - 1); |
| if (first == last && (first == '\'' || first == '"')) { |
| return s.substring(1, s.length() - 1); |
| } |
| |
| return s; |
| } |
| |
| private void checkTextNode(XmlContext context, Element element, String text) { |
| String name = null; |
| 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 == '%') { |
| if (name == null) { |
| name = element.getAttribute(ATTR_NAME); |
| } |
| |
| // Also make sure this String isn't an unformatted String |
| String formatted = element.getAttribute("formatted"); //$NON-NLS-1$ |
| 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<String>(); |
| } |
| 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 (found && name != null) { |
| if (!context.getProject().getReportIssues()) { |
| // If this is a library project not being analyzed, ignore it |
| return; |
| } |
| |
| // Record it for analysis when seen in Java code |
| if (mFormatStrings == null) { |
| mFormatStrings = new HashMap<String, List<Pair<Handle,String>>>(); |
| } |
| |
| List<Pair<Handle, String>> list = mFormatStrings.get(name); |
| if (list == null) { |
| list = new ArrayList<Pair<Handle, String>>(); |
| mFormatStrings.put(name, list); |
| } |
| Handle handle = context.createLocationHandle(element); |
| handle.setClientData(element); |
| list.add(Pair.of(handle, text)); |
| } |
| } |
| |
| /** |
| * 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 (LintUtils.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 afterCheckProject(@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) { |
| 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<Integer, String>(); |
| Map<Integer, Handle> typeDefinition = new HashMap<Integer, Handle>(); |
| 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")) { //$NON-NLS-1$ //$NON-NLS-2$ |
| // 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, |
| f.getParentFile().getName() + File.separator + f.getName()); |
| //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) { |
| String contents = context.getClient().readFile(location.getFile()); |
| if (endOffset <= contents.length() && startOffset < endOffset) { |
| int formatOffset = contents.indexOf(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<Integer>(); |
| 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<Integer>(); |
| for (int j = 1; j < count; j++) { |
| all.add(j); |
| } |
| all.removeAll(indices); |
| List<Integer> sorted = new ArrayList<Integer>(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 |
| // |
| "%" + //$NON-NLS-1$ |
| // Argument Index |
| "(\\d+\\$)?" + //$NON-NLS-1$ |
| // Flags |
| "([-+#, 0(\\<]*)?" + //$NON-NLS-1$ |
| // Width |
| "(\\d+)?" + //$NON-NLS-1$ |
| // Precision |
| "(\\.\\d+)?" + //$NON-NLS-1$ |
| // Conversion. These are all a single character, except date/time conversions |
| // which take a prefix of t/T: |
| "([tT])?" + //$NON-NLS-1$ |
| // 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%])"); //$NON-NLS-1$ |
| |
| /** 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)) { //$NON-NLS-1$ //$NON-NLS-2$ |
| 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. |
| */ |
| @VisibleForTesting |
| 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)) { //$NON-NLS-1$ //$NON-NLS-2$ |
| 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 visitMethod(@NonNull JavaContext context, @Nullable AstVisitor visitor, |
| @NonNull MethodInvocation node) { |
| if (mFormatStrings == null && !context.getClient().supportsProjectResources()) { |
| return; |
| } |
| |
| String methodName = node.astName().astValue(); |
| if (methodName.equals(FORMAT_METHOD)) { |
| // String.format(getResources().getString(R.string.foo), arg1, arg2, ...) |
| // Check that the arguments in R.string.foo match arg1, arg2, ... |
| if (node.astOperand() instanceof VariableReference) { |
| VariableReference ref = (VariableReference) node.astOperand(); |
| if ("String".equals(ref.astIdentifier().astValue())) { //$NON-NLS-1$ |
| // Found a String.format call |
| // Look inside to see if we can find an R string |
| // Find surrounding method |
| checkFormatCall(context, node); |
| } |
| } |
| } else { |
| // getResources().getString(R.string.foo, arg1, arg2, ...) |
| // Check that the arguments in R.string.foo match arg1, arg2, ... |
| if (node.astArguments().size() > 1 && node.astOperand() != null ) { |
| checkFormatCall(context, node); |
| } |
| } |
| } |
| |
| private void checkFormatCall(JavaContext context, MethodInvocation node) { |
| lombok.ast.Node current = getParentMethod(node); |
| if (current != null) { |
| checkStringFormatCall(context, current, node); |
| } |
| } |
| |
| /** |
| * 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 method the method containing the {@link String#format} call |
| * @param call the AST node for the {@link String#format} |
| */ |
| private void checkStringFormatCall( |
| JavaContext context, |
| lombok.ast.Node method, |
| MethodInvocation call) { |
| |
| StrictListAccessor<Expression, MethodInvocation> args = call.astArguments(); |
| if (args.isEmpty()) { |
| return; |
| } |
| |
| StringTracker tracker = new StringTracker(context, method, call, 0); |
| method.accept(tracker); |
| String name = tracker.getFormatStringName(); |
| if (name == null) { |
| return; |
| } |
| |
| if (mIgnoreStrings != null && mIgnoreStrings.contains(name)) { |
| return; |
| } |
| |
| if (mNotFormatStrings.containsKey(name)) { |
| Handle handle = mNotFormatStrings.get(name); |
| 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( |
| "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); |
| return; |
| } |
| |
| Iterator<Expression> argIterator = args.iterator(); |
| Expression first = argIterator.next(); |
| Expression second = argIterator.hasNext() ? argIterator.next() : null; |
| |
| boolean specifiesLocale; |
| TypeDescriptor parameterType = context.getType(first); |
| if (parameterType != null) { |
| specifiesLocale = isLocaleReference(parameterType.getName()); |
| } else if (!call.astName().astValue().equals(FORMAT_METHOD)) { |
| specifiesLocale = false; |
| } else { |
| // No type information with this AST; use string patterns instead to make |
| // an educated guess |
| String firstName = first.toString(); |
| specifiesLocale = firstName.startsWith("Locale.") //$NON-NLS-1$ |
| || firstName.contains("locale") //$NON-NLS-1$ |
| || firstName.equals("null") //$NON-NLS-1$ |
| || (second != null && second.toString().contains("getString") //$NON-NLS-1$ |
| && !firstName.contains("getString") //$NON-NLS-1$ |
| && !firstName.contains(R_PREFIX) |
| && !(first instanceof StringLiteral)); |
| } |
| |
| 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)) { |
| AbstractResourceRepository resources = client |
| .getProjectResources(context.getMainProject(), true); |
| List<ResourceItem> items = resources |
| .getResourceItem(ResourceType.STRING, name); |
| if (items != null) { |
| for (final ResourceItem item : items) { |
| ResourceValue v = item.getResourceValue(false); |
| if (v != null) { |
| String value = v.getRawXmlValue(); |
| if (value != null) { |
| // 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 |
| } |
| |
| if (isFormattingString) { |
| if (list == null) { |
| list = Lists.newArrayList(); |
| if (mFormatStrings == null) { |
| mFormatStrings = Maps.newHashMap(); |
| } |
| mFormatStrings.put(name, list); |
| } |
| Handle handle = client.createResourceItemHandle(item); |
| list.add(Pair.of(handle, value)); |
| } |
| } |
| } |
| } |
| } |
| } 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 != args.size() - 1 - (specifiesLocale ? 1 : 0)) { |
| if (isSharedPreferenceGetString(context, call)) { |
| continue; |
| } |
| |
| 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, args.size() - 1 - (specifiesLocale ? 1 : 0)); |
| context.report(ARG_TYPES, method, location, message); |
| if (reported == null) { |
| reported = Sets.newHashSet(); |
| } |
| reported.add(s); |
| } else { |
| for (int i = 1; i <= count; i++) { |
| int argumentIndex = i + (specifiesLocale ? 1 : 0); |
| Class<?> type = tracker.getArgumentType(argumentIndex); |
| 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 = type == Boolean.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 = type == Integer.TYPE |
| || type == Float.TYPE |
| || type == Double.TYPE |
| || type == Long.TYPE |
| || type == Byte.TYPE |
| || type == Short.TYPE; |
| break; |
| case 'c': |
| case 'C': |
| // Unicode character |
| valid = type == Character.TYPE; |
| break; |
| case 'h': |
| case 'H': // Hex print of hash code of objects |
| 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 = type != Boolean.TYPE && |
| !Number.class.isAssignableFrom(type); |
| break; |
| } |
| |
| if (!valid) { |
| if (isSharedPreferenceGetString(context, call)) { |
| continue; |
| } |
| |
| Expression argument = tracker.getArgument(argumentIndex); |
| Location location = context.getLocation(argument); |
| Location secondary = handle.resolve(); |
| secondary.setMessage("Conflicting argument declaration here"); |
| location.setSecondary(secondary); |
| |
| 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)", |
| i, name, formatType, type.getSimpleName(), |
| argumentIndex + 1); |
| context.report(ARG_TYPES, method, location, message); |
| if (reported == null) { |
| reported = Sets.newHashSet(); |
| } |
| reported.add(s); |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| private static boolean isSharedPreferenceGetString(@NonNull JavaContext context, |
| @NonNull MethodInvocation call) { |
| if (!GET_STRING_METHOD.equals(call.astName().astValue())) { |
| return false; |
| } |
| |
| JavaParser.ResolvedNode resolved = context.resolve(call); |
| if (resolved instanceof JavaParser.ResolvedMethod) { |
| JavaParser.ResolvedMethod resolvedMethod = (JavaParser.ResolvedMethod) resolved; |
| JavaParser.ResolvedClass containingClass = resolvedMethod.getContainingClass(); |
| return containingClass.isSubclassOf(ANDROID_CONTENT_SHARED_PREFERENCES, false); |
| } |
| |
| return false; // not certain |
| } |
| |
| private static boolean isLocaleReference(@Nullable TypeDescriptor reference) { |
| return reference != null && isLocaleReference(reference.getName()); |
| } |
| |
| private static boolean isLocaleReference(@Nullable String typeName) { |
| return typeName != null && (typeName.equals("Locale") //$NON-NLS-1$ |
| || typeName.equals("java.util.Locale")); //$NON-NLS-1$ |
| } |
| |
| /** Returns the parent method of the given AST node */ |
| @Nullable |
| public static lombok.ast.Node getParentMethod(@NonNull lombok.ast.Node node) { |
| lombok.ast.Node current = node.getParent(); |
| while (current != null |
| && !(current instanceof MethodDeclaration) |
| && !(current instanceof ConstructorDeclaration)) { |
| current = current.getParent(); |
| } |
| |
| return current; |
| } |
| |
| /** Returns the resource name corresponding to the first argument in the given call */ |
| @Nullable |
| public static String getResourceForFirstArg( |
| @NonNull lombok.ast.Node method, |
| @NonNull lombok.ast.Node call) { |
| assert call instanceof MethodInvocation || call instanceof ConstructorInvocation; |
| StringTracker tracker = new StringTracker(null, method, call, 0); |
| method.accept(tracker); |
| |
| return tracker.getFormatStringName(); |
| } |
| |
| /** Returns the resource name corresponding to the given argument in the given call */ |
| @Nullable |
| public static String getResourceArg( |
| @NonNull lombok.ast.Node method, |
| @NonNull lombok.ast.Node call, |
| int argIndex) { |
| assert call instanceof MethodInvocation || call instanceof ConstructorInvocation; |
| StringTracker tracker = new StringTracker(null, method, call, argIndex); |
| method.accept(tracker); |
| |
| return tracker.getFormatStringName(); |
| } |
| |
| /** |
| * Given a variable reference, finds the original R.string value corresponding to it. |
| * For example: |
| * <pre> |
| * {@code |
| * String target = "World"; |
| * String hello = getResources().getString(R.string.hello); |
| * String output = String.format(hello, target); |
| * } |
| * </pre> |
| * |
| * Given the {@code String.format} call, we want to find out what R.string resource |
| * corresponds to the first argument, in this case {@code R.string.hello}. |
| * To do this, we look for R.string references, and track those through assignments |
| * until we reach the target node. |
| * <p> |
| * In addition, it also does some primitive type tracking such that it (in some cases) |
| * can answer questions about the types of variables. This allows it to check whether |
| * certain argument types are valid. Note however that it does not do full-blown |
| * type analysis by checking method call signatures and so on. |
| */ |
| private static class StringTracker extends ForwardingAstVisitor { |
| /** Method we're searching within */ |
| private final lombok.ast.Node mTop; |
| /** The argument index in the method we're targeting */ |
| private final int mArgIndex; |
| /** Map from variable name to corresponding string resource name */ |
| private final Map<String, String> mMap = new HashMap<String, String>(); |
| /** Map from variable name to corresponding type */ |
| private final Map<String, Class<?>> mTypes = new HashMap<String, Class<?>>(); |
| /** The AST node for the String.format we're interested in */ |
| private final lombok.ast.Node mTargetNode; |
| private boolean mDone; |
| @Nullable |
| private JavaContext mContext; |
| |
| /** |
| * Result: the name of the string resource being passed to the |
| * String.format, if any |
| */ |
| private String mName; |
| |
| public StringTracker(@Nullable JavaContext context, lombok.ast.Node top, lombok.ast.Node targetNode, int argIndex) { |
| mContext = context; |
| mTop = top; |
| mArgIndex = argIndex; |
| mTargetNode = targetNode; |
| } |
| |
| public String getFormatStringName() { |
| return mName; |
| } |
| |
| /** Returns the argument type of the given formatting argument of the |
| * target node. Note: This is in the formatting string, which is one higher |
| * than the String.format parameter number, since the first argument is the |
| * formatting string itself. |
| * |
| * @param argument the argument number |
| * @return the class (such as {@link Integer#TYPE} etc) or null if not known |
| */ |
| public Class<?> getArgumentType(int argument) { |
| Expression arg = getArgument(argument); |
| if (arg != null) { |
| // Look up type based on the source code literals |
| Class<?> type = getType(arg); |
| if (type != null) { |
| return type; |
| } |
| |
| // If the AST supports type resolution, use that for other types |
| // of expressions |
| if (mContext != null) { |
| return getTypeClass(mContext.getType(arg)); |
| } |
| } |
| |
| return null; |
| } |
| |
| private static Class<?> getTypeClass(@Nullable TypeDescriptor type) { |
| if (type != null) { |
| return getTypeClass(type.getName()); |
| } |
| return null; |
| } |
| |
| private static Class<?> getTypeClass(@Nullable String fqcn) { |
| if (fqcn == null) { |
| return null; |
| } else if (fqcn.equals(TYPE_STRING) || fqcn.equals("String")) { //$NON-NLS-1$ |
| return String.class; |
| } else if (fqcn.equals(TYPE_INT)) { |
| return Integer.TYPE; |
| } else if (fqcn.equals(TYPE_BOOLEAN)) { |
| return Boolean.TYPE; |
| } else if (fqcn.equals(TYPE_NULL)) { |
| return Object.class; |
| } else if (fqcn.equals(TYPE_LONG)) { |
| return Long.TYPE; |
| } else if (fqcn.equals(TYPE_FLOAT)) { |
| return Float.TYPE; |
| } else if (fqcn.equals(TYPE_DOUBLE)) { |
| return Double.TYPE; |
| } else if (fqcn.equals(TYPE_CHAR)) { |
| return Character.TYPE; |
| } else if (fqcn.equals("BigDecimal") //$NON-NLS-1$ |
| || fqcn.equals("java.math.BigDecimal")) { //$NON-NLS-1$ |
| return Float.TYPE; |
| } else if (fqcn.equals("BigInteger") //$NON-NLS-1$ |
| || fqcn.equals("java.math.BigInteger")) { //$NON-NLS-1$ |
| return Integer.TYPE; |
| } else if (fqcn.equals(TYPE_OBJECT)) { |
| return null; |
| } else if (fqcn.startsWith("java.lang.")) { |
| if (fqcn.equals("java.lang.Integer") |
| || fqcn.equals("java.lang.Short") |
| || fqcn.equals("java.lang.Byte") |
| || fqcn.equals("java.lang.Long")) { |
| return Integer.TYPE; |
| } else if (fqcn.equals("java.lang.Float") |
| || fqcn.equals("java.lang.Double")) { |
| return Float.TYPE; |
| } else { |
| return null; |
| } |
| } else if (fqcn.equals(TYPE_BYTE)) { |
| return Byte.TYPE; |
| } else if (fqcn.equals(TYPE_SHORT)) { |
| return Short.TYPE; |
| } else { |
| return null; |
| } |
| } |
| |
| public Expression getArgument(int argument) { |
| if (!(mTargetNode instanceof MethodInvocation)) { |
| return null; |
| } |
| MethodInvocation call = (MethodInvocation) mTargetNode; |
| StrictListAccessor<Expression, MethodInvocation> args = call.astArguments(); |
| if (argument >= args.size()) { |
| return null; |
| } |
| |
| Iterator<Expression> iterator = args.iterator(); |
| int index = 0; |
| while (iterator.hasNext()) { |
| Expression arg = iterator.next(); |
| if (index++ == argument) { |
| return arg; |
| } |
| } |
| |
| return null; |
| } |
| |
| @Override |
| public boolean visitNode(lombok.ast.Node node) { |
| return mDone || super.visitNode(node); |
| |
| } |
| |
| @Override |
| public boolean visitVariableReference(VariableReference node) { |
| if (node.astIdentifier().astValue().equals(R_CLASS) && //$NON-NLS-1$ |
| node.getParent() instanceof Select && |
| node.getParent().getParent() instanceof Select) { |
| |
| // See if we're on the right hand side of an assignment |
| lombok.ast.Node current = node.getParent().getParent(); |
| String reference = ((Select) current).astIdentifier().astValue(); |
| |
| while (current != mTop && !(current instanceof VariableDefinitionEntry)) { |
| if (current == mTargetNode) { |
| mName = reference; |
| mDone = true; |
| return false; |
| } |
| current = current.getParent(); |
| } |
| if (current instanceof VariableDefinitionEntry) { |
| VariableDefinitionEntry entry = (VariableDefinitionEntry) current; |
| String variable = entry.astName().astValue(); |
| mMap.put(variable, reference); |
| } |
| } |
| |
| return false; |
| } |
| |
| @Nullable |
| private Expression getTargetArgument() { |
| Iterator<Expression> iterator; |
| if (mTargetNode instanceof MethodInvocation) { |
| iterator = ((MethodInvocation) mTargetNode).astArguments().iterator(); |
| } else if (mTargetNode instanceof ConstructorInvocation) { |
| iterator = ((ConstructorInvocation) mTargetNode).astArguments().iterator(); |
| } else { |
| return null; |
| } |
| int i = 0; |
| while (i < mArgIndex && iterator.hasNext()) { |
| iterator.next(); |
| i++; |
| } |
| if (iterator.hasNext()) { |
| Expression next = iterator.next(); |
| if (next != null && mContext != null && iterator.hasNext()) { |
| TypeDescriptor type = mContext.getType(next); |
| if (isLocaleReference(type)) { |
| next = iterator.next(); |
| } else if (type == null |
| && next.toString().startsWith("Locale.")) { //$NON-NLS-1$ |
| next = iterator.next(); |
| } |
| } |
| return next; |
| } |
| |
| return null; |
| } |
| |
| @Override |
| public boolean visitMethodInvocation(MethodInvocation node) { |
| if (node == mTargetNode) { |
| Expression arg = getTargetArgument(); |
| if (arg instanceof VariableReference) { |
| VariableReference reference = (VariableReference) arg; |
| String variable = reference.astIdentifier().astValue(); |
| mName = mMap.get(variable); |
| mDone = true; |
| return true; |
| } |
| } |
| |
| // Is this a getString() call? On a resource object? If so, |
| // promote the resource argument up to the left hand side |
| return super.visitMethodInvocation(node); |
| } |
| |
| @Override |
| public boolean visitConstructorInvocation(ConstructorInvocation node) { |
| if (node == mTargetNode) { |
| Expression arg = getTargetArgument(); |
| if (arg instanceof VariableReference) { |
| VariableReference reference = (VariableReference) arg; |
| String variable = reference.astIdentifier().astValue(); |
| mName = mMap.get(variable); |
| mDone = true; |
| return true; |
| } |
| } |
| |
| // Is this a getString() call? On a resource object? If so, |
| // promote the resource argument up to the left hand side |
| return super.visitConstructorInvocation(node); |
| } |
| |
| @Override |
| public boolean visitVariableDefinitionEntry(VariableDefinitionEntry node) { |
| String name = node.astName().astValue(); |
| Expression rhs = node.astInitializer(); |
| Class<?> type = getType(rhs); |
| if (type != null) { |
| mTypes.put(name, type); |
| } else { |
| // Make sure we're not visiting the String.format node itself. If you have |
| // msg = String.format("%1$s", msg) |
| // then we'd be wiping out the type of "msg" before visiting the |
| // String.format call! |
| if (rhs != mTargetNode) { |
| mTypes.remove(name); |
| } |
| } |
| |
| return super.visitVariableDefinitionEntry(node); |
| } |
| |
| private Class<?> getType(Expression expression) { |
| if (expression == null) { |
| return null; |
| } |
| |
| if (expression instanceof VariableReference) { |
| VariableReference reference = (VariableReference) expression; |
| String variable = reference.astIdentifier().astValue(); |
| Class<?> type = mTypes.get(variable); |
| if (type != null) { |
| return type; |
| } |
| } else if (expression instanceof MethodInvocation) { |
| MethodInvocation method = (MethodInvocation) expression; |
| String methodName = method.astName().astValue(); |
| if (methodName.equals(GET_STRING_METHOD)) { |
| return String.class; |
| } |
| } else if (expression instanceof StringLiteral) { |
| return String.class; |
| } else if (expression instanceof IntegralLiteral) { |
| return Integer.TYPE; |
| } else if (expression instanceof FloatingPointLiteral) { |
| return Float.TYPE; |
| } else if (expression instanceof CharLiteral) { |
| return Character.TYPE; |
| } else if (expression instanceof BooleanLiteral) { |
| return Boolean.TYPE; |
| } else if (expression instanceof NullLiteral) { |
| return Object.class; |
| } |
| |
| if (mContext != null) { |
| TypeDescriptor type = mContext.getType(expression); |
| if (type != null) { |
| Class<?> typeClass = getTypeClass(type); |
| if (typeClass != null) { |
| return typeClass; |
| } else { |
| return Object.class; |
| } |
| } |
| } |
| |
| return null; |
| } |
| } |
| } |