Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 additions & 0 deletions nullaway/src/main/java/com/uber/nullaway/LibraryModels.java
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,24 @@ default ImmutableSetMultimap<MethodRef, MethodRef> ensuresNonNullIfTrueMethodCal
return ImmutableMap.of();
}

/**
* Get the locations in library method signatures that have linked, polymorphic nullness.
*
* <p>At a call, NullAway introduces nullability inference variables at the modeled input
* locations and generates constraints from invocation arguments, including lambdas and method
* references. All input occurrences must infer the same nullness, which is then substituted at
* every modeled location. Explicit method type arguments seed the corresponding inference
* variables after substitution. This model is used only in JSpecify mode, and the modeled
* method's enclosing class is expected to be modeled as {@code @NullMarked}. A PolyNull location
* takes precedence over any fixed nullable or non-null library model for the same location.
* Receiver locations can be represented but are not yet supported by inference.
*
* @return map from methods to signature locations with polymorphic nullness
*/
default ImmutableSetMultimap<MethodRef, PolyNullLocation> polyNullLocations() {
return ImmutableSetMultimap.of();
}

/**
* Get the (className, type argument index) pairs for library classes where the generic type
* variable has a {@code @Nullable} upper bound. Only used in JSpecify mode.
Expand Down Expand Up @@ -339,6 +357,37 @@ public String toString() {
}
}

/**
* A location within a method signature whose nullness is linked to other locations for the same
* method.
*
* @param position receiver, parameter, or return position in the method signature
* @param typePath path within the receiver, parameter, or return type; an empty path denotes its
* top level
*/
public record PolyNullLocation(
Position position, ImmutableList<NestedAnnotationInfo.TypePathEntry> typePath) {

/** A component of a method signature that may carry polymorphic nullness. */
public sealed interface Position permits Receiver, Parameter, Return {}

/** The method receiver. */
public record Receiver() implements Position {}

/** A zero-based method parameter. */
public record Parameter(int index) implements Position {
/** Rejects negative parameter indexes. */
public Parameter {
if (index < 0) {
throw new IllegalArgumentException("parameter index must be non-negative");
}
}
}

/** The method return type. */
public record Return() implements Position {}
}

