com.google.errorprone.bugpatterns.AbstractMockChecker Maven / Gradle / Ivy
/*
* Copyright 2019 The Error Prone Authors.
*
* 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.google.errorprone.bugpatterns;
import static com.google.common.collect.Iterables.getOnlyElement;
import static com.google.errorprone.matchers.Description.NO_MATCH;
import com.google.auto.value.AutoValue;
import com.google.common.base.Strings;
import com.google.common.base.Suppliers;
import com.google.common.collect.Lists;
import com.google.errorprone.VisitorState;
import com.google.errorprone.bugpatterns.BugChecker.MethodInvocationTreeMatcher;
import com.google.errorprone.bugpatterns.BugChecker.VariableTreeMatcher;
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.ExpressionTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.source.tree.Tree;
import com.sun.source.tree.VariableTree;
import com.sun.tools.javac.code.Attribute.Compound;
import com.sun.tools.javac.code.Symbol.CompletionFailure;
import com.sun.tools.javac.code.Symbol.TypeSymbol;
import com.sun.tools.javac.code.Type;
import java.lang.annotation.Annotation;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nullable;
/**
* Helper for enforcing Annotations that disallow mocking.
*
* @author [email protected] (Alan Malloy)
*/
public abstract class AbstractMockChecker extends BugChecker
implements MethodInvocationTreeMatcher, VariableTreeMatcher {
public AbstractMockChecker(
TypeExtractor varExtractor,
TypeExtractor methodExtractor,
Class annotationClass,
Function getValueFunction) {
this.varExtractor = varExtractor;
this.methodExtractor = methodExtractor;
this.annotationClass = annotationClass;
this.getValueFunction = getValueFunction;
this.annotationName = annotationClass.getSimpleName();
}
/**
* A policy for determining what classes should not be mocked.
*
* This interface's intended use is to forbid mocking of classes you don't control, for example
* those in the JDK itself or in a library you use.
*/
public interface MockForbidder {
/**
* If the given type should not be mocked, provide an explanation why.
*
* @param type the type that is being mocked
* @return the reason it should not be mocked
*/
Optional forbidReason(Type type, VisitorState state);
}
/** An explanation of what type should not be mocked, and the reason why. */
@AutoValue
public abstract static class Reason {
public static Reason of(Type t, String reason) {
return new AutoValue_AbstractMockChecker_Reason(t, reason);
}
/** A Type object representing the class that should not be mocked. */
public abstract Type unmockableClass();
/**
* The reason this class should not be mocked, which may be as simple as "it is annotated to
* forbid mocking" but may also provide a suggested workaround.
*/
public abstract String reason();
}
/**
* Produce a MockForbidder to use when looking for disallowed mocks, in addition to the built-in
* checks for Annotations of type {@code T}.
*
* This method will be called at most once for each instance of AbstractMockChecker, but of
* course the returned object's {@link MockForbidder#forbidReason(Type, VisitorState)
* forbidReason} method may be called many times.
*
*
The default implementation forbids nothing.
*/
protected MockForbidder forbidder() {
return (type, state) -> Optional.empty();
}
/**
* An extension of {@link Matcher} to return, not just a boolean `matches`, but also extract some
* type information about the Tree of interest.
*
*
This is used to identify what classes are being mocked in a Tree.
*
* @param the type of Tree that this TypeExtractor operates on
*/
public interface TypeExtractor {
/**
* Investigate the provided Tree, and return type information about it if it matches.
*
* @return the Type of the object being mocked, if any; Optional.empty() otherwise
*/
Optional extract(T tree, VisitorState state);
/**
* Enrich this TypeExtractor with fallback behavior.
*
* @return a TypeExtractor which first tries {@code this.extract(t, s)}, and if that does not
* match, falls back to {@code other.extract(t, s)}.
*/
default TypeExtractor or(TypeExtractor other) {
return (tree, state) ->
TypeExtractor.this
.extract(tree, state)
.map(Optional::of)
.orElseGet(() -> other.extract(tree, state));
}
}
/**
* Produces an extractor which, if the tree matches, extracts the type of that tree, as given by
* {@link ASTHelpers#getType(Tree)}.
*/
public static TypeExtractor extractType(Matcher m) {
return (tree, state) -> {
if (m.matches(tree, state)) {
return Optional.ofNullable(ASTHelpers.getType(tree));
}
return Optional.empty();
};
}
/**
* Produces an extractor which, if the tree matches, extracts the type of the first argument to
* the method invocation.
*/
public static TypeExtractor extractFirstArg(
Matcher m) {
return (tree, state) -> {
if (!m.matches(tree, state)) {
return Optional.empty();
}
if (tree.getArguments().size() >= 1) {
return Optional.ofNullable(ASTHelpers.getType(tree.getArguments().get(0)));
}
return Optional.ofNullable(ASTHelpers.targetType(state)).map(t -> t.type());
};
}
/**
* Produces an extractor which, if the tree matches, extracts the type of the first argument whose
* type is {@link Class} (preserving its {@code } type parameter, if it has one}.
*
* @param m the matcher to use. It is an error for this matcher to succeed on any Tree that does
* not include at least one argument of type {@link Class}; if such a matcher is provided, the
* behavior of the returned Extractor is undefined.
*/
public static TypeExtractor extractClassArg(
Matcher m) {
return (tree, state) -> {
if (m.matches(tree, state)) {
for (ExpressionTree argument : tree.getArguments()) {
Type argumentType = ASTHelpers.getType(argument);
if (ASTHelpers.isSameType(argumentType, state.getSymtab().classType, state)) {
return Optional.of(argumentType);
}
}
// It's undefined, so we could fall through - but an exception is less likely to surprise.
throw new IllegalStateException();
}
return Optional.empty();
};
}
/**
* Creates a TypeExtractor that extracts the type of a class field if that field is annotated with
* any one of the given annotations.
*/
public static TypeExtractor fieldAnnotatedWithOneOf(
Stream annotationClasses) {
return extractType(
Matchers.allOf(
Matchers.isField(),
Matchers.anyOf(
annotationClasses.map(Matchers::hasAnnotation).collect(Collectors.toList()))));
}
/**
* A TypeExtractor for method invocations that create a mock using Mockito.mock, Mockito.spy, or
* EasyMock.create[...]Mock, extracting the type being mocked.
*/
public static final TypeExtractor MOCKING_METHOD =
extractFirstArg(
Matchers.toType(
MethodInvocationTree.class,
Matchers.staticMethod().onClass("org.mockito.Mockito").namedAnyOf("mock", "spy")))
.or(
extractClassArg(
Matchers.toType(
MethodInvocationTree.class,
Matchers.staticMethod()
.onClass("org.easymock.EasyMock")
.withNameMatching(Pattern.compile("^create.*Mock(Builder)?$")))));
@Override
public final Description matchMethodInvocation(MethodInvocationTree tree, VisitorState state) {
return this.methodExtractor
.extract(tree, state)
.flatMap(type -> argFromClass(type, state))
.map(type -> checkMockedType(type, tree, state))
.orElse(NO_MATCH);
}
@Override
public final Description matchVariable(VariableTree tree, VisitorState state) {
return varExtractor
.extract(tree, state)
.map(type -> checkMockedType(type, tree, state))
.orElse(NO_MATCH);
}
private Description checkMockedType(Type mockedClass, Tree tree, VisitorState state) {
if (ASTHelpers.isSameType(Type.noType, mockedClass, state)) {
return NO_MATCH;
}
// We could extract this for loop to a "default" MockForbidder, but there's not much
// advantage in doing so.
for (Type currentType : Lists.reverse(state.getTypes().closure(mockedClass))) {
TypeSymbol currentSymbol = currentType.asElement();
T doNotMock = currentSymbol.getAnnotation(annotationClass);
if (doNotMock != null) {
return buildDescription(tree)
.setMessage(buildMessage(mockedClass, currentSymbol, doNotMock))
.build();
}
for (Compound compound : currentSymbol.getAnnotationMirrors()) {
TypeSymbol metaAnnotationType = (TypeSymbol) compound.getAnnotationType().asElement();
try {
metaAnnotationType.complete();
} catch (CompletionFailure e) {
// if the annotation isn't on the compilation classpath, we can't check it for
// the annotationClass meta-annotation
continue;
}
doNotMock = metaAnnotationType.getAnnotation(annotationClass);
if (doNotMock != null) {
return buildDescription(tree)
.setMessage(buildMessage(mockedClass, currentSymbol, metaAnnotationType, doNotMock))
.build();
}
}
}
return forbidder
.get()
.forbidReason(mockedClass, state)
.map(
reason ->
buildDescription(tree)
.setMessage(
buildMessage(mockedClass, reason.unmockableClass().tsym, reason.reason()))
.build())
.orElse(NO_MATCH);
}
/**
* If type is Class, returns the erasure of T. Otherwise, returns type unmodified. Returns
* empty() when provided a raw Class as argument.
*/
private static Optional argFromClass(Type type, VisitorState state) {
if (ASTHelpers.isSameType(type, state.getSymtab().classType, state)) {
if (type.getTypeArguments().isEmpty()) {
return Optional.empty();
}
return Optional.of(state.getTypes().erasure(getOnlyElement(type.getTypeArguments())));
}
return Optional.of(type);
}
private String buildMessage(Type mockedClass, TypeSymbol forbiddenType, T doNotMock) {
return buildMessage(mockedClass, forbiddenType, null, doNotMock);
}
private String buildMessage(
Type mockedClass,
TypeSymbol forbiddenType,
@Nullable TypeSymbol metaAnnotationType,
T doNotMock) {
return String.format(
"%s; %s is annotated as @%s%s: %s.",
buildMessage(mockedClass, forbiddenType),
forbiddenType,
metaAnnotationType == null ? annotationName : metaAnnotationType,
(metaAnnotationType == null
? ""
: String.format(" (which is annotated as @%s)", annotationName)),
Optional.ofNullable(Strings.emptyToNull(getValueFunction.apply(doNotMock)))
.orElseGet(() -> String.format("It is annotated as %s.", annotationName)));
}
private static String buildMessage(Type mockedClass, TypeSymbol forbiddenType) {
return String.format(
"Do not mock '%s'%s",
mockedClass,
(mockedClass.asElement().equals(forbiddenType)
? ""
: " (which is-a '" + forbiddenType + "')"));
}
private static String buildMessage(Type mockedClass, TypeSymbol forbiddenType, String reason) {
return String.format("%s: %s.", buildMessage(mockedClass, forbiddenType), reason);
}
private final TypeExtractor varExtractor;
private final TypeExtractor methodExtractor;
private final Class annotationClass;
private final String annotationName;
private final Function getValueFunction;
private final Supplier forbidder = Suppliers.memoize(this::forbidder);
}