Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ad3a0f3
to use NestedAnnotationInfo and Immutable classes
haewiful Jan 8, 2026
4358508
add nested annotation fields to JSON file using NestedAnnotationInfo …
haewiful Jan 8, 2026
d66e0e4
Merge branch 'uber:master' into nested-annotations
haewiful Jan 8, 2026
c2fb5ff
minor changes
haewiful Jan 8, 2026
4c9211b
add type adaptor for ImmutableList in AstubxGenerator
haewiful Jan 9, 2026
582aade
change comments
haewiful Jan 9, 2026
e7e19be
clean up
haewiful Jan 9, 2026
b21c4d2
make field private
haewiful Jan 9, 2026
0d0f0de
for consistency
haewiful Jan 9, 2026
4958718
coderabbit suggestions
haewiful Jan 9, 2026
d83bc26
copy NestedAnnotationInfo into project
haewiful Jan 11, 2026
981ec0c
Merge branch 'master' into nested-annotations
msridhar Jan 14, 2026
d5cc811
suggested simplification in CreateNestedAnnotationInfoVisitor
haewiful Jan 14, 2026
8606f18
code review comments
haewiful Jan 14, 2026
1ae87d2
modify array element type test
haewiful Jan 14, 2026
42b3f41
change return type and add getter method
haewiful Jan 14, 2026
e8b97f0
javadoc
haewiful Jan 14, 2026
997a76b
Merge branch 'master' into nested-annotations
msridhar Jan 15, 2026
000f06c
Update jdk-javac-plugin/src/main/java/com/uber/nullaway/javacplugin/C…
haewiful Jan 15, 2026
e879332
Update jdk-javac-plugin/src/main/java/com/uber/nullaway/javacplugin/C…
haewiful Jan 15, 2026
ad29bce
Update jdk-javac-plugin/src/main/java/com/uber/nullaway/javacplugin/N…
haewiful Jan 15, 2026
06c4233
Update jdk-javac-plugin/src/main/java/com/uber/nullaway/javacplugin/N…
haewiful Jan 15, 2026
e49e75c
new test case
haewiful Jan 15, 2026
c558007
Merge branch 'master' into nested-annotations
msridhar Jan 15, 2026
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
@@ -1,8 +1,11 @@
package com.uber.nullaway.jdkannotations;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonDeserializer;
import com.google.gson.reflect.TypeToken;
import com.uber.nullaway.javacplugin.NullnessAnnotationSerializer.ClassInfo;
import com.uber.nullaway.javacplugin.NullnessAnnotationSerializer.MethodInfo;
Expand All @@ -13,6 +16,7 @@
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Paths;
Expand Down Expand Up @@ -164,7 +168,35 @@ private static Map<String, List<ClassInfo>> parseJson(String jsonDirPath) {
throw new IllegalStateException("No JSON files found in: " + jsonDirPath);
}

Gson gson = new Gson();
Gson gson =
new GsonBuilder()
.registerTypeAdapter(
ImmutableList.class,
(JsonDeserializer<ImmutableList<?>>)
(json, type, context) -> {
if (json.isJsonNull()) {
return ImmutableList.of();
}
if (!(type instanceof ParameterizedType paramType)) {
// Raw ImmutableList, deserialize as List<Object>
List<?> list = context.deserialize(json, List.class);
return list == null ? ImmutableList.of() : ImmutableList.copyOf(list);
}
// Get type inside the list
Type[] typeArgs = paramType.getActualTypeArguments();
Type innerType = typeArgs.length > 0 ? typeArgs[0] : Object.class;

// Get as ArrayList
List<?> standardList =
context.deserialize(
json, TypeToken.getParameterized(List.class, innerType).getType());

// Convert to Guava ImmutableList
return standardList == null
? ImmutableList.of()
: ImmutableList.copyOf(standardList);
})
.create();
Type parsedType = new TypeToken<Map<String, List<ClassInfo>>>() {}.getType();

