Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce DuplicateAnnotationAttributeListing #1070

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
package tech.picnic.errorprone.bugpatterns;

import static com.google.errorprone.BugPattern.LinkType.CUSTOM;
import static com.google.errorprone.BugPattern.SeverityLevel.SUGGESTION;
import static com.google.errorprone.BugPattern.StandardTags.STYLE;
import static java.util.stream.Collectors.joining;
import static javax.lang.model.element.ElementKind.CLASS;
import static javax.lang.model.element.ElementKind.INTERFACE;
import static tech.picnic.errorprone.utils.Documentation.BUG_PATTERNS_BASE_URL;

import com.google.auto.service.AutoService;
import com.google.common.collect.ImmutableSet;
import com.google.errorprone.BugPattern;
import com.google.errorprone.ErrorProneFlags;
import com.google.errorprone.VisitorState;
import com.google.errorprone.bugpatterns.BugChecker;
import com.google.errorprone.bugpatterns.BugChecker.AnnotationTreeMatcher;
import com.google.errorprone.fixes.Fix;
import com.google.errorprone.fixes.SuggestedFix;
import com.google.errorprone.matchers.Description;
import com.google.errorprone.util.ASTHelpers;
import com.sun.source.tree.AnnotationTree;
import com.sun.source.tree.AssignmentTree;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.IdentifierTree;
import com.sun.source.tree.LiteralTree;
import com.sun.source.tree.NewArrayTree;
import com.sun.source.tree.PrimitiveTypeTree;
import com.sun.source.tree.Tree;
import com.sun.source.util.TreeScanner;
import com.sun.tools.javac.code.Symbol;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import javax.inject.Inject;
import javax.tools.JavaFileObject.Kind;
import org.jspecify.annotations.Nullable;
import tech.picnic.errorprone.utils.AnnotationAttributeMatcher;
import tech.picnic.errorprone.utils.Flags;
import tech.picnic.errorprone.utils.SourceCode;

/**
* A {@link BugChecker} that flags and removes duplicate listings inside declared annotations.
*
* <p><b>Example:</b>
*
* <pre>
* {@code @JsonPropertyOrder({ "a", "b", "a" })}
* </pre>
*
* <p><b>Will be flagged and fixed to the following:</b>
*
* <pre>
* {@code @JsonPropertyOrder({ "a", "b"})}
* </pre>
*/
@AutoService(BugChecker.class)
@BugPattern(
summary = "Duplicate listings within an annotation are often a mistake.",
link = BUG_PATTERNS_BASE_URL + "DuplicateAnnotationAttributeListing",
linkType = CUSTOM,
severity = SUGGESTION,
tags = STYLE)
@SuppressWarnings("java:S2160" /* Super class equality definition suffices. */)
public final class DuplicateAnnotationAttributeListing extends BugChecker
implements AnnotationTreeMatcher {
private static final long serialVersionUID = 1L;
private static final String FLAG_PREFIX = "DuplicateAnnotationAttributeListing:";
private static final String INCLUDED_ANNOTATIONS_FLAG = FLAG_PREFIX + "Includes";
private static final String EXCLUDED_ANNOTATIONS_FLAG = FLAG_PREFIX + "Excludes";

private final AnnotationAttributeMatcher matcher;

/** Instantiates a default {@link DuplicateAnnotationAttributeListing} instance. */
public DuplicateAnnotationAttributeListing() {
this(ErrorProneFlags.empty());
}

/**
* Instantiates a customized {@link DuplicateAnnotationAttributeListing}.
*
* @param flags Any provided command line flags.
*/
@Inject
DuplicateAnnotationAttributeListing(ErrorProneFlags flags) {
matcher = createAnnotationAttributeMatcher(flags);
}

@Override
public Description matchAnnotation(AnnotationTree tree, VisitorState state) {
return removeDuplicateAnnotationEntries(tree, state)
.map(fix -> describeMatch(tree, fix))
.orElse(Description.NO_MATCH);
}

private Optional<Fix> removeDuplicateAnnotationEntries(AnnotationTree tree, VisitorState state) {
return matcher
.extractMatchingArguments(tree)
.map(expr -> extractArray(expr).flatMap(arr -> removeDuplicates(arr, tree, state)))
.flatMap(Optional::stream)
.reduce(SuggestedFix.Builder::merge)
.map(SuggestedFix.Builder::build);
}

private static Optional<NewArrayTree> extractArray(ExpressionTree expr) {
return expr instanceof AssignmentTree assignment
? extractArray(assignment.getExpression())
: Optional.of(expr).filter(NewArrayTree.class::isInstance).map(NewArrayTree.class::cast);
}

private static Optional<SuggestedFix.Builder> removeDuplicates(
NewArrayTree array, AnnotationTree tree, VisitorState state) {
if (array.getInitializers().size() < 2) {

Check warning on line 114 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/DuplicateAnnotationAttributeListing.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 114 without causing a test to fail

removed conditional - replaced comparison check with false (covered by 3 tests RemoveConditionalMutator_ORDER_ELSE)
/* There's only one element, no duplicates are expected. */
return Optional.empty();
}

List<? extends ExpressionTree> actualEntries = array.getInitializers();

ImmutableSet<Tree> nonDuplicateEntries = getNonDuplicateEntries(array, state);

if (actualEntries.size() == nonDuplicateEntries.size()) {
/* In the (presumably) common case that no duplicates are found. */
return Optional.empty();
}

String prefix = shouldOmitBrackets(tree, nonDuplicateEntries) ? "" : "{";
String suffix = prefix.isBlank() ? "" : "}";

String suggestion =
nonDuplicateEntries.stream()
.map(expr -> extractName(state, expr))
.collect(joining(", ", prefix, suffix));
return Optional.of(SuggestedFix.builder().replace(array, suggestion));
}

/**
* Extracts from the given array element expression a distinct set of {@link Tree nodes} omitting
* the duplicate entries.
*
* @implNote Annotations are a special case in which we want to make sure we get the distinct
* value of the full annotation expression instead of the annotation name. For example, we
* want to flag the following as a duplicate annotation entry:
* <pre>
* {@code @Foo(anns = { @Bar("a"), @Bar("a") })}
* </pre>
* but not:
* <pre>
* {@code @Foo(anns = { @Bar("a"), @Bar("b") })}
* </pre>
* To do this, we conditionally visit the identifiers, literals and primitive types of the
* original annotation but not those of the annotation entry being visited, given that the
* listings of each nested annotation will be the visited through the {@link
* AnnotationTreeMatcher}.
*/
private static ImmutableSet<Tree> getNonDuplicateEntries(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory, this method will flag duplicates of different types with the same name. In practice, that should not happen since listings are of the same type.

ExpressionTree array, VisitorState state) {
// Helper collections to identify when a node has been visited by its identifier, to avoid
// including duplicate nodes.
Set<String> visitedAnnotations = new HashSet<>();
Set<String> visitedIdentifiers = new HashSet<>();
Set<String> visitedLiterals = new HashSet<>();
Set<String> visitPrimitiveType = new HashSet<>();

ImmutableSet.Builder<Tree> nodes = ImmutableSet.builder();

new TreeScanner<@Nullable Void, Set<String>>() {

Check warning on line 168 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/DuplicateAnnotationAttributeListing.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 168 without causing a test to fail

swapped parameters 4 and 5 in call to <init> (covered by 3 tests ParamSwapMutator)
@Override
public @Nullable Void visitAnnotation(AnnotationTree node, Set<String> visitedAnnotations) {
String annotation = SourceCode.treeToString(node, state);
if (!visitedAnnotations.contains(annotation)) {
nodes.add(node);
}
visitedAnnotations.add(annotation);

return super.visitAnnotation(node, visitedAnnotations);

Check warning on line 177 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/DuplicateAnnotationAttributeListing.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 177 without causing a test to fail

replaced return value with null for visitAnnotation (covered by 2 tests NullReturnValsMutator)
}

@Override
public @Nullable Void visitIdentifier(IdentifierTree node, Set<String> visitedAnnotations) {
String identifier = node.getName().toString();

if (visitedAnnotations.isEmpty() && !visitedIdentifiers.contains(identifier)) {
nodes.add(node);
}
visitedIdentifiers.add(identifier);

return super.visitIdentifier(node, visitedAnnotations);

Check warning on line 189 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/DuplicateAnnotationAttributeListing.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 189 without causing a test to fail

replaced return value with null for visitIdentifier (covered by 2 tests NullReturnValsMutator)
}

@Override
public @Nullable Void visitLiteral(LiteralTree node, Set<String> visitedAnnotations) {
String literal = String.valueOf(ASTHelpers.constValue(node));

if (visitedAnnotations.isEmpty() && !visitedLiterals.contains(literal)) {
nodes.add(node);
}
visitedLiterals.add(literal);

return super.visitLiteral(node, visitedAnnotations);

Check warning on line 201 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/DuplicateAnnotationAttributeListing.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 201 without causing a test to fail

replaced return value with null for visitLiteral (covered by 3 tests NullReturnValsMutator)
}

@Override
public @Nullable Void visitPrimitiveType(
PrimitiveTypeTree node, Set<String> visitedAnnotations) {
String primitiveTypeKind = node.getPrimitiveTypeKind().toString();

if (visitedAnnotations.isEmpty() && !visitPrimitiveType.contains(primitiveTypeKind)) {
nodes.add(node);
}
visitPrimitiveType.add(primitiveTypeKind);

return super.visitPrimitiveType(node, visitedAnnotations);

Check warning on line 214 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/DuplicateAnnotationAttributeListing.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 214 without causing a test to fail

replaced return value with null for visitPrimitiveType (covered by 1 tests NullReturnValsMutator)
}
}.scan(array, visitedAnnotations);

return nodes.build();
}

// In the case of entries defined for the special attribute name value, if after removing the
// duplicates in a listing array, there's only one element left, then we can omit the brackets.
private static boolean shouldOmitBrackets(
AnnotationTree tree, ImmutableSet<Tree> nonDuplicateEntries) {
boolean hasValueKeyword =
tree.getArguments().stream()
.filter(arg -> arg.getKind() == Tree.Kind.ASSIGNMENT)

Check warning on line 227 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/DuplicateAnnotationAttributeListing.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 227 without causing a test to fail

removed call to filter (covered by 3 tests RemoveFilterMutator)
.map(AssignmentTree.class::cast)
.map(AssignmentTree::getVariable)
.map(IdentifierTree.class::cast)
.map(IdentifierTree::getName)
.anyMatch(name -> name.contentEquals("value"));
return nonDuplicateEntries.size() == 1 && hasValueKeyword;
}

private static String extractName(VisitorState state, Tree expr) {
String exprString = SourceCode.treeToString(expr, state);

Symbol symbol = ASTHelpers.getSymbol(expr);
if (symbol != null) {
return (symbol.getKind() == INTERFACE || symbol.getKind() == CLASS)

Check warning on line 241 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/DuplicateAnnotationAttributeListing.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 241 without causing a test to fail

removed conditional - replaced equality check with false (covered by 2 tests RemoveConditionalMutator_EQUAL_ELSE)
? exprString + Kind.CLASS.extension
: exprString;
}

return exprString;
}

private static AnnotationAttributeMatcher createAnnotationAttributeMatcher(
ErrorProneFlags flags) {
return AnnotationAttributeMatcher.create(
flags.get(INCLUDED_ANNOTATIONS_FLAG).isPresent()
? Optional.of(flags.getListOrEmpty(INCLUDED_ANNOTATIONS_FLAG))
: Optional.empty(),
Flags.getList(flags, EXCLUDED_ANNOTATIONS_FLAG));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@
*/
// XXX: Currently this checker only flags method-level annotations. It should likely also flag
// type-, field- and parameter-level annotations.
// XXX: Duplicate entries are often a mistake. Consider introducing a similar `BugChecker` that
// flags duplicates.
@AutoService(BugChecker.class)
@BugPattern(
summary = "Sort annotations lexicographically where possible",
Expand Down
Loading
Loading