/** * Wire * Copyright (C) 2016 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.waz.lintrules.issues; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.tools.lint.checks.StringFormatDetector; import com.android.tools.lint.client.api.JavaParser; import com.android.tools.lint.detector.api.Category; 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.Scope; import com.android.tools.lint.detector.api.Severity; import lombok.ast.AstVisitor; import lombok.ast.BinaryExpression; import lombok.ast.BinaryOperator; import lombok.ast.BooleanLiteral; import lombok.ast.CharLiteral; import lombok.ast.Expression; import lombok.ast.ExpressionStatement; import lombok.ast.FloatingPointLiteral; import lombok.ast.If; import lombok.ast.InlineIfExpression; import lombok.ast.IntegralLiteral; import lombok.ast.MethodInvocation; import lombok.ast.Node; import lombok.ast.NullLiteral; import lombok.ast.StrictListAccessor; import lombok.ast.StringLiteral; import lombok.ast.VariableReference; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.regex.Matcher; import static com.android.SdkConstants.GET_STRING_METHOD; 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; public class WrongTimberUsageDetector extends Detector implements Detector.JavaScanner { public static final Issue ISSUE_LOG = Issue.create("com.waz.LogNotTimber", "Logging call to Log instead of Timber", "Since Timber is included in the project, it is likely that calls to Log should " + "instead be going to Timber.", Category.MESSAGES, 5, Severity.WARNING, new Implementation(WrongTimberUsageDetector.class, Scope.JAVA_FILE_SCOPE)); public static final Issue ISSUE_FORMAT = Issue.create("com.waz.StringFormatInTimber", "Logging call with Timber contains String.format", "Since Timber handles String.format automatically, you may not use String.format()", Category.MESSAGES, 5, Severity.WARNING, new Implementation(WrongTimberUsageDetector.class, Scope.JAVA_FILE_SCOPE)); public static final Issue ISSUE_D_USAGE = Issue.create("com.waz.TimberDDetector", "Using `timber.log.Timber#d()` not allowed", "We decided to not use `timber.log.Timber#d()` in master. If you really want to keep this log, " + "replace it with another suitable `timber.log.Timber` method.", Category.MESSAGES, 5, Severity.WARNING, new Implementation(WrongTimberUsageDetector.class, Scope.JAVA_FILE_SCOPE)); public static final Issue ISSUE_THROWABLE = Issue.create("com.waz.ThrowableNotAtBeginning", "Exception in Timber#e() not at the beginning", "In Timber you have to pass a Throwable at the beginning of the call.", Category.MESSAGES, 5, Severity.WARNING, new Implementation(WrongTimberUsageDetector.class, Scope.JAVA_FILE_SCOPE)); public static final Issue ISSUE_BINARY = Issue.create("com.waz.BinaryOperationInTimber", "Use String#format()", "Since Timber handles String#format() automatically, use this instead of +", Category.MESSAGES, 5, Severity.WARNING, new Implementation(WrongTimberUsageDetector.class, Scope.JAVA_FILE_SCOPE)); public static final Issue ISSUE_ARG_COUNT = Issue.create("com.waz.TimberArgCount", "Formatting argument types incomplete or inconsistent", "When a formatted string takes arguments, you need to pass " + "at least that amount of arguments to the formatting call.", Category.MESSAGES, 9, Severity.ERROR, new Implementation(WrongTimberUsageDetector.class, Scope.JAVA_FILE_SCOPE)); public static final Issue ISSUE_ARG_TYPES = Issue.create("com.waz.TimberArgTypes", "Formatting string doesn't match passed arguments", "The argument types that you specified in your formatting " + "string does not match the types of the arguments that you " + "passed to your formatting call.", Category.MESSAGES, 9, Severity.ERROR, new Implementation(WrongTimberUsageDetector.class, Scope.JAVA_FILE_SCOPE)); public WrongTimberUsageDetector() { } @Override public List<String> getApplicableMethodNames() { return Arrays.asList("format", "v", "d", "i", "w", "e", "wtf"); } @Override public void visitMethod(@NonNull JavaContext context, AstVisitor visitor, @NonNull MethodInvocation node) { String methodName = node.astName().getDescription(); if ("format".equals(methodName)) { if (!(node.astOperand() instanceof VariableReference)) { return; } VariableReference ref = (VariableReference) node.astOperand(); if (!"String".equals(ref.astIdentifier().astValue())) { return; } // Found a String.format call // Look outside to see if we inside of a Timber call Node current = node.getParent(); while (current != null && !(current instanceof ExpressionStatement)) { current = current.getParent(); } if (current == null) { return; } ExpressionStatement statement = (ExpressionStatement) current; if (!statement.toString().startsWith("Timber.")) { return; } context.report(ISSUE_FORMAT, node, context.getLocation(node), "Using 'String.format' instead of 'Timber'"); } else { if (node.astOperand() instanceof VariableReference) { VariableReference ref = (VariableReference) node.astOperand(); if ("Log".equals(ref.astIdentifier().astValue())) { context.report(ISSUE_LOG, node, context.getLocation(node), "Using 'Log' instead of 'Timber'"); return; } String callName = node.astName().astValue(); if ("d".equals(callName)) { String message = String.format("Use another method from timber.log.Timber or remove %1$s()", callName); context.report(ISSUE_D_USAGE, node, context.getLocation(node), message); } checkThrowablePosition(context, node); checkArguments(context, node); } } } private void checkArguments(JavaContext context, MethodInvocation node) { StrictListAccessor<Expression, MethodInvocation> astArguments = node.astArguments(); Iterator<Expression> iterator = astArguments.iterator(); if (!iterator.hasNext()) { return; } int startIndexOfArguments = 1; Expression formatStringArg = iterator.next(); if (formatStringArg instanceof VariableReference) { if (isSubclassOf(context, (VariableReference) formatStringArg, Exception.class)) { formatStringArg = iterator.next(); startIndexOfArguments++; } } String formatString = findLiteralValue(context, formatStringArg); // We passed for example a method call if (formatString == null) { return; } int argumentCount = getFormatArgumentCount(formatString); int passedArgCount = astArguments.size() - startIndexOfArguments; if (argumentCount < passedArgCount) { context.report(ISSUE_ARG_COUNT, node, context.getLocation(node), String.format("Wrong argument count, format string `%1$s` requires `%2$d` but format " + "call supplies `%3$d`", formatString, argumentCount, passedArgCount)); return; } if (argumentCount == 0) { return; } List<String> types = getStringArgumentTypes(formatString); Expression argument = null; boolean valid = true; for (int i = 0; i < types.size(); i++) { String formatType = types.get(i); if (iterator.hasNext()) { argument = iterator.next(); } else { context.report(ISSUE_ARG_COUNT, node, context.getLocation(node), String.format("Wrong argument count, format string `%1$s` requires `%2$d` but format " + "call supplies `%3$d`", formatString, argumentCount, passedArgCount)); } char last = formatType.charAt(formatType.length() - 1); if (formatType.length() >= 2 && Character.toLowerCase(formatType.charAt(formatType.length() - 2)) == 't') { // Date time conversion. // TODO continue; } Class type = getType(context, argument); if (type != null) { 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) { 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, formatString, formatType, type.getSimpleName(), startIndexOfArguments + i + 1); context.report(ISSUE_ARG_TYPES, node, context.getLocation(argument), message); } } } } private Class<?> getType(JavaContext context, Expression expression) { if (expression == null) { return null; } 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 (context != null) { JavaParser.TypeDescriptor type = context.getType(expression); if (type != null) { Class<?> typeClass = getTypeClass(type); if (typeClass != null) { return typeClass; } else { return Object.class; } } } return null; } private static Class<?> getTypeClass(@Nullable JavaParser.TypeDescriptor type) { if (type != null) { return getTypeClass(type.getName()); } return null; } private static Class<?> getTypeClass(@Nullable String typeClassName) { if (typeClassName == null) { return null; } else if (typeClassName.equals(TYPE_STRING) || "String".equals(typeClassName)) { return String.class; } else if (typeClassName.equals(TYPE_INT)) { return Integer.TYPE; } else if (typeClassName.equals(TYPE_BOOLEAN)) { return Boolean.TYPE; } else if (typeClassName.equals(TYPE_NULL)) { return Object.class; } else if (typeClassName.equals(TYPE_LONG)) { return Long.TYPE; } else if (typeClassName.equals(TYPE_FLOAT)) { return Float.TYPE; } else if (typeClassName.equals(TYPE_DOUBLE)) { return Double.TYPE; } else if (typeClassName.equals(TYPE_CHAR)) { return Character.TYPE; } else if ("BigDecimal".equals(typeClassName) || "java.math.BigDecimal".equals(typeClassName)) { return Float.TYPE; } else if ("BigInteger".equals(typeClassName) || "java.math.BigInteger".equals(typeClassName)) { return Integer.TYPE; } else if (typeClassName.equals(TYPE_OBJECT)) { return null; } else if (typeClassName.startsWith("java.lang.")) { if ("java.lang.Integer".equals(typeClassName) || "java.lang.Short".equals(typeClassName) || "java.lang.Byte".equals(typeClassName) || "java.lang.Long".equals(typeClassName)) { return Integer.TYPE; } else if ("java.lang.Float".equals(typeClassName) || "java.lang.Double".equals(typeClassName)) { return Float.TYPE; } else { return null; } } else if (typeClassName.equals(TYPE_BYTE)) { return Byte.TYPE; } else if (typeClassName.equals(TYPE_SHORT)) { return Short.TYPE; } else { return null; } } private boolean isSubclassOf(JavaContext context, VariableReference variableReference, Class clazz) { JavaParser.ResolvedNode resolved = context.resolve(variableReference); if (resolved instanceof JavaParser.ResolvedVariable) { JavaParser.ResolvedClass typeClass = ((JavaParser.ResolvedVariable) resolved).getType().getTypeClass(); return (typeClass != null && typeClass.isSubclassOf(clazz.getName(), false)); } return false; } private List<String> getStringArgumentTypes(String formatString) { List<String> types = new ArrayList<>(); Matcher matcher = StringFormatDetector.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 ("%%".equals(str) || "%n".equals(str)) { // Just an escaped % 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) { numberString = numberString.substring(0, numberString.length() - 1); number = Integer.parseInt(numberString); nextNumber = number + 1; } else { number = nextNumber++; } String format = matcher.group(6); types.add(format); } else { break; } } return types; } private static String findLiteralValue(@NonNull JavaContext context, @NonNull Node argument) { if (argument instanceof StringLiteral) { return ((StringLiteral) argument).astValue(); } else if (argument instanceof BinaryExpression) { BinaryExpression expression = (BinaryExpression) argument; if (expression.astOperator() == BinaryOperator.PLUS) { String left = findLiteralValue(context, expression.astLeft()); String right = findLiteralValue(context, expression.astRight()); if (left != null && right != null) { return left + right; } } } else { JavaParser.ResolvedNode resolved = context.resolve(argument); if (resolved instanceof JavaParser.ResolvedField) { JavaParser.ResolvedField field = (JavaParser.ResolvedField) resolved; Object value = field.getValue(); if (value instanceof String) { return (String) value; } } } return null; } private int getFormatArgumentCount(@NonNull String s) { Matcher matcher = StringFormatDetector.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; } index = matcher.end(); } else { break; } } return max; } private void checkThrowablePosition(JavaContext context, MethodInvocation node) { int index = 0; for (Node argument : node.astArguments()) { if (checkNode(context, node, argument)) { break; } if (argument instanceof VariableReference) { if (isSubclassOf(context, (VariableReference) argument, Exception.class) && index > 0) { context.report(ISSUE_THROWABLE, node, context.getLocation(node), "Please use exception as first argument"); } } index++; } } private boolean checkNode(JavaContext context, MethodInvocation node, Node argument) { if (argument instanceof BinaryExpression) { context.report(ISSUE_BINARY, node, context.getLocation(argument), "Replace usage of + with String.format"); return true; } else if (argument instanceof If || argument instanceof InlineIfExpression) { return checkConditionalUsage(context, node, argument); } return false; } private boolean checkConditionalUsage(JavaContext context, MethodInvocation node, Node argument) { Node thenStatement; Node elseStatement; if (argument instanceof If) { thenStatement = ((If) argument).astStatement(); elseStatement = ((If) argument).astElseStatement(); } else if (argument instanceof InlineIfExpression) { thenStatement = ((InlineIfExpression) argument).astIfFalse(); elseStatement = ((InlineIfExpression) argument).astIfTrue(); } else { return false; } if (!checkNode(context, node, thenStatement)) { return checkNode(context, node, elseStatement); } return false; } }