All Downloads are FREE. Search and download functionalities are using the official Maven repository.

timber.lint.WrongTimberUsageDetector Maven / Gradle / Ivy

The newest version!
package timber.lint;

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.ClassContext;
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 com.android.tools.lint.detector.api.Speed;
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 org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;

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 final class WrongTimberUsageDetector extends Detector implements Detector.JavaScanner,
    Detector.ClassScanner {
  @NonNull @Override public Speed getSpeed() {
    return Speed.NORMAL;
  }

  @Override public List getApplicableCallNames() {
    return Arrays.asList("v", "d", "i", "w", "e", "wtf");
  }

  @Override public List getApplicableMethodNames() {
    return Arrays.asList("tag", "format", "v", "d", "i", "w", "e", "wtf");
  }

  @Override public void checkCall(@NonNull ClassContext context, @NonNull ClassNode classNode,
      @NonNull MethodNode method, @NonNull MethodInsnNode call) {
    String owner = call.owner;
    if (owner.startsWith("android/util/Log")) {
      context.report(ISSUE_LOG, method, call, context.getLocation(call),
          "Using 'Log' instead of 'Timber'");
    }
  }

  @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' inside of 'Timber'");
    } else if ("tag".equals(methodName)) {
      Node argument = node.astArguments().iterator().next();
      String tag = findLiteralValue(context, argument);
      if (tag.length() > 23) {
        String message = String.format(
            "The logging tag can be at most 23 characters, was %1$d (%2$s)",
            tag.length(), tag);
        context.report(ISSUE_TAG_LENGTH, node, context.getLocation(node), message);
      }
    } else {
      if (node.astOperand() instanceof VariableReference) {
        VariableReference ref = (VariableReference) node.astOperand();
        if (!"Timber".equals(ref.astIdentifier().astValue())) {
          return;
        }
        checkThrowablePosition(context, node);
        checkArguments(context, node);
      }
    }
  }

  private static void checkArguments(JavaContext context, MethodInvocation node) {
    StrictListAccessor astArguments = node.astArguments();
    Iterator iterator = astArguments.iterator();
    if (!iterator.hasNext()) {
      return;
    }
    int startIndexOfArguments = 1;
    Expression formatStringArg = iterator.next();
    if (formatStringArg instanceof VariableReference) {
      if (isSubclassOf(context, (VariableReference) formatStringArg, Throwable.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 types = getStringArgumentTypes(formatString);
    Expression argument = null;
    boolean valid;
    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) {
          case 'b':
          case 'B':
            valid = type == Boolean.TYPE;
            break;
          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':
            valid = type == Character.TYPE;
            break;
          case 'h':
          case 'H':
          case 's':
          case 'S':
            valid = type != Boolean.TYPE && !Number.class.isAssignableFrom(type);
            break;
          default:
            valid = true;
        }
        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 static 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 static boolean isSubclassOf(JavaContext context, VariableReference variableReference,
      Class clazz) {
    JavaParser.ResolvedNode resolved = context.resolve(variableReference);
    if (resolved instanceof JavaParser.ResolvedVariable) {
      JavaParser.ResolvedVariable resolvedVariable = (JavaParser.ResolvedVariable) resolved;
      JavaParser.ResolvedClass typeClass = resolvedVariable.getType().getTypeClass();
      return (typeClass != null && typeClass.isSubclassOf(clazz.getName(), false));
    }
    return false;
  }

  private static List getStringArgumentTypes(String formatString) {
    List types = new ArrayList();
    Matcher matcher = StringFormatDetector.FORMAT.matcher(formatString);
    int index = 0;
    int prevIndex = 0;
    while (true) {
      if (matcher.find(index)) {
        int matchStart = matcher.start();
        while (prevIndex < matchStart) {
          char c = formatString.charAt(prevIndex);
          if (c == '\\') {
            prevIndex++;
          }
          prevIndex++;
        }
        if (prevIndex > matchStart) {
          index = prevIndex;
          continue;
        }

        index = matcher.end();
        String str = formatString.substring(matchStart, matcher.end());
        if ("%%".equals(str) || "%n".equals(str)) {
          continue;
        }
        types.add(matcher.group(6));
      } 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 static 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)) {
          index = matcher.end();
          continue;
        }
        int matchStart = matcher.start();
        for (; prevIndex < matchStart; prevIndex++) {
          char c = s.charAt(prevIndex);
          if (c == '\\') {
            prevIndex++;
          }
        }
        if (prevIndex > matchStart) {
          index = prevIndex;
          continue;
        }

        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 static void checkThrowablePosition(JavaContext context, MethodInvocation node) {
    int index = 0;
    for (Node argument : node.astArguments()) {
      if (checkNode(context, node, argument)) {
        break;
      }
      if (argument instanceof VariableReference) {
        VariableReference variableReference = (VariableReference) argument;
        if (isSubclassOf(context, variableReference, Throwable.class) && index > 0) {
          context.report(ISSUE_THROWABLE, node, context.getLocation(node),
              "Throwable should be first argument");
        }
      }
      index++;
    }
  }

  private static boolean checkNode(JavaContext context, MethodInvocation node, Node argument) {
    if (argument instanceof BinaryExpression) {
      context.report(ISSUE_BINARY, node, context.getLocation(argument),
          "Replace String concatenation with Timber's string formatting");
      return true;
    } else if (argument instanceof If || argument instanceof InlineIfExpression) {
      return checkConditionalUsage(context, node, argument);
    }
    return false;
  }

  private static boolean checkConditionalUsage(JavaContext context, MethodInvocation node,
      Node arg) {
    Node thenStatement;
    Node elseStatement;
    if (arg instanceof If) {
      If ifArg = (If) arg;
      thenStatement = ifArg.astStatement();
      elseStatement = ifArg.astElseStatement();
    } else if (arg instanceof InlineIfExpression) {
      InlineIfExpression inlineIfArg = (InlineIfExpression) arg;
      thenStatement = inlineIfArg.astIfFalse();
      elseStatement = inlineIfArg.astIfTrue();
    } else {
      return false;
    }
    if (checkNode(context, node, thenStatement)) {
      return false;
    }
    return checkNode(context, node, elseStatement);
  }

  public static final Issue ISSUE_LOG =
      Issue.create("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.CLASS_FILE_SCOPE));
  public static final Issue ISSUE_FORMAT =
      Issue.create("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_THROWABLE =
      Issue.create("ThrowableNotAtBeginning", "Exception in Timber 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("BinaryOperationInTimber", "Use String#format()",
          "Since Timber handles String#format() automatically, use this instead of String"
              + " concatenation.", Category.MESSAGES, 5, Severity.WARNING,
          new Implementation(WrongTimberUsageDetector.class, Scope.JAVA_FILE_SCOPE));
  public static final Issue ISSUE_ARG_COUNT =
      Issue.create("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("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 static final Issue ISSUE_TAG_LENGTH =
      Issue.create("TimberTagLength", "Too Long Log Tags", "Log tags are only allowed to be at most"
              + " 23 tag characters long.", Category.CORRECTNESS, 5, Severity.ERROR,
          new Implementation(WrongTimberUsageDetector.class, Scope.JAVA_FILE_SCOPE));
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy