blob: 89354e9e043682c1349eaf05d88e0083f3d2779f [file] [log] [blame]
package org.checkerframework.framework.stub;
import com.github.javaparser.ParseResult;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.Position;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.Node;
import com.github.javaparser.ast.NodeList;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.ArrayInitializerExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.MarkerAnnotationExpr;
import com.github.javaparser.ast.expr.MemberValuePair;
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
import com.github.javaparser.ast.expr.StringLiteralExpr;
import com.github.javaparser.ast.nodeTypes.NodeWithAnnotations;
import com.github.javaparser.ast.visitor.GenericListVisitorAdapter;
import com.github.javaparser.utils.CollectionStrategy;
import com.github.javaparser.utils.ParserCollectionStrategy;
import com.github.javaparser.utils.PositionUtils;
import com.github.javaparser.utils.ProjectRoot;
import com.github.javaparser.utils.SourceRoot;
import com.google.common.base.CharMatcher;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;
import com.google.common.reflect.ClassPath;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import org.checkerframework.javacutil.BugInCF;
import org.plumelib.util.CollectionsPlume;
/**
* Process Java source files to remove annotations that ought to be inferred.
*
* <p>Removes annotations from all files in the given directories. Modifies the files in place.
*
* <p>Does not remove trusted annotations: those that the checker trusts rather than verifies.
*
* <p>Does not remove annotations at locations where inference does no work:
*
* <ul>
* <li>within the scope of a relevant @SuppressWarnings
* <li>within the scope of @IgnoreInWholeProgramInference or an annotation meta-annotated with
* that, such as @Option
* </ul>
*
* After removing annotations, javac may issue "warning: [cast] redundant cast to ..." if {@code
* -Alint:cast} (or {@code -Alint:all} which implies it) is passed to javac. You can suppress the
* warning by passing {@code -Alint:-cast} to javac.
*/
public class RemoveAnnotationsForInference {
/**
* Processes each provided command-line argument; see {@link RemoveAnnotationsForInference class
* documentation} for details.
*
* @param args command-line arguments: directories to process
*/
public static void main(String[] args) {
if (args.length < 1) {
System.err.println("Usage: provide one or more directory names to process");
System.exit(1);
}
for (String arg : args) {
process(arg);
}
}
/**
* Maps from simple names to fully-qualified names of annotations. (Actually, it includes every
* class on the classpath.)
*/
static Multimap<String, String> simpleToFullyQualified = ArrayListMultimap.create();
static {
try {
ClassPath cp = ClassPath.from(RemoveAnnotationsForInference.class.getClassLoader());
for (ClassPath.ClassInfo ci : cp.getTopLevelClasses()) {
// There is no way to determine whether `ci` represents an annotation, without loading it.
// I could filter using a heuristic: only include classes in a package named "qual".
simpleToFullyQualified.put(ci.getSimpleName(), ci.getName());
}
} catch (IOException e) {
throw new BugInCF(e);
}
}
/**
* Process each file in the given directory; see the {@link RemoveAnnotationsForInference class
* documentation} for details.
*
* @param dir directory to process
*/
private static void process(String dir) {
Path root = JavaStubifier.dirnameToPath(dir);
RemoveAnnotationsCallback rac = new RemoveAnnotationsCallback();
CollectionStrategy strategy = new ParserCollectionStrategy();
// Required to include directories that contain a module-info.java, which don't parse by
// default.
strategy.getParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_11);
ProjectRoot projectRoot = strategy.collect(root);
for (SourceRoot sourceRoot : projectRoot.getSourceRoots()) {
try {
sourceRoot.parse("", rac);
} catch (IOException e) {
throw new BugInCF(e);
}
}
}
/**
* Callback to process each Java file; see the {@link RemoveAnnotationsForInference class
* documentation} for details.
*/
private static class RemoveAnnotationsCallback implements SourceRoot.Callback {
/** The visitor instance. */
private final RemoveAnnotationsVisitor rav = new RemoveAnnotationsVisitor();
@Override
public Result process(Path localPath, Path absolutePath, ParseResult<CompilationUnit> result) {
Optional<CompilationUnit> opt = result.getResult();
if (opt.isPresent()) {
CompilationUnit cu = opt.get();
List<AnnotationExpr> removals = rav.visit(cu, null);
removeAnnotations(absolutePath, removals);
}
return Result.DONT_SAVE;
}
}
// An earlier implementation used ModifierVisitor. However, JavaParser's unparser can change
// the structure of the program. For example, it changes `protected @Nullable Object x;` to
// `@Nullable protected Object x;` which yields a type.anno.before.modifier error.
/**
* Rewrites the file in place, removing the given annotations from it.
*
* @param absolutePath the path to the file
* @param removals the annotations to remove
*/
static void removeAnnotations(Path absolutePath, List<AnnotationExpr> removals) {
if (removals.isEmpty()) {
return;
}
List<String> lines;
try {
lines = Files.readAllLines(absolutePath);
} catch (IOException e) {
System.out.printf("Problem reading %s: %s%n", absolutePath, e.getMessage());
System.exit(1);
throw new Error("unreachable");
}
PositionUtils.sortByBeginPosition(removals);
Collections.reverse(removals);
// This code (correctly) assumes that no element of `removals` is contained within another.
for (AnnotationExpr removal : removals) {
Position begin = removal.getBegin().get();
Position end = removal.getEnd().get();
int beginLine = begin.line - 1;
int beginColumn = begin.column - 1;
int endLine = end.line - 1;
int endColumn = end.column; // a JavaParser range is inclusive of the character at "end"
if (beginLine == endLine) {
String line = lines.get(beginLine);
String prefix = line.substring(0, beginColumn);
String suffix = line.substring(endColumn);
// Remove whitespace to beautify formatting.
suffix = CharMatcher.whitespace().trimLeadingFrom(suffix);
if (suffix.startsWith("[")) {
prefix = CharMatcher.whitespace().trimTrailingFrom(prefix);
}
String newLine = prefix + suffix;
replaceLine(lines, beginLine, newLine);
} else {
String newLastLine = lines.get(endLine).substring(0, endColumn);
replaceLine(lines, endLine, newLastLine);
for (int lineno = endLine - 1; lineno > beginLine; lineno--) {
lines.remove(lineno);
}
String newFirstLine = lines.get(beginLine).substring(0, beginColumn);
replaceLine(lines, beginLine, newFirstLine);
}
}
try {
PrintWriter pw = new PrintWriter(absolutePath.toString());
for (String line : lines) {
pw.println(line);
}
pw.close();
} catch (IOException e) {
throw new Error(e);
}
}
/**
* If {@code newLine} is blank, removes the given line. Otherwise replaces the given line.
*
* @param lines the list in which to do replacement or removal
* @param lineno the index of the line to be removed or replaced
* @param newLine the new line for index {@code lineno}
*/
static void replaceLine(List<String> lines, int lineno, String newLine) {
if (isBlank(newLine)) {
lines.remove(lineno);
} else {
lines.set(lineno, newLine);
}
}
// TODO: Put the following utility methods in StringsPlume.
/**
* Returns true if the string contains only white space codepoints, otherwise false.
*
* <p>In Java 11, use {@code String.isBlank()} instead.
*
* @param s a string
* @return true if the string contains only white space codepoints, otherwise false
*/
static boolean isBlank(String s) {
return s.chars().allMatch(Character::isWhitespace);
}
/**
* Visits one compilation unit, collecting the annotations that should be removed. See the {@link
* RemoveAnnotationsForInference class documentation} for more details.
*
* <p>The annotations will be removed from the source code by the {@link #removeAnnotations}
* method.
*/
private static class RemoveAnnotationsVisitor
extends GenericListVisitorAdapter<AnnotationExpr, Void> {
/**
* Returns the argument if it should be removed from source code.
*
* @param n an annotation
* @param superResult the result of processing the subcomponents of n
* @return the argument to remove it, or superResult to retain it
*/
List<AnnotationExpr> processAnnotation(AnnotationExpr n, List<AnnotationExpr> superResult) {
if (n == null) {
return superResult;
}
String name = n.getNameAsString();
// Retain annotations defined in the JDK.
if (isJdkAnnotation(name)) {
return superResult;
}
// Retain trusted annotations.
if (isTrustedAnnotation(name)) {
return superResult;
}
// Retain annotations for which warnings are suppressed.
if (isSuppressed(n)) {
return superResult;
}
// The default behavior is to remove the annotation.
// Don't include superResult, which is contained within `n`.
return Collections.singletonList(n);
}
// There are three JavaParser AST nodes that represent annotations
@Override
public List<AnnotationExpr> visit(final MarkerAnnotationExpr n, final Void arg) {
return processAnnotation(n, super.visit(n, arg));
}
@Override
public List<AnnotationExpr> visit(final NormalAnnotationExpr n, final Void arg) {
return processAnnotation(n, super.visit(n, arg));
}
@Override
public List<AnnotationExpr> visit(final SingleMemberAnnotationExpr n, final Void arg) {
return processAnnotation(n, super.visit(n, arg));
}
}
/**
* Returns true if the given annotation is defined in the JDK.
*
* @param name the annotation's name (simple or fully-qualified)
* @return true if the given annotation is defined in the JDK
*/
static boolean isJdkAnnotation(String name) {
return name.equals("Serial")
|| name.equals("java.io.Serial")
|| name.equals("Deprecated")
|| name.equals("java.lang.Deprecated")
|| name.equals("FunctionalInterface")
|| name.equals("java.lang.FunctionalInterface")
|| name.equals("Override")
|| name.equals("java.lang.Override")
|| name.equals("SafeVarargs")
|| name.equals("java.lang.SafeVarargs")
|| name.equals("Documented")
|| name.equals("java.lang.annotation.Documented")
|| name.equals("Inherited")
|| name.equals("java.lang.annotation.Inherited")
|| name.equals("Native")
|| name.equals("java.lang.annotation.Native")
|| name.equals("Repeatable")
|| name.equals("java.lang.annotation.Repeatable")
|| name.equals("Retention")
|| name.equals("java.lang.annotation.Retention")
|| name.equals("SuppressWarnings")
|| name.equals("java.lang.SuppressWarnings")
|| name.equals("Target")
|| name.equals("java.lang.annotation.Target");
}
/**
* Returns true if the given annotation is trusted, not checked/verified.
*
* @param name the annotation's name (simple or fully-qualified)
* @return true if the given annotation is trusted, not verified
*/
static boolean isTrustedAnnotation(String name) {
// This list was determined by grepping for "trusted" in `qual` directories.
return name.equals("Untainted")
|| name.equals("org.checkerframework.checker.tainting.qual.Untainted")
|| name.equals("InternedDistinct")
|| name.equals("org.checkerframework.checker.interning.qual.InternedDistinct")
|| name.equals("ReturnsReceiver")
|| name.equals("org.checkerframework.checker.builder.qual.ReturnsReceiver")
|| name.equals("TerminatesExecution")
|| name.equals("org.checkerframework.dataflow.qual.TerminatesExecution")
|| name.equals("Covariant")
|| name.equals("org.checkerframework.framework.qual.Covariant")
|| name.equals("NonLeaked")
|| name.equals("org.checkerframework.common.aliasing.qual.NonLeaked")
|| name.equals("LeakedToResult")
|| name.equals("org.checkerframework.common.aliasing.qual.LeakedToResult");
}
// This approach searches upward to find all the active warning suppressions.
// An alternative, more efficient approach would be to track the current set of warning
// suppressions, using a stack.
// There are two problems with the alternative approach (and besides, this approach is fast
// enough as it is).
// 1. JavaParser sometimes visits members before the annotation, so there was not a chance to
// observe the annotation and place it on the suppression stack. This should be fixed for
// ModifierVisitor (but not for other visitors such as GenericListVisitorAdapter) in
// JavaParser release 3.19.0.
// 2. A user might write an annotation before @SuppressWarnings, as in:
// @Interned @SuppressWarnings("interning")
// The {@code @Interned} annotation is visited before the {@code @SuppressWarnings}
// annotation is. This could be addressed by searching just the parent's annotations.
/**
* Returns true if warnings about the given annotation are suppressed.
*
* <p>Its heuristic is to look for a {@code @SuppressWarnings} annotation on a containing program
* element, whose string is one of the elements of the annotation's fully-qualified name.
*
* @param arg an annotation
* @return true if warnings about the given annotation are suppressed
*/
private static boolean isSuppressed(AnnotationExpr arg) {
String name = arg.getNameAsString();
// If it's a simple name for which we know a fully-qualified name,
// try all fully-qualified names that it could expand to.
Collection<String> names;
if (simpleToFullyQualified.containsKey(name)) {
names = simpleToFullyQualified.get(name);
} else {
names = Collections.singletonList(name);
}
Iterator<Node> itor = new Node.ParentsVisitor(arg);
while (itor.hasNext()) {
Node n = itor.next();
if (n instanceof NodeWithAnnotations) {
for (AnnotationExpr ae : ((NodeWithAnnotations<?>) n).getAnnotations()) {
if (suppresses(ae, names)) {
return true;
}
}
}
}
return false;
}
/**
* Returns true if {@code suppressor} suppresses warnings regarding {@code suppressees}.
*
* @param suppressor an annotation that might be {@code @SuppressWarnings} or like it
* @param suppressees an annotation for which warnings might be suppressed. This is actually a
* list: if the annotation was written unqualified, it contains all the fully-qualified names
* that the unqualified annotation might stand for.
* @return true if {@code suppressor} suppresses warnings regarding {@code suppressees}
*/
static boolean suppresses(AnnotationExpr suppressor, Collection<String> suppressees) {
List<String> suppressWarningsStrings = suppressWarningsStrings(suppressor);
if (suppressWarningsStrings == null) {
return false;
}
List<String> checkerNames =
CollectionsPlume.mapList(
RemoveAnnotationsForInference::checkerName, suppressWarningsStrings);
// "allcheckers" suppresses all warnings.
if (checkerNames.contains("allcheckers")) {
return true;
}
// Try every element of suppressee's fully-qualified name.
for (String suppressee : suppressees) {
for (String fqPart : suppressee.split("\\.")) {
if (checkerNames.contains(fqPart)) {
return true;
}
}
}
return false;
}
/**
* Given a @SuppressWarnings annotation, returns its strings. Given an annotation that suppresses
* warnings, returns strings for what it suppresses. Otherwise, returns null.
*
* @param n an annotation
* @return the (effective) arguments to {@code @SuppressWarnings}, or null
*/
private static List<String> suppressWarningsStrings(AnnotationExpr n) {
String name = n.getNameAsString();
if (name.equals("SuppressWarnings") || name.equals("java.lang.SuppressWarnings")) {
if (n instanceof MarkerAnnotationExpr) {
return Collections.emptyList();
} else if (n instanceof NormalAnnotationExpr) {
NodeList<MemberValuePair> pairs = ((NormalAnnotationExpr) n).getPairs();
assert pairs.size() == 1;
MemberValuePair pair = pairs.get(0);
assert pair.getName().asString().equals("value");
return annotationElementStrings(pair.getValue());
} else if (n instanceof SingleMemberAnnotationExpr) {
return annotationElementStrings(((SingleMemberAnnotationExpr) n).getMemberValue());
} else {
throw new BugInCF("Unexpected AnnotationExpr of type %s: %s", n.getClass(), n);
}
}
if (name.equals("IgnoreInWholeProgramInference")
|| name.equals("org.checkerframework.framework.qual.IgnoreInWholeProgramInference")
|| name.equals("Inject")
|| name.equals("javax.inject.Inject")
|| name.equals("Singleton")
|| name.equals("javax.inject.Singleton")
|| name.equals("Option")
|| name.equals("org.plumelib.options.Option")) {
return Collections.singletonList("allcheckers");
}
return null;
}
/**
* Given an annotation argument for an element of type String[], return a list of strings.
*
* @param e an annotation argument
* @return the strings expressed by {@code e}
*/
private static List<String> annotationElementStrings(Expression e) {
if (e instanceof StringLiteralExpr) {
return Collections.singletonList(((StringLiteralExpr) e).asString());
} else if (e instanceof ArrayInitializerExpr) {
NodeList<Expression> values = ((ArrayInitializerExpr) e).getValues();
List<String> result = new ArrayList<>(values.size());
for (Expression v : values) {
if (v instanceof StringLiteralExpr) {
result.add(((StringLiteralExpr) v).asString());
} else {
throw new BugInCF("Unexpected annotation element of type %s: %s", v.getClass(), v);
}
}
return result;
} else {
throw new BugInCF("Unexpected %s: %s", e.getClass(), e);
}
}
/**
* Returns the "checker name" part of a SuppressWarnings string: the part before the colon, or the
* whole thing if it contains no colon.
*
* @param s a SuppressWarnings string: the argument to {@code @SuppressWarnings}
* @return the part of s before the colon, or the whole thing if it contains no colon
*/
private static String checkerName(String s) {
int colonPos = s.indexOf(":");
if (colonPos == -1) {
return s;
} else {
return s.substring(colonPos + 1);
}
}
}