blob: d249ce0a6395250e99ee1088e6534f7d98588018 [file] [log] [blame]
package org.checkerframework.framework.ajava;
import com.github.javaparser.Position;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.ImportDeclaration;
import com.github.javaparser.ast.Node;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.nodeTypes.NodeWithAnnotations;
import com.github.javaparser.ast.type.ArrayType;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.type.Type;
import com.github.javaparser.printer.DefaultPrettyPrinter;
import com.github.javaparser.utils.Pair;
import com.sun.source.util.JavacTask;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileVisitResult;
import java.nio.file.FileVisitor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.util.Elements;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaCompiler.CompilationTask;
import javax.tools.JavaFileManager;
import javax.tools.JavaFileObject;
import javax.tools.ToolProvider;
import org.checkerframework.checker.signature.qual.DotSeparatedIdentifiers;
import org.checkerframework.checker.signature.qual.FullyQualifiedName;
import org.checkerframework.framework.stub.AnnotationFileParser;
import org.checkerframework.framework.util.JavaParserUtil;
import org.plumelib.util.FilesPlume;
/** This program inserts annotations from an ajava file into a Java file. See {@link #main}. */
public class InsertAjavaAnnotations {
/** Element utilities. */
private Elements elements;
/**
* Constructs an {@code InsertAjavaAnnotations} using the given {@code Elements} instance.
*
* @param elements an instance of {@code Elements}
*/
public InsertAjavaAnnotations(Elements elements) {
this.elements = elements;
}
/**
* Gets an instance of {@code Elements} from the current Java compiler.
*
* @return Element utilities
*/
private static Elements createElements() {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
if (compiler == null) {
System.err.println("Could not get compiler instance");
System.exit(1);
}
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<JavaFileObject>();
JavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null);
if (fileManager == null) {
System.err.println("Could not get file manager");
System.exit(1);
}
CompilationTask cTask =
compiler.getTask(
null, fileManager, diagnostics, Collections.emptyList(), null, Collections.emptyList());
if (!(cTask instanceof JavacTask)) {
System.err.println("Could not get a valid JavacTask: " + cTask.getClass());
System.exit(1);
}
return ((JavacTask) cTask).getElements();
}
/** Represents some text to be inserted at a file and its location. */
private static class Insertion {
/** Offset of the insertion in the file, measured in characters from the beginning. */
public int position;
/** The contents of the insertion. */
public String contents;
/** Whether the insertion should be on its own separate line. */
public boolean ownLine;
/**
* Constructs an insertion with the given position and contents.
*
* @param position offset of the insertion in the file
* @param contents contents of the insertion
*/
public Insertion(int position, String contents) {
this(position, contents, false);
}
/**
* Constructs an insertion with the given position and contents.
*
* @param position offset of the insertion in the file
* @param contents contents of the insertion
* @param ownLine true if this insertion should appear on its own separate line (doesn't affect
* the contents of the insertion)
*/
public Insertion(int position, String contents, boolean ownLine) {
this.position = position;
this.contents = contents;
this.ownLine = ownLine;
}
@Override
public String toString() {
return "Insertion [contents=" + contents + ", position=" + position + "]";
}
}
/**
* Given two JavaParser ASTs representing the same Java file but with differing annotations,
* stores a list of {@link Insertion}s. The {@link Insertion}s represent how to textually modify
* the file of the second AST to insert all annotations in the first AST into the second AST, but
* this class doesn't modify the second AST itself. To use this class, call {@link
* #visit(CompilationUnit, Node)} on a pair of ASTs and then use the contents of {@link
* #insertions}.
*/
private class BuildInsertionsVisitor extends DoubleJavaParserVisitor {
/**
* The set of annotations found in the file. Keys are both fully-qualified and simple names.
* Contains an entry for the fully qualified name of each annotation and, if it was imported,
* its simple name.
*
* <p>The map is populated from import statements and also when parsing a file that uses the
* fully qualified name of an annotation it doesn't import.
*/
private Map<String, TypeElement> allAnnotations;
/** The annotation insertions seen so far. */
public List<Insertion> insertions;
/** A printer for annotations. */
private DefaultPrettyPrinter printer;
/** The lines of the String representation of the second AST. */
private List<String> lines;
/** The line separator used in the text the second AST was parsed from */
private String lineSeparator;
/**
* Stores the offsets of the lines in the string representation of the second AST. At index i,
* stores the number of characters from the start of the file to the beginning of the ith line.
*/
private List<Integer> cumulativeLineSizes;
/**
* Constructs a {@code BuildInsertionsVisitor} where {@code destFileContents} is the String
* representation of the AST to insert annotation into, that uses the given line separator. When
* visiting a node pair, the second node must always be from an AST generated from this String.
*
* @param destFileContents the String the second vistide AST was parsed from
* @param lineSeparator the line separator that {@code destFileContents} uses
*/
public BuildInsertionsVisitor(String destFileContents, String lineSeparator) {
allAnnotations = null;
insertions = new ArrayList<>();
printer = new DefaultPrettyPrinter();
String[] lines = destFileContents.split(lineSeparator);
this.lines = Arrays.asList(lines);
this.lineSeparator = lineSeparator;
cumulativeLineSizes = new ArrayList<>();
cumulativeLineSizes.add(0);
for (int i = 1; i < lines.length; i++) {
int lastSize = cumulativeLineSizes.get(i - 1);
int lastLineLength = lines[i - 1].length() + lineSeparator.length();
cumulativeLineSizes.add(lastSize + lastLineLength);
}
}
@Override
public void defaultAction(Node src, Node dest) {
if (!(src instanceof NodeWithAnnotations<?>)) {
return;
}
NodeWithAnnotations<?> srcWithAnnos = (NodeWithAnnotations<?>) src;
// If `src` is a declaration, its annotations are declaration annotations.
if (src instanceof MethodDeclaration) {
addAnnotationOnOwnLine(dest.getBegin().get(), srcWithAnnos.getAnnotations());
return;
} else if (src instanceof FieldDeclaration) {
addAnnotationOnOwnLine(dest.getBegin().get(), srcWithAnnos.getAnnotations());
return;
}
// `src`'s annotations are type annotations.
Position position;
if (dest instanceof ClassOrInterfaceType) {
// In a multi-part name like my.package.MyClass, type annotations go directly in front of
// MyClass instead of the full name.
position = ((ClassOrInterfaceType) dest).getName().getBegin().get();
} else {
position = dest.getBegin().get();
}
addAnnotations(position, srcWithAnnos.getAnnotations(), 0, false);
}
@Override
public void visit(ArrayType src, Node other) {
ArrayType dest = (ArrayType) other;
// The second component of this pair contains a list of ArrayBracketPairs from left to
// right. For example, if src contains String[][], then the list will contain the
// types String[] and String[][]. To insert array annotations in the correct location,
// we insert them directly to the right of the end of the previous element.
Pair<Type, List<ArrayType.ArrayBracketPair>> srcArrayTypes = ArrayType.unwrapArrayTypes(src);
Pair<Type, List<ArrayType.ArrayBracketPair>> destArrayTypes =
ArrayType.unwrapArrayTypes(dest);
// The first annotations go directly after the element type.
Position firstPosition = destArrayTypes.a.getEnd().get();
addAnnotations(firstPosition, srcArrayTypes.b.get(0).getAnnotations(), 1, false);
for (int i = 1; i < srcArrayTypes.b.size(); i++) {
Position position = destArrayTypes.b.get(i - 1).getTokenRange().get().toRange().get().end;
addAnnotations(position, srcArrayTypes.b.get(i).getAnnotations(), 1, true);
}
// Visit the component type.
srcArrayTypes.a.accept(this, destArrayTypes.a);
}
@Override
public void visit(CompilationUnit src, Node other) {
CompilationUnit dest = (CompilationUnit) other;
defaultAction(src, dest);
// Gather annotations used in the ajava file.
allAnnotations = getImportedAnnotations(src);
// Move any annotations that JavaParser puts in the declaration position but belong only in
// the type position.
src.accept(new TypeAnnotationMover(allAnnotations, elements), null);
// Transfer import statements from the ajava file to the Java file.
List<String> newImports;
{ // set `newImports`
Set<String> existingImports = new HashSet<>();
for (ImportDeclaration importDecl : dest.getImports()) {
existingImports.add(printer.print(importDecl));
}
newImports = new ArrayList<>();
for (ImportDeclaration importDecl : src.getImports()) {
String importString = printer.print(importDecl);
if (!existingImports.contains(importString)) {
newImports.add(importString);
}
}
}
if (!newImports.isEmpty()) {
int position;
int lineBreaksBeforeFirstImport;
if (!dest.getImports().isEmpty()) {
Position lastImportPosition =
dest.getImports().get(dest.getImports().size() - 1).getEnd().get();
position = getFilePosition(lastImportPosition) + 1;
lineBreaksBeforeFirstImport = 1;
} else if (dest.getPackageDeclaration().isPresent()) {
Position packagePosition = dest.getPackageDeclaration().get().getEnd().get();
position = getFilePosition(packagePosition) + 1;
lineBreaksBeforeFirstImport = 2;
} else {
position = 0;
lineBreaksBeforeFirstImport = 0;
}
String insertionContent = "";
// In Java 11, use String::repeat.
for (int i = 0; i < lineBreaksBeforeFirstImport; i++) {
insertionContent += lineSeparator;
}
insertionContent += String.join("", newImports);
insertions.add(new Insertion(position, insertionContent));
}
src.getModule().ifPresent(l -> l.accept(this, dest.getModule().get()));
src.getPackageDeclaration()
.ifPresent(l -> l.accept(this, dest.getPackageDeclaration().get()));
for (int i = 0; i < src.getTypes().size(); i++) {
src.getTypes().get(i).accept(this, dest.getTypes().get(i));
}
}
/**
* Creates an insertion for a collection of annotations and adds it to {@link #insertions}. The
* annotations will appear on their own line (unless any non-whitespace characters precede the
* insertion position on its own line).
*
* @param position the position of the insertion
* @param annotations List of annotations to insert
*/
private void addAnnotationOnOwnLine(Position position, List<AnnotationExpr> annotations) {
String line = lines.get(position.line - 1);
int insertionColumn = position.column - 1;
boolean ownLine = true;
for (int i = 0; i < insertionColumn; i++) {
if (line.charAt(i) != ' ' && line.charAt(i) != '\t') {
ownLine = false;
break;
}
}
if (ownLine) {
StringJoiner insertionContent = new StringJoiner(" ");
for (AnnotationExpr annotation : annotations) {
insertionContent.add(printer.print(annotation));
}
if (insertionContent.length() == 0) {
return;
}
String leadingWhitespace = line.substring(0, insertionColumn);
int filePosition = getFilePosition(position);
insertions.add(
new Insertion(
filePosition,
insertionContent.toString() + lineSeparator + leadingWhitespace,
true));
} else {
addAnnotations(position, annotations, 0, false);
}
}
/**
* Creates an insertion for a collection of annotations at {@code position} + {@code offset} and
* adds it to {@link #insertions}.
*
* @param position the position of the insertion
* @param annotations List of annotations to insert
* @param offset additional offset of the insertion after {@code position}
* @param addSpaceBefore if true, the insertion content will start with a space
*/
private void addAnnotations(
Position position,
Iterable<AnnotationExpr> annotations,
int offset,
boolean addSpaceBefore) {
StringBuilder insertionContent = new StringBuilder();
for (AnnotationExpr annotation : annotations) {
insertionContent.append(printer.print(annotation));
insertionContent.append(" ");
}
// Can't test `annotations.isEmpty()` earlier because `annotations` has type `Iterable`.
if (insertionContent.length() == 0) {
return;
}
if (addSpaceBefore) {
insertionContent.insert(0, " ");
}
int filePosition = getFilePosition(position) + offset;
insertions.add(new Insertion(filePosition, insertionContent.toString()));
}
/**
* Converts a Position (which contains a line and column) to an offset from the start of the
* file, in characters.
*
* @param position a Position
* @return the total offset of the position from the start of the file
*/
private int getFilePosition(Position position) {
return cumulativeLineSizes.get(position.line - 1) + (position.column - 1);
}
}
/**
* Returns all annotations imported by the annotation file as a mapping from simple and qualified
* names to TypeElements.
*
* @param cu compilation unit to extract imports from
* @return a map from names to TypeElement, for all annotations imported by the annotation file.
* Two entries for each annotation: one for the simple name and another for the
* fully-qualified name, with the same value.
*/
private Map<String, TypeElement> getImportedAnnotations(CompilationUnit cu) {
if (cu.getImports() == null) {
return Collections.emptyMap();
}
Map<String, TypeElement> result = new HashMap<>();
for (ImportDeclaration importDecl : cu.getImports()) {
if (importDecl.isAsterisk()) {
@SuppressWarnings("signature" // https://tinyurl.com/cfissue/3094:
// com.github.javaparser.ast.expr.Name inherits toString,
// so there can be no annotation for it
)
@DotSeparatedIdentifiers String imported = importDecl.getName().toString();
if (importDecl.isStatic()) {
// Wildcard import of members of a type (class or interface)
TypeElement element = elements.getTypeElement(imported);
if (element != null) {
// Find nested annotations
result.putAll(AnnotationFileParser.annosInType(element));
}
} else {
// Wildcard import of members of a package
PackageElement element = elements.getPackageElement(imported);
if (element != null) {
result.putAll(AnnotationFileParser.annosInPackage(element));
}
}
} else {
@SuppressWarnings("signature" // importDecl is non-wildcard, so its name is
// @FullyQualifiedName
)
@FullyQualifiedName String imported = importDecl.getNameAsString();
TypeElement importType = elements.getTypeElement(imported);
if (importType != null && importType.getKind() == ElementKind.ANNOTATION_TYPE) {
TypeElement annoElt = elements.getTypeElement(imported);
if (annoElt != null) {
result.put(annoElt.getSimpleName().toString(), annoElt);
}
}
}
}
return result;
}
/**
* Inserts all annotations from the ajava file read from {@code annotationFile} into a Java file
* with contents {@code javaFileContents} that uses the given line separator and returns the
* resulting String.
*
* @param annotationFile input stream for an ajava file for {@code javaFileContents}
* @param javaFileContents contents of a Java file to insert annotations into
* @param lineSeparator the line separator {@code javaFileContents} uses
* @return a modified {@code javaFileContents} with annotations from {@code annotationFile}
* inserted
*/
public String insertAnnotations(
InputStream annotationFile, String javaFileContents, String lineSeparator) {
CompilationUnit annotationCu = JavaParserUtil.parseCompilationUnit(annotationFile);
CompilationUnit javaCu = JavaParserUtil.parseCompilationUnit(javaFileContents);
BuildInsertionsVisitor insertionVisitor =
new BuildInsertionsVisitor(javaFileContents, lineSeparator);
annotationCu.accept(insertionVisitor, javaCu);
List<Insertion> insertions = insertionVisitor.insertions;
insertions.sort(InsertAjavaAnnotations::compareInsertions);
StringBuilder result = new StringBuilder(javaFileContents);
for (Insertion insertion : insertions) {
result.insert(insertion.position, insertion.contents);
}
return result.toString();
}
/**
* Compares two insertions in the reverse order of where their content should appear in the file.
* Making an insertion changes the offset values of all content after the insertion, so performing
* the insertions in reverse order of appearance removes the need to recalculate the positions of
* other insertions.
*
* <p>The order in which insertions should appear is determined first by their absolute position
* in the file, and second by whether they have their own line. In a method like
* {@code @Pure @Tainting String myMethod()} both annotations should be inserted at the same
* location (right before "String"), but {@code @Pure} should always come first because it belongs
* on its own line.
*
* @param insertion1 the first insertion
* @param insertion2 the second insertion
* @return a negative integer, zero, or a positive integer if {@code insertion1} belongs before,
* at the same position, or after {@code insertion2} respectively in the above ordering
*/
private static int compareInsertions(Insertion insertion1, Insertion insertion2) {
int cmp = Integer.compare(insertion1.position, insertion2.position);
if (cmp == 0 && (insertion1.ownLine != insertion2.ownLine)) {
if (insertion1.ownLine) {
cmp = -1;
} else {
cmp = 1;
}
}
return -cmp;
}
/**
* Inserts all annotations from the ajava file at {@code annotationFilePath} into {@code
* javaFilePath}.
*
* @param annotationFilePath path to an ajava file
* @param javaFilePath path to a Java file to insert annotation into
*/
public void insertAnnotations(String annotationFilePath, String javaFilePath) {
try {
File javaFile = new File(javaFilePath);
String fileContents = FilesPlume.readFile(javaFile);
String lineSeparator = FilesPlume.inferLineSeparator(annotationFilePath);
FileInputStream annotationInputStream = new FileInputStream(annotationFilePath);
String result = insertAnnotations(annotationInputStream, fileContents, lineSeparator);
annotationInputStream.close();
FilesPlume.writeFile(javaFile, result);
} catch (IOException e) {
System.err.println(
"Failed to insert annotations from file "
+ annotationFilePath
+ " into file "
+ javaFilePath);
System.exit(1);
}
}
/**
* Inserts annotations from ajava files into Java files in place.
*
* <p>The first argument is an ajava file or a directory containing ajava files.
*
* <p>The second argument is a Java file or a directory containing Java files to insert
* annotations into.
*
* <p>For each Java file, checks if any ajava files from the first argument match it. For each
* such ajava file, inserts all its annotations into the Java file.
*
* @param args command line arguments: the first element should be a path to ajava files and the
* second should be the directory containing Java files to insert into
*/
public static void main(String[] args) {
if (args.length != 2) {
System.out.println(
"Usage: java InsertAjavaAnnotations <ajava-file-or-directory> <java-file-or-directory");
System.exit(1);
}
String ajavaDir = args[0];
String javaSourceDir = args[1];
AnnotationFileStore annotationFiles = new AnnotationFileStore();
annotationFiles.addFileOrDirectory(new File(ajavaDir));
InsertAjavaAnnotations inserter = new InsertAjavaAnnotations(createElements());
// For each Java file, this visitor inserts annotations into it.
FileVisitor<Path> insertionVisitor =
new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) {
if (!path.getFileName().toString().endsWith(".java")) {
return FileVisitResult.CONTINUE;
}
CompilationUnit root = null;
try {
root = JavaParserUtil.parseCompilationUnit(path.toFile());
} catch (IOException e) {
System.err.println("Failed to read file: " + path);
System.exit(1);
}
Set<String> annotationFilesForRoot = new LinkedHashSet<>();
for (TypeDeclaration<?> type : root.getTypes()) {
String name = JavaParserUtil.getFullyQualifiedName(type, root);
annotationFilesForRoot.addAll(annotationFiles.getAnnotationFileForType(name));
}
for (String annotationFile : annotationFilesForRoot) {
inserter.insertAnnotations(annotationFile, path.toString());
}
return FileVisitResult.CONTINUE;
}
};
try {
Files.walkFileTree(Paths.get(javaSourceDir), insertionVisitor);
} catch (IOException e) {
System.out.println("Error while adding annotations to: " + javaSourceDir);
e.printStackTrace();
System.exit(1);
}
}
}