SpyOnInterface.java

package de.schegge.errorprone;

import com.google.auto.service.AutoService;
import com.google.errorprone.BugPattern;
import com.google.errorprone.BugPattern.SeverityLevel;
import com.google.errorprone.VisitorState;
import com.google.errorprone.bugpatterns.AbstractMockChecker.TypeExtractor;
import com.google.errorprone.bugpatterns.BugChecker;
import com.google.errorprone.bugpatterns.BugChecker.VariableTreeMatcher;
import com.google.errorprone.fixes.SuggestedFix;
import com.google.errorprone.matchers.Description;
import com.google.errorprone.matchers.Matcher;
import com.google.errorprone.matchers.Matchers;
import com.google.errorprone.util.ASTHelpers;
import com.sun.source.tree.Tree;
import com.sun.source.tree.VariableTree;
import com.sun.tools.javac.code.Type;

import javax.lang.model.element.Name;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;

import static com.google.errorprone.matchers.Description.NO_MATCH;
import static com.google.errorprone.matchers.Matchers.*;
import static com.google.errorprone.matchers.Matchers.allOf;
import static java.util.stream.Collectors.toList;

@AutoService(BugChecker.class)
@BugPattern(name = "SpyOnInterface", summary = "A @Spy on an interface is only a @Mock", severity = SeverityLevel.WARNING)
public class SpyOnInterface extends BugChecker implements VariableTreeMatcher {
  private static final TypeExtractor<VariableTree> SPIED_VAR = fieldAnnotatedWithOneOf(Stream.of("org.mockito.Spy"));
  private static final String LINK_URL = "https://schegge.de/2021/08/mockito---der-spion-der-mich-hasste/";
  public static TypeExtractor<VariableTree> fieldAnnotatedWithOneOf(Stream<String> annotationClasses) {
    return extractType(allOf(isField(), anyOf(annotationClasses.map(Matchers::hasAnnotation).collect(toList()))));
  }
  public static <T extends Tree> TypeExtractor<T> extractType(Matcher<T> m) {
    return (tree, state) -> m.matches(tree, state) ? Optional.ofNullable(ASTHelpers.getType(tree)) : Optional.empty();
  }
  @Override
  public final Description matchVariable(VariableTree tree, VisitorState state) {
    return SPIED_VAR.extract(tree, state).map(type -> checkMockedType(type, tree, state)).orElse(NO_MATCH);
  }
  private Description checkMockedType(Type mockedClass, VariableTree tree, VisitorState state) {
    if (!mockedClass.isInterface() || tree.getInitializer() != null) {
      return NO_MATCH;
    }
    Description.Builder description = buildDescription(tree).setLinkUrl(LINK_URL);
    String modifiersWithAnnotations = Objects.requireNonNullElse(state.getSourceForNode(tree.getModifiers()), "");
    String modifiersWithoutSpy = modifiersWithAnnotations.replace("@org.mockito.Spy", "").replace("@Spy", "");
    String type = state.getSourceForNode(tree.getType());
    Name variable = tree.getName();
    description.addFix(SuggestedFix.replace(tree, String.format("@Mock %s %s %s;", modifiersWithoutSpy, type, variable)));
    return description.build();
  }
}