Skip to content

Commit 32febbd

Browse files
haewifulmsridhar
andauthored
Update jdk-javac-plugin to contain nested annotations (#1432)
This pull requests updates the jdk-javac-plugin to parse the nested annotations in external libraries and creates JSON files according to it. For the nested annotation information, it uses [`NestedAnnotationInfo`](https://github.com/uber/NullAway/blob/50ff5820bf6a645db68c4cdff5a00f647e27b5d1/nullaway/src/main/java/com/uber/nullaway/librarymodel/NestedAnnotationInfo.java#L14-L14) class. It is the first step from [#1410](#1410). The added unit test for this is `nestedAnnotationsForMethods()`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Detects and records nested JSpecify nullability annotations inside generics, arrays, and wildcard bounds for method return types and parameters. * **Serialization / Compatibility** * JSON output now includes nested annotation data; JSON parsing updated to correctly reconstruct Guava ImmutableList contents. * **Tests** * Added unit tests validating nested-annotation capture and end-to-end serialization for methods. * **Chores** * Build configuration updated to include required library dependency. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Manu Sridharan <msridhar@gmail.com>
1 parent 1ca1b25 commit 32febbd

6 files changed

Lines changed: 429 additions & 25 deletions

File tree

jdk-annotations/astubx-generator/src/main/java/com/uber/nullaway/jdkannotations/AstubxGenerator.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package com.uber.nullaway.jdkannotations;
22

3+
import com.google.common.collect.ImmutableList;
34
import com.google.common.collect.ImmutableMap;
45
import com.google.common.collect.ImmutableSet;
56
import com.google.gson.Gson;
7+
import com.google.gson.GsonBuilder;
8+
import com.google.gson.JsonDeserializer;
69
import com.google.gson.reflect.TypeToken;
710
import com.uber.nullaway.javacplugin.NullnessAnnotationSerializer.ClassInfo;
811
import com.uber.nullaway.javacplugin.NullnessAnnotationSerializer.MethodInfo;
@@ -13,6 +16,7 @@
1316
import java.io.File;
1417
import java.io.FileOutputStream;
1518
import java.io.IOException;
19+
import java.lang.reflect.ParameterizedType;
1620
import java.lang.reflect.Type;
1721
import java.nio.file.Files;
1822
import java.nio.file.Paths;
@@ -164,7 +168,35 @@ private static Map<String, List<ClassInfo>> parseJson(String jsonDirPath) {
164168
throw new IllegalStateException("No JSON files found in: " + jsonDirPath);
165169
}
166170

167-
Gson gson = new Gson();
171+
Gson gson =
172+
new GsonBuilder()
173+
.registerTypeAdapter(
174+
ImmutableList.class,
175+
(JsonDeserializer<ImmutableList<?>>)
176+
(json, type, context) -> {
177+
if (json.isJsonNull()) {
178+
return ImmutableList.of();
179+
}
180+
if (!(type instanceof ParameterizedType paramType)) {
181+
// Raw ImmutableList, deserialize as List<Object>
182+
List<?> list = context.deserialize(json, List.class);
183+
return list == null ? ImmutableList.of() : ImmutableList.copyOf(list);
184+
}
185+
// Get type inside the list
186+
Type[] typeArgs = paramType.getActualTypeArguments();
187+
Type innerType = typeArgs.length > 0 ? typeArgs[0] : Object.class;
188+
189+
// Get as ArrayList
190+
List<?> standardList =
191+
context.deserialize(
192+
json, TypeToken.getParameterized(List.class, innerType).getType());
193+
194+
// Convert to Guava ImmutableList
195+
return standardList == null
196+
? ImmutableList.of()
197+
: ImmutableList.copyOf(standardList);
198+
})
199+
.create();
168200
Type parsedType = new TypeToken<Map<String, List<ClassInfo>>>() {}.getType();
169201

170202
// parse JSON file

jdk-javac-plugin/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ dependencies {
5757

5858
implementation libs.gson
5959
implementation libs.jspecify
60+
implementation libs.guava
6061

6162
testImplementation libs.junit4
6263
testImplementation(libs.error.prone.test.helpers) {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package com.uber.nullaway.javacplugin;
2+
3+
import com.google.common.collect.ImmutableList;
4+
import com.sun.tools.javac.code.Type;
5+
import com.sun.tools.javac.code.Types;
6+
import com.uber.nullaway.javacplugin.NestedAnnotationInfo.Annotation;
7+
import com.uber.nullaway.javacplugin.NestedAnnotationInfo.TypePathEntry;
8+
import java.util.ArrayDeque;
9+
import java.util.HashSet;
10+
import java.util.List;
11+
import java.util.Set;
12+
import javax.lang.model.element.AnnotationMirror;
13+
import javax.lang.model.element.TypeElement;
14+
import javax.lang.model.type.TypeMirror;
15+
import org.jspecify.annotations.NullMarked;
16+
import org.jspecify.annotations.Nullable;
17+
18+
/**
19+
* Visitor that traverses a {@link Type} structure to discover and record nested JSpecify
20+
* annotations.
21+
*
22+
* <p>This visitor records annotations that occur on:
23+
*
24+
* <ul>
25+
* <li>Type arguments of parameterized types (e.g. {@code List<@Nullable String>})
26+
* <li>Array element types (e.g. {@code @Nullable String[]})
27+
* <li>Wildcard bounds (e.g. {@code ? extends @Nullable T}, {@code ? super @NonNull T})
28+
* </ul>
29+
*
30+
* <p>After the visitor has completed traversal, callers should invoke {@link
31+
* #getNestedAnnotationInfoSet()} to retrieve the set of collected {@link NestedAnnotationInfo}
32+
* instances.
33+
*/
34+
@NullMarked
35+
public class CreateNestedAnnotationInfoVisitor
36+
extends Types.DefaultTypeVisitor<@Nullable Void, @Nullable Void> {
37+
38+
private final ArrayDeque<TypePathEntry> path;
39+
private final Set<NestedAnnotationInfo> nestedAnnotationInfoSet;
40+
41+
private static final String NULLABLE_QNAME = "org.jspecify.annotations.Nullable";
42+
private static final String NONNULL_QNAME = "org.jspecify.annotations.NonNull";
43+
44+
public CreateNestedAnnotationInfoVisitor() {
45+
path = new ArrayDeque<>();
46+
nestedAnnotationInfoSet = new HashSet<>();
47+
}
48+
49+
@Override
50+
public @Nullable Void visitClassType(Type.ClassType classType, @Nullable Void unused) {
51+
// only processes type arguments
52+
List<Type> typeArguments = classType.getTypeArguments();
53+
if (!typeArguments.isEmpty()) {
54+
for (int idx = 0; idx < typeArguments.size(); idx++) {
55+
path.addLast(new TypePathEntry(TypePathEntry.Kind.TYPE_ARGUMENT, idx));
56+
Type typeArg = typeArguments.get(idx);
57+
addNestedAnnotationInfo(typeArg);
58+
typeArg.accept(this, null);
59+
path.removeLast();
60+
}
61+
}
62+
return null;
63+
}
64+
65+
@Override
66+
public @Nullable Void visitArrayType(Type.ArrayType arrayType, @Nullable Void unused) {
67+
path.addLast(new TypePathEntry(TypePathEntry.Kind.ARRAY_ELEMENT, -1));
68+
addNestedAnnotationInfo(arrayType.elemtype);
69+
arrayType.elemtype.accept(this, null);
70+
path.removeLast();
71+
return null;
72+
}
73+
74+
@Override
75+
public @Nullable Void visitWildcardType(Type.WildcardType wildcardTypet, @Nullable Void unused) {
76+
// Upper Bound (? extends T)
77+
if (wildcardTypet.getExtendsBound() != null) {
78+
path.addLast(new TypePathEntry(TypePathEntry.Kind.WILDCARD_BOUND, 0));
79+
Type upperBound = wildcardTypet.getExtendsBound();
80+
addNestedAnnotationInfo(upperBound);
81+
upperBound.accept(this, null);
82+
path.removeLast();
83+
}
84+
85+
// Lower Bound (? super T)
86+
if (wildcardTypet.getSuperBound() != null) {
87+
path.addLast(new TypePathEntry(TypePathEntry.Kind.WILDCARD_BOUND, 1));
88+
Type lowerBound = wildcardTypet.getSuperBound();
89+
addNestedAnnotationInfo(lowerBound);
90+
lowerBound.accept(this, null);
91+
path.removeLast();
92+
}
93+
94+
return null;
95+
}
96+
97+
@Override
98+
public @Nullable Void visitType(Type type, @Nullable Void unused) {
99+
return null;
100+
}
101+
102+
public Set<NestedAnnotationInfo> getNestedAnnotationInfoSet() {
103+
return nestedAnnotationInfoSet;
104+
}
105+
106+
private void addNestedAnnotationInfo(Type type) {
107+
if (hasNullableAnnotation(type)) {
108+
nestedAnnotationInfoSet.add(new NestedAnnotationInfo(Annotation.NULLABLE, getTypePath()));
109+
} else if (hasNonNullAnnotation(type)) {
110+
nestedAnnotationInfoSet.add(new NestedAnnotationInfo(Annotation.NONNULL, getTypePath()));
111+
}
112+
}
113+
114+
private static boolean hasAnnotation(TypeMirror type, String qname) {
115+
if (type == null) {
116+
return false;
117+
}
118+
for (AnnotationMirror annotation : type.getAnnotationMirrors()) {
119+
String qualifiedName =
120+
((TypeElement) annotation.getAnnotationType().asElement()).getQualifiedName().toString();
121+
if (qualifiedName.equals(qname)) {
122+
return true;
123+
}
124+
}
125+
return false;
126+
}
127+
128+
private boolean hasNullableAnnotation(TypeMirror type) {
129+
return hasAnnotation(type, NULLABLE_QNAME);
130+
}
131+
132+
private boolean hasNonNullAnnotation(TypeMirror type) {
133+
return hasAnnotation(type, NONNULL_QNAME);
134+
}
135+
136+
private ImmutableList<TypePathEntry> getTypePath() {
137+
return ImmutableList.copyOf(path);
138+
}
139+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.uber.nullaway.javacplugin;
2+
3+
import com.google.common.collect.ImmutableList;
4+
import org.jspecify.annotations.NullMarked;
5+
6+
/**
7+
* Class to hold information about a nested nullability annotation within a type, including the type
8+
* of nullability annotation and the type path to reach it.
9+
*
10+
* @param annotation the nullability annotation
11+
* @param typePath the type path to reach the annotation. If empty, the annotation applies to the
12+
* outermost type. Otherwise, each entry indicates one step in how to navigate to the nested
13+
* type.
14+
*/
15+
@NullMarked
16+
public record NestedAnnotationInfo(Annotation annotation, ImmutableList<TypePathEntry> typePath) {
17+
18+
/**
19+
* Class for a single entry in a type path, indicating how to navigate the "next step" in the type
20+
* to eventually reach some target type.
21+
*
22+
* @param kind the kind of this type path entry
23+
* @param index the index associated with the kind. For TYPE_ARGUMENT, this is the type argument
24+
* index. For WILDCARD_BOUND, this is 0 for the upper bound ({@code ? extends Foo}) and 1 for
25+
* the lower bound ({@code ? super Foo}). For ARRAY_ELEMENT, this is unused and set to -1.
26+
*/
27+
public record TypePathEntry(Kind kind, int index) {
28+
29+
/** Possible nested type kinds for an entry */
30+
public enum Kind {
31+
ARRAY_ELEMENT,
32+
TYPE_ARGUMENT,
33+
WILDCARD_BOUND
34+
}
35+
}
36+
37+
/** Possible annotations for nullability */
38+
public enum Annotation {
39+
NULLABLE,
40+
NONNULL
41+
}
42+
}

jdk-javac-plugin/src/main/java/com/uber/nullaway/javacplugin/NullnessAnnotationSerializer.java

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.util.List;
2626
import java.util.Map;
2727
import java.util.Objects;
28+
import java.util.Set;
2829
import java.util.UUID;
2930
import javax.lang.model.element.AnnotationMirror;
3031
import javax.lang.model.element.Modifier;
@@ -54,7 +55,8 @@ public record MethodInfo(
5455
String name,
5556
boolean nullMarked,
5657
boolean nullUnmarked,
57-
List<TypeParamInfo> typeParams) {}
58+
List<TypeParamInfo> typeParams,
59+
Map<Integer, Set<NestedAnnotationInfo>> nestedAnnotationsList) {}
5860

5961
public record ClassInfo(
6062
String name,
@@ -153,22 +155,35 @@ public void finished(com.sun.source.util.TaskEvent e) {
153155
return super.visitMethod(methodTree, null);
154156
}
155157
boolean methodHasAnnotations = false;
158+
Map<Integer, Set<NestedAnnotationInfo>> nestedAnnotationsMap = new HashMap<>();
156159
String returnType = "";
157160
if (methodTree.getReturnType() != null) {
158161
returnType += mSym.getReturnType().toString();
159162
if (hasJSpecifyAnnotationDeep(mSym.getReturnType())) {
160163
methodHasAnnotations = true;
164+
CreateNestedAnnotationInfoVisitor visitor =
165+
new CreateNestedAnnotationInfoVisitor();
166+
mSym.getReturnType().accept(visitor, null);
167+
Set<NestedAnnotationInfo> nested = visitor.getNestedAnnotationInfoSet();
168+
if (!nested.isEmpty()) {
169+
nestedAnnotationsMap.put(-1, nested);
170+
}
161171
}
162172
}
163173
boolean hasNullMarked = hasAnnotation(mSym, NULLMARKED_NAME);
164174
boolean hasNullUnmarked = hasAnnotation(mSym, NULLUNMARKED_NAME);
165175
methodHasAnnotations = methodHasAnnotations || hasNullMarked || hasNullUnmarked;
166176
// check each parameter annotations
167-
if (!methodHasAnnotations) {
168-
for (Symbol.VarSymbol vSym : mSym.getParameters()) {
169-
if (hasJSpecifyAnnotationDeep(vSym.asType())) {
170-
methodHasAnnotations = true;
171-
break;
177+
for (int idx = 0; idx < mSym.getParameters().size(); idx++) {
178+
Symbol.VarSymbol vSym = mSym.getParameters().get(idx);
179+
if (hasJSpecifyAnnotationDeep(vSym.asType())) {
180+
methodHasAnnotations = true;
181+
CreateNestedAnnotationInfoVisitor visitor =
182+
new CreateNestedAnnotationInfoVisitor();
183+
vSym.asType().accept(visitor, null);
184+
Set<NestedAnnotationInfo> nested = visitor.getNestedAnnotationInfoSet();
185+
if (!nested.isEmpty()) {
186+
nestedAnnotationsMap.put(idx, nested);
172187
}
173188
}
174189
}
@@ -185,7 +200,8 @@ public void finished(com.sun.source.util.TaskEvent e) {
185200
mSym.toString(),
186201
hasNullMarked,
187202
hasNullUnmarked,
188-
methodTypeParams);
203+
methodTypeParams,
204+
nestedAnnotationsMap);
189205
// only add this method if it uses JSpecify annotations
190206
if (currentClass != null && methodHasAnnotations) {
191207
currentClass.methods().add(methodInfo);

0 commit comments

Comments
 (0)