/** Representation of a field as a qualified class name + a field name */
record FieldRef(String enclosingClassName, String fieldName) {

Expand Down
4 changes: 3 additions & 1 deletion nullaway/src/main/java/com/uber/nullaway/NullAway.java
Original file line number Diff line number Diff line change
Expand Up @@ -3009,7 +3009,9 @@ private boolean mayBeNullMethodCall(
if (Nullness.hasNullableAnnotation(exprSymbol, config)) {
return true;
}
if (config.isJSpecifyMode() && exprSymbol.getReturnType().getKind().equals(TypeKind.TYPEVAR)) {
if (config.isJSpecifyMode()
&& (exprSymbol.getReturnType().getKind().equals(TypeKind.TYPEVAR)
|| genericsChecks.hasPolyNullModel(exprSymbol, state))) {
// It is important to pass a correct TreePath to getGenericReturnNullnessAtInvocation. So, we
// do a search under path to find invocationTree. This shouldn't be too costly in the common
// case, and it's important for correctness.
Expand Down
445 changes: 372 additions & 73 deletions nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package com.uber.nullaway.generics;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.errorprone.VisitorState;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.tools.javac.code.Symbol;
import com.sun.tools.javac.code.Type;
import com.sun.tools.javac.util.ListBuffer;
import com.uber.nullaway.LibraryModels.PolyNullLocation;
import com.uber.nullaway.LibraryModels.PolyNullLocation.Parameter;
import com.uber.nullaway.LibraryModels.PolyNullLocation.Position;
import com.uber.nullaway.LibraryModels.PolyNullLocation.Return;
import com.uber.nullaway.Nullness;
import com.uber.nullaway.librarymodel.NestedTypePathUpdater;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import javax.lang.model.element.Element;

/** Constructs and applies polymorphic-nullness constraints for modeled method locations. */
final class PolyNullInference {

/** Diagnostic for incompatible constraints on modeled PolyNull locations. */
static final String INFERENCE_FAILURE_MESSAGE =
"inference failure: polymorphic nullness constrained to both @NonNull and @Nullable";

/** The modeled method-type overlay and shared PolyNull variable for one call. */
record PolyNullInferenceContext(
Type.MethodType inferenceMethodType,
ImmutableList<PolyNullLocation> locations,
Type.TypeVar inferenceVariable) {

/** Returns whether the overlay contains at least one modeled parameter location. */
boolean hasInputLocations() {
return locations.stream().anyMatch(location -> location.position() instanceof Parameter);
}

/** Returns whether the overlay contains at least one modeled return location. */
boolean hasReturnLocations() {
return locations.stream().anyMatch(location -> location.position() instanceof Return);
}
}

private PolyNullInference() {}

/** Applies a resolved PolyNull annotation to every modeled parameter and return location. */
@SuppressWarnings({"ReferenceEquality", "TypeEquals"}) // deliberate reference equality checks
static Type.MethodType applyToMethodType(
Type.MethodType methodType, ImmutableSet<PolyNullLocation> locations, Type annotationType) {
boolean changed = false;
ListBuffer<Type> updatedParameterTypes = new ListBuffer<>();
int parameterIndex = 0;
for (com.sun.tools.javac.util.List<Type> remaining = methodType.argtypes;
remaining.nonEmpty();
remaining = remaining.tail, parameterIndex++) {
Type parameterType = remaining.head;
Type updatedParameterType =
applyToType(parameterType, new Parameter(parameterIndex), locations, annotationType);
updatedParameterTypes.append(updatedParameterType);
changed |= updatedParameterType != parameterType;
}
Type returnType = methodType.restype;
Type updatedReturnType = applyToReturnType(returnType, locations, annotationType);
changed |= updatedReturnType != returnType;
return changed
? new Type.MethodType(
updatedParameterTypes.toList(), updatedReturnType, methodType.thrown, methodType.tsym)
: methodType;
}

/** Applies a resolved PolyNull annotation to every modeled location within a return type. */
static Type applyToReturnType(
Type returnType, ImmutableSet<PolyNullLocation> locations, Type annotationType) {
return applyToType(returnType, new Return(), locations, annotationType);
}

/** Resolves PolyNull nullness by invocation after a shared generic-inference solver run. */
static IdentityHashMap<MethodInvocationTree, Nullness> resolveNullnessByInvocation(
IdentityHashMap<MethodInvocationTree, PolyNullInferenceContext> contexts,
Map<Element, ConstraintSolver.InferredNullability> solution) {
IdentityHashMap<MethodInvocationTree, Nullness> nullnessByInvocation = new IdentityHashMap<>();
for (Map.Entry<MethodInvocationTree, PolyNullInferenceContext> entry : contexts.entrySet()) {
nullnessByInvocation.put(entry.getKey(), resolveContext(entry.getValue(), solution));
}
return nullnessByInvocation;
}

/** Resolves the shared PolyNull variable for one invocation. */
static Nullness resolveContext(
PolyNullInferenceContext inferenceContext,
Map<Element, ConstraintSolver.InferredNullability> solution) {
ConstraintSolver.InferredNullability inferred =
solution.getOrDefault(
inferenceContext.inferenceVariable().asElement(),
ConstraintSolver.InferredNullability.NONNULL);
return inferred == ConstraintSolver.InferredNullability.NULLABLE
? Nullness.NULLABLE
: Nullness.NONNULL;
}

/** Creates a method-type overlay with one shared variable at every modeled PolyNull location. */
@SuppressWarnings({"ReferenceEquality", "TypeEquals"}) // deliberate reference equality checks
static PolyNullInferenceContext createContext(
Symbol.MethodSymbol methodSymbol,
Type.MethodType methodType,
ImmutableSet<PolyNullLocation> locations,
Type nullableAnnotationType,
VisitorState state) {
Map<Integer, java.util.List<PolyNullLocation>> locationsByParameter = new LinkedHashMap<>();
ImmutableList.Builder<PolyNullLocation> appliedLocations = ImmutableList.builder();
Type.TypeVar inferenceVariable =
createInferenceVariable(methodSymbol, 0, nullableAnnotationType, state);
for (PolyNullLocation location : locations) {
if (!(location.position() instanceof Parameter parameter)) {
continue;
}
int parameterIndex = parameter.index();
if (parameterIndex < 0 || parameterIndex >= methodType.argtypes.size()) {
continue;
}
locationsByParameter
.computeIfAbsent(parameterIndex, unused -> new ArrayList<>())
.add(location);
}
ListBuffer<Type> updatedParameterTypes = new ListBuffer<>();
int parameterIndex = 0;
for (com.sun.tools.javac.util.List<Type> remaining = methodType.argtypes;
remaining.nonEmpty();
remaining = remaining.tail, parameterIndex++) {
Type updated = remaining.head;
for (PolyNullLocation location :
locationsByParameter.getOrDefault(parameterIndex, java.util.List.of())) {
Type replaced =
NestedTypePathUpdater.replaceType(updated, location.typePath(), inferenceVariable);
if (replaced != updated) {
updated = replaced;
appliedLocations.add(location);
}
}
updatedParameterTypes.append(updated);
}
Type updatedReturnType = methodType.restype;
for (PolyNullLocation location : locations) {
if (location.position() instanceof Return) {
Type replaced =
NestedTypePathUpdater.replaceType(
updatedReturnType, location.typePath(), inferenceVariable);
if (replaced != updatedReturnType) {
updatedReturnType = replaced;
appliedLocations.add(location);
}
}
}
return new PolyNullInferenceContext(
new Type.MethodType(
updatedParameterTypes.toList(), updatedReturnType, methodType.thrown, methodType.tsym),
appliedLocations.build(),
inferenceVariable);
}

/** Adds a call-result subtype constraint using the invocation's shared PolyNull variable. */
static void addResultConstraints(
ConstraintSolver solver,
PolyNullInferenceContext inferenceContext,
Type targetType,
boolean assignedToLocal) {
if (!inferenceContext.hasReturnLocations()) {
return;
}
solver.addSubtypeConstraint(
inferenceContext.inferenceMethodType().getReturnType(), targetType, assignedToLocal);
}

/** Returns whether {@code typeVariable} is a PolyNull variable from one of {@code contexts}. */
static boolean containsInferenceVariable(
IdentityHashMap<MethodInvocationTree, PolyNullInferenceContext> contexts,
Element typeVariable) {
return contexts.values().stream()
.anyMatch(context -> Objects.equals(context.inferenceVariable().asElement(), typeVariable));
}

/** Creates the nullable-bounded synthetic type variable for one PolyNull invocation. */
private static Type.TypeVar createInferenceVariable(
Symbol.MethodSymbol methodSymbol,
int group,
Type nullableAnnotationType,
VisitorState state) {
Symbol.TypeVariableSymbol symbol =
new Symbol.TypeVariableSymbol(
0, state.getName("$PolyNull$" + group), Type.noType, methodSymbol);
Type nullableObject =
TypeSubstitutionUtils.typeWithAnnot(state.getSymtab().objectType, nullableAnnotationType);
Type.TypeVar variable = new Type.TypeVar(symbol, nullableObject, state.getSymtab().botType);
symbol.type = variable;
return variable;
}

/** Applies {@code annotationType} at modeled locations within one method-type component. */
private static Type applyToType(
Type type, Position position, ImmutableSet<PolyNullLocation> locations, Type annotationType) {
Type updated = type;
for (PolyNullLocation location : locations) {
if (location.position().equals(position)) {
updated = NestedTypePathUpdater.addAnnotation(updated, location.typePath(), annotationType);
}
}
return updated;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
import com.sun.tools.javac.code.Types;
import com.sun.tools.javac.util.Context;
import com.uber.nullaway.ErrorMessage;
import com.uber.nullaway.LibraryModels.PolyNullLocation;
import com.uber.nullaway.LibraryModels.PolyNullLocation.Receiver;
import com.uber.nullaway.MethodParameterNullness;
import com.uber.nullaway.NullAway;
import com.uber.nullaway.Nullness;
Expand Down Expand Up @@ -376,6 +378,35 @@ public Type.MethodType onOverrideMethodType(
return currentType;
}

@Override
public ImmutableSet<PolyNullLocation> onGetPolyNullLocations(
Symbol.MethodSymbol methodSymbol, VisitorState state) {
ImmutableSet.Builder<PolyNullLocation> result = ImmutableSet.builder();
Handler previousOwner = null;
for (Handler h : handlers) {
ImmutableSet<PolyNullLocation> locations = h.onGetPolyNullLocations(methodSymbol, state);
if (locations.isEmpty()) {
continue;
}
if (locations.stream().anyMatch(location -> location.position() instanceof Receiver)) {
throw new IllegalArgumentException(
"PolyNull receiver locations are not yet supported for " + methodSymbol);
}
if (previousOwner != null) {
throw new IllegalArgumentException(
"Multiple handlers provide PolyNull locations for "
+ methodSymbol
+ ": "
+ previousOwner.getClass().getName()
+ " and "
+ h.getClass().getName());
}
previousOwner = h;
result.addAll(locations);
}
return result.build();
}

@Override
public FieldSkipResult shouldSkipFieldInitializationCheck(
Symbol.ClassSymbol classSymbol, Symbol fieldSymbol, VisitorState state) {
Expand Down
13 changes: 13 additions & 0 deletions nullaway/src/main/java/com/uber/nullaway/handlers/Handler.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import com.sun.tools.javac.util.Context;
import com.uber.nullaway.ErrorMessage;
import com.uber.nullaway.LibraryModels;
import com.uber.nullaway.LibraryModels.PolyNullLocation;
import com.uber.nullaway.MethodParameterNullness;
import com.uber.nullaway.NullAway;
import com.uber.nullaway.Nullness;
Expand Down Expand Up @@ -505,6 +506,18 @@ default Type.MethodType onOverrideMethodType(
return methodType;
}

/**
* Returns modeled polymorphic-nullness locations for {@code methodSymbol}.
*
* <p>The generic type-checking machinery uses these locations when resolving an invocation. A
* handler should only provide model metadata here; substitution and call-site reasoning belong in
* {@code GenericsChecks}.
*/
default ImmutableSet<PolyNullLocation> onGetPolyNullLocations(
Symbol.MethodSymbol methodSymbol, VisitorState state) {
return ImmutableSet.of();
}

enum FieldSkipResult {
/** do not skip the check */
NO,
Expand Down
Loading
Loading