Skip to content

Commit 10d4fee

Browse files
authored
Prototype implementation of javac plugin to serialize nullness annotations (#1225)
This is a new approach to serializing the annotations from https://github.com/jspecify/jdk for use with NullAway. The idea is we build that JDK with this plugin, which will dump out all the discovered annotation info into JSON files. Then, we write some code to take all those JSON files and convert them to astubx for NullAway. I went with JSON instead of directly serializing astubx to iterate more quickly. I have tested with jspecify/jdk and confirmed I can inject this plugin into the build and the output JSON files get created. I haven't started on the code to convert the JSON to astubx yet. Of course there is much left to do, but I wanted to get this initial implementation up for review rather than dropping a huge PR at the end (and also to enable others to help out). We previously thought about getting out the JDK annotations by parsing the source files (and wrote some code to do so), but I think this approach will be more robust, as in the javac plugin we don't need worry about parsing of recent language features and all symbols are just resolved. Related to #950 (which probably needs to be updated if we go forward with this approach)
1 parent 20dd152 commit 10d4fee

File tree

7 files changed

+470
-0
lines changed

7 files changed

+470
-0
lines changed

code-coverage-report/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,5 @@ dependencies {
8282
implementation project(':jdk-recent-unit-tests')
8383
implementation project(':library-model:library-model-generator')
8484
implementation project(':library-model:library-model-generator-integration-test')
85+
implementation project(':jdk-javac-plugin')
8586
}

gradle/dependencies.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def build = [
7676
errorProneTestHelpers : "com.google.errorprone:error_prone_test_helpers:${versions.errorProneApi}",
7777
errorProneTestHelpersOld: "com.google.errorprone:error_prone_test_helpers:${oldestErrorProneVersion}",
7878
checkerDataflow : "org.checkerframework:dataflow-nullaway:${versions.checkerFramework}",
79+
gson : "com.google.code.gson:gson:2.13.1",
7980
guava : "com.google.guava:guava:30.1-jre",
8081
javaparser : "com.github.javaparser:javaparser-core:${versions.javaparser}",
8182
javaparserSymbolSolver : "com.github.javaparser:javaparser-symbol-solver-core:${versions.javaparser}",

jdk-javac-plugin/build.gradle

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
plugins {
2+
id 'java'
3+
// we use the shadow jar when building the JDK
4+
id 'com.github.johnrengelman.shadow'
5+
id 'nullaway.java-test-conventions'
6+
}
7+
8+
// Use JDK 21 for this module, via a toolchain
9+
// We must null out sourceCompatibility and targetCompatibility to use toolchains.
10+
java.sourceCompatibility = null
11+
java.targetCompatibility = null
12+
java.toolchain.languageVersion.set JavaLanguageVersion.of(21)
13+
14+
tasks.withType(JavaCompile).configureEach {
15+
options.compilerArgs += [
16+
"--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
17+
"--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
18+
"--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
19+
"--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
20+
"--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
21+
"--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED",
22+
"--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
23+
"--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
24+
"--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
25+
"--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
26+
"--add-exports=jdk.compiler/com.sun.source.tree=ALL-UNNAMED",
27+
]
28+
}
29+
30+
configurations {
31+
// We use this configuration to expose a module path that can be
32+
// used to test handling of modules
33+
testModulePath
34+
}
35+
36+
dependencies {
37+
implementation deps.build.gson
38+
39+
testImplementation deps.test.junit4
40+
testImplementation(deps.build.errorProneTestHelpers) {
41+
exclude group: "junit", module: "junit"
42+
}
43+
testImplementation deps.build.jspecify
44+
testImplementation deps.build.gson
45+
testImplementation deps.test.assertJ
46+
47+
testModulePath deps.build.jspecify
48+
}
49+
50+
tasks.withType(Test).configureEach { test ->
51+
test.jvmArgs += [
52+
// Expose a module path for tests as a JVM property.
53+
"-Dtest.module.path=${configurations.testModulePath.asPath}"
54+
]
55+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package com.uber.nullaway.javacplugin;
2+
3+
import com.google.gson.Gson;
4+
import com.google.gson.GsonBuilder;
5+
import com.sun.source.tree.ClassTree;
6+
import com.sun.source.tree.CompilationUnitTree;
7+
import com.sun.source.tree.MethodTree;
8+
import com.sun.source.tree.TypeParameterTree;
9+
import com.sun.source.util.JavacTask;
10+
import com.sun.source.util.Plugin;
11+
import com.sun.source.util.TreePathScanner;
12+
import com.sun.source.util.Trees;
13+
import com.sun.tools.javac.code.Symbol.ClassSymbol;
14+
import com.sun.tools.javac.code.Symbol.MethodSymbol;
15+
import java.io.IOException;
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
18+
import java.nio.file.Paths;
19+
import java.util.ArrayDeque;
20+
import java.util.ArrayList;
21+
import java.util.Deque;
22+
import java.util.HashMap;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.UUID;
26+
import javax.lang.model.element.AnnotationMirror;
27+
import javax.lang.model.element.Modifier;
28+
import javax.lang.model.element.Name;
29+
import javax.lang.model.type.TypeMirror;
30+
31+
/**
32+
* A Javac plugin that serializes nullness annotations from Java source files into a JSON file.
33+
* Primarily intended for serializing annotations from the JSpecify JDK models.
34+
*/
35+
public class NullnessAnnotationSerializer implements Plugin {
36+
37+
private static final String NULLMARKED_NAME = "org.jspecify.annotations.NullMarked";
38+
private static final String NULLUNMARKED_NAME = "org.jspecify.annotations.NullUnmarked";
39+
40+
// Data classes for JSON output
41+
record TypeParamInfo(String name, List<String> bounds) {}
42+
43+
record MethodInfo(
44+
String name, boolean nullMarked, boolean nullUnmarked, List<TypeParamInfo> typeParams) {}
45+
46+
record ClassInfo(
47+
String name,
48+
String type,
49+
boolean nullMarked,
50+
boolean nullUnmarked,
51+
List<TypeParamInfo> typeParams,
52+
List<MethodInfo> methods) {}
53+
54+
/** Map from module name to information for classes in that module. */
55+
private final Map<String, List<ClassInfo>> moduleClasses = new HashMap<>();
56+
57+
@Override
58+
public String getName() {
59+
return "NullnessAnnotationSerializer";
60+
}
61+
62+
@Override
63+
public void init(JavacTask task, String... args) {
64+
String outputDir = args[0];
65+
Trees trees = Trees.instance(task);
66+
task.addTaskListener(
67+
new com.sun.source.util.TaskListener() {
68+
69+
@Override
70+
public void finished(com.sun.source.util.TaskEvent e) {
71+
if (e.getKind() == com.sun.source.util.TaskEvent.Kind.ANALYZE) {
72+
CompilationUnitTree cu = e.getCompilationUnit();
73+
new TreePathScanner<Void, Void>() {
74+
/** keep a stack of class contexts to handle nested classes */
75+
Deque<ClassInfo> classStack = new ArrayDeque<>();
76+
77+
ClassInfo currentClass = null;
78+
79+
@Override
80+
public Void visitClass(ClassTree classTree, Void unused) {
81+
Name simpleName = classTree.getSimpleName();
82+
if (simpleName.contentEquals("")) {
83+
return null; // skip anonymous
84+
}
85+
ClassSymbol classSym = (ClassSymbol) trees.getElement(getCurrentPath());
86+
@SuppressWarnings("ASTHelpersSuggestions")
87+
String moduleName =
88+
classSym.packge().getEnclosingElement().getQualifiedName().toString();
89+
if (moduleName.isEmpty()) { // unnamed module
90+
moduleName = "unnamed";
91+
}
92+
if (classSym.getModifiers().contains(Modifier.PRIVATE)) {
93+
return null; // skip private classes
94+
}
95+
TypeMirror classType = trees.getTypeMirror(getCurrentPath());
96+
boolean hasNullMarked = hasAnnotation(classSym, NULLMARKED_NAME);
97+
boolean hasNullUnmarked = hasAnnotation(classSym, NULLUNMARKED_NAME);
98+
if (currentClass != null) {
99+
// save current class context
100+
classStack.push(currentClass);
101+
}
102+
// build new class context
103+
List<TypeParamInfo> classTypeParams = new ArrayList<>();
104+
for (TypeParameterTree tp : classTree.getTypeParameters()) {
105+
classTypeParams.add(typeParamInfo(tp));
106+
}
107+
List<MethodInfo> classMethods = new ArrayList<>();
108+
currentClass =
109+
new ClassInfo(
110+
simpleName.toString(),
111+
classType.toString(),
112+
hasNullMarked,
113+
hasNullUnmarked,
114+
classTypeParams,
115+
classMethods);
116+
moduleClasses
117+
.computeIfAbsent(moduleName, k -> new ArrayList<>())
118+
.add(currentClass);
119+
super.visitClass(classTree, null);
120+
// restore previous class context
121+
currentClass = !classStack.isEmpty() ? classStack.pop() : null;
122+
return null;
123+
}
124+
125+
@Override
126+
public Void visitMethod(MethodTree methodTree, Void unused) {
127+
MethodSymbol mSym = (MethodSymbol) trees.getElement(getCurrentPath());
128+
if (mSym.getModifiers().contains(Modifier.PRIVATE)) {
129+
return super.visitMethod(methodTree, null);
130+
}
131+
boolean hasNullMarked = hasAnnotation(mSym, NULLMARKED_NAME);
132+
boolean hasNullUnmarked = hasAnnotation(mSym, NULLUNMARKED_NAME);
133+
List<TypeParamInfo> methodTypeParams = new ArrayList<>();
134+
for (TypeParameterTree tp : methodTree.getTypeParameters()) {
135+
methodTypeParams.add(typeParamInfo(tp));
136+
}
137+
MethodInfo methodInfo =
138+
new MethodInfo(
139+
mSym.toString(), hasNullMarked, hasNullUnmarked, methodTypeParams);
140+
if (currentClass != null) {
141+
currentClass.methods().add(methodInfo);
142+
}
143+
return super.visitMethod(methodTree, null);
144+
}
145+
146+
private TypeParamInfo typeParamInfo(TypeParameterTree tp) {
147+
String name = tp.getName().toString();
148+
List<String> bounds = new ArrayList<>();
149+
for (var b : tp.getBounds()) {
150+
bounds.add(b.toString());
151+
}
152+
return new TypeParamInfo(name, bounds);
153+
}
154+
155+
private boolean hasAnnotation(com.sun.tools.javac.code.Symbol sym, String fqn) {
156+
return sym.getAnnotationMirrors().stream()
157+
.map(AnnotationMirror::getAnnotationType)
158+
.map(Object::toString)
159+
.anyMatch(fqn::equals);
160+
}
161+
}.scan(cu, null);
162+
} else if (e.getKind() == com.sun.source.util.TaskEvent.Kind.COMPILATION) {
163+
Gson gson = new GsonBuilder().setPrettyPrinting().create();
164+
String jsonFileName = "classes-" + UUID.randomUUID() + ".json";
165+
Path p = Paths.get(outputDir, jsonFileName);
166+
try {
167+
Files.writeString(p, gson.toJson(moduleClasses));
168+
} catch (IOException ex) {
169+
throw new RuntimeException(ex);
170+
}
171+
}
172+
}
173+
});
174+
}
175+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
com.uber.nullaway.javacplugin.NullnessAnnotationSerializer

0 commit comments

Comments
 (0)