// parse JSON file
Expand Down
1 change: 1 addition & 0 deletions jdk-javac-plugin/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ dependencies {

implementation libs.gson
implementation libs.jspecify
implementation libs.guava

testImplementation libs.junit4
testImplementation(libs.error.prone.test.helpers) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.uber.nullaway.javacplugin;

import com.google.common.collect.ImmutableList;
import com.sun.tools.javac.code.Type;
import com.sun.tools.javac.code.Types;
import com.uber.nullaway.javacplugin.NestedAnnotationInfo.Annotation;
import com.uber.nullaway.javacplugin.NestedAnnotationInfo.TypePathEntry;
import java.util.ArrayDeque;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.TypeElement;
import javax.lang.model.type.TypeMirror;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

/**
* Visitor that traverses a {@link Type} structure to discover and record nested JSpecify
* annotations.
*
* <p>This visitor records annotations that occur on:
*
* <ul>
* <li>Type arguments of parameterized types (e.g. {@code List<@Nullable String>})
* <li>Array element types (e.g. {@code @Nullable String[]})
* <li>Wildcard bounds (e.g. {@code ? extends @Nullable T}, {@code ? super @NonNull T})
* </ul>
*
* <p>After the visitor has completed traversal, callers should invoke {@link
* #getNestedAnnotationInfoSet()} to retrieve the set of collected {@link NestedAnnotationInfo}
* instances.
*/
@NullMarked
public class CreateNestedAnnotationInfoVisitor
extends Types.DefaultTypeVisitor<@Nullable Void, @Nullable Void> {

private final ArrayDeque<TypePathEntry> path;
private final Set<NestedAnnotationInfo> nestedAnnotationInfoSet;

private static final String NULLABLE_QNAME = "org.jspecify.annotations.Nullable";
private static final String NONNULL_QNAME = "org.jspecify.annotations.NonNull";

public CreateNestedAnnotationInfoVisitor() {
path = new ArrayDeque<>();
nestedAnnotationInfoSet = new HashSet<>();
}

@Override
public @Nullable Void visitClassType(Type.ClassType classType, @Nullable Void unused) {
// only processes type arguments
List<Type> typeArguments = classType.getTypeArguments();
if (!typeArguments.isEmpty()) {
for (int idx = 0; idx < typeArguments.size(); idx++) {
path.addLast(new TypePathEntry(TypePathEntry.Kind.TYPE_ARGUMENT, idx));
Type typeArg = typeArguments.get(idx);
addNestedAnnotationInfo(typeArg);
typeArg.accept(this, null);
path.removeLast();
}
}
return null;
}

@Override
public @Nullable Void visitArrayType(Type.ArrayType arrayType, @Nullable Void unused) {
path.addLast(new TypePathEntry(TypePathEntry.Kind.ARRAY_ELEMENT, -1));
addNestedAnnotationInfo(arrayType.elemtype);
arrayType.elemtype.accept(this, null);
path.removeLast();
return null;
}

@Override
public @Nullable Void visitWildcardType(Type.WildcardType wildcardTypet, @Nullable Void unused) {
// Upper Bound (? extends T)
if (wildcardTypet.getExtendsBound() != null) {
path.addLast(new TypePathEntry(TypePathEntry.Kind.WILDCARD_BOUND, 0));
Type upperBound = wildcardTypet.getExtendsBound();
addNestedAnnotationInfo(upperBound);
upperBound.accept(this, null);
path.removeLast();
}

// Lower Bound (? super T)
if (wildcardTypet.getSuperBound() != null) {
path.addLast(new TypePathEntry(TypePathEntry.Kind.WILDCARD_BOUND, 1));
Type lowerBound = wildcardTypet.getSuperBound();
addNestedAnnotationInfo(lowerBound);
lowerBound.accept(this, null);
path.removeLast();
}

return null;
}

@Override
public @Nullable Void visitType(Type type, @Nullable Void unused) {
return null;
}

public Set<NestedAnnotationInfo> getNestedAnnotationInfoSet() {
return nestedAnnotationInfoSet;
}
Comment on lines +102 to +104
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider returning an unmodifiable view of the set.

getNestedAnnotationInfoSet() returns the internal mutable HashSet, allowing callers to modify the visitor's state. Consider returning Collections.unmodifiableSet(nestedAnnotationInfoSet) or Set.copyOf(nestedAnnotationInfoSet).

🤖 Prompt for AI Agents
In
`@jdk-javac-plugin/src/main/java/com/uber/nullaway/javacplugin/CreateNestedAnnotationInfoVisitor.java`
around lines 86 - 88, getNestedAnnotationInfoSet currently exposes the internal
mutable HashSet allowing external mutation; change the getter in
CreateNestedAnnotationInfoVisitor to return an unmodifiable view (e.g.,
Collections.unmodifiableSet(nestedAnnotationInfoSet) or
Set.copyOf(nestedAnnotationInfoSet)) so callers cannot modify the visitor's
internal state, keeping the field type and name (nestedAnnotationInfoSet,
getNestedAnnotationInfoSet) unchanged.


private void addNestedAnnotationInfo(Type type) {
if (hasNullableAnnotation(type)) {
nestedAnnotationInfoSet.add(new NestedAnnotationInfo(Annotation.NULLABLE, getTypePath()));
} else if (hasNonNullAnnotation(type)) {
nestedAnnotationInfoSet.add(new NestedAnnotationInfo(Annotation.NONNULL, getTypePath()));
}
}

private static boolean hasAnnotation(TypeMirror type, String qname) {
if (type == null) {
return false;
}
for (AnnotationMirror annotation : type.getAnnotationMirrors()) {
String qualifiedName =
((TypeElement) annotation.getAnnotationType().asElement()).getQualifiedName().toString();
if (qualifiedName.equals(qname)) {
return true;
}
}
return false;
}

private boolean hasNullableAnnotation(TypeMirror type) {
return hasAnnotation(type, NULLABLE_QNAME);
}

private boolean hasNonNullAnnotation(TypeMirror type) {
return hasAnnotation(type, NONNULL_QNAME);
}

private ImmutableList<TypePathEntry> getTypePath() {
return ImmutableList.copyOf(path);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.uber.nullaway.javacplugin;

import com.google.common.collect.ImmutableList;
import org.jspecify.annotations.NullMarked;

/**
* Class to hold information about a nested nullability annotation within a type, including the type
* of nullability annotation and the type path to reach it.
*
* @param annotation the nullability annotation
* @param typePath the type path to reach the annotation. If empty, the annotation applies to the
* outermost type. Otherwise, each entry indicates one step in how to navigate to the nested
* type.
*/
@NullMarked
public record NestedAnnotationInfo(Annotation annotation, ImmutableList<TypePathEntry> typePath) {

/**
* Class for a single entry in a type path, indicating how to navigate the "next step" in the type
* to eventually reach some target type.
*
* @param kind the kind of this type path entry
* @param index the index associated with the kind. For TYPE_ARGUMENT, this is the type argument
* index. For WILDCARD_BOUND, this is 0 for the upper bound ({@code ? extends Foo}) and 1 for
* the lower bound ({@code ? super Foo}). For ARRAY_ELEMENT, this is unused and set to -1.
*/
public record TypePathEntry(Kind kind, int index) {

/** Possible nested type kinds for an entry */
public enum Kind {
ARRAY_ELEMENT,
TYPE_ARGUMENT,
WILDCARD_BOUND
}
}

/** Possible annotations for nullability */
public enum Annotation {
NULLABLE,
NONNULL
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.Modifier;
Expand Down Expand Up @@ -54,7 +55,8 @@ public record MethodInfo(
String name,
boolean nullMarked,
boolean nullUnmarked,
List<TypeParamInfo> typeParams) {}
List<TypeParamInfo> typeParams,
Map<Integer, Set<NestedAnnotationInfo>> nestedAnnotationsList) {}

public record ClassInfo(
String name,
Expand Down Expand Up @@ -153,22 +155,35 @@ public void finished(com.sun.source.util.TaskEvent e) {
return super.visitMethod(methodTree, null);
}
boolean methodHasAnnotations = false;
Map<Integer, Set<NestedAnnotationInfo>> nestedAnnotationsMap = new HashMap<>();
String returnType = "";
if (methodTree.getReturnType() != null) {
returnType += mSym.getReturnType().toString();
if (hasJSpecifyAnnotationDeep(mSym.getReturnType())) {
methodHasAnnotations = true;
CreateNestedAnnotationInfoVisitor visitor =
new CreateNestedAnnotationInfoVisitor();
mSym.getReturnType().accept(visitor, null);
Set<NestedAnnotationInfo> nested = visitor.getNestedAnnotationInfoSet();
if (!nested.isEmpty()) {
nestedAnnotationsMap.put(-1, nested);
}
}
}
boolean hasNullMarked = hasAnnotation(mSym, NULLMARKED_NAME);
boolean hasNullUnmarked = hasAnnotation(mSym, NULLUNMARKED_NAME);
methodHasAnnotations = methodHasAnnotations || hasNullMarked || hasNullUnmarked;
// check each parameter annotations
if (!methodHasAnnotations) {
for (Symbol.VarSymbol vSym : mSym.getParameters()) {
if (hasJSpecifyAnnotationDeep(vSym.asType())) {
methodHasAnnotations = true;
break;
for (int idx = 0; idx < mSym.getParameters().size(); idx++) {
Symbol.VarSymbol vSym = mSym.getParameters().get(idx);
if (hasJSpecifyAnnotationDeep(vSym.asType())) {
methodHasAnnotations = true;
CreateNestedAnnotationInfoVisitor visitor =
new CreateNestedAnnotationInfoVisitor();
vSym.asType().accept(visitor, null);
Set<NestedAnnotationInfo> nested = visitor.getNestedAnnotationInfoSet();
if (!nested.isEmpty()) {
nestedAnnotationsMap.put(idx, nested);
}
}
}
Expand All @@ -185,7 +200,8 @@ public void finished(com.sun.source.util.TaskEvent e) {
mSym.toString(),
hasNullMarked,
hasNullUnmarked,
methodTypeParams);
methodTypeParams,
nestedAnnotationsMap);
// only add this method if it uses JSpecify annotations
if (currentClass != null && methodHasAnnotations) {
currentClass.methods().add(methodInfo);
Expand Down
Loading