Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Add support for JSpecify nullness annotations (#1243)
Detect @nonnull, @nullable, @NullMarked and @NullUnmarked from
org.jspecify.annotations to control null check generation in
mapping code.

Property-level null checks:
- Source @nonnull: skip null check (value guaranteed non-null)
- Target @nonnull: always add null check (must protect non-null target)
- All other cases: defer to existing NullValueCheckStrategy
- Safety guards (default values, unboxing, NVPMS) are preserved

Method-level source parameter:
- @nonnull parameter: skip the method-level null guard

@NullMarked / @NullUnmarked scope:
- In a @NullMarked class or package, unannotated types are effectively
  @nonnull
- @NullUnmarked reverts to unknown nullability within its scope
- @nullable on a specific type overrides the scope
- Scope is resolved by walking the enclosing element chain
  (method -> class -> outer class -> package) and the result is
  cached on Type.isNullMarked()

Constructor parameters:
- Report a compile error when mapping a potentially nullable source to
  a @nonnull constructor parameter without a defaultValue, since a null
  check would leave the variable at null violating the contract
  • Loading branch information
filiphr committed Apr 12, 2026
commit 455778094c049f196908c25d2a04c36b9e9ac462
7 changes: 7 additions & 0 deletions parent/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,13 @@
<version>1.14.6</version>
</dependency>

<!-- JSpecify -->
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version>
</dependency>

<!-- Joda-Time -->
<dependency>
<groupId>joda-time</groupId>
Expand Down
7 changes: 7 additions & 0 deletions processor/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@
<scope>test</scope>
</dependency>

<!-- There is no compile dependency to JSpecify; It's only required for testing the null annotations -->
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.mapstruct.ap.internal.model.presence.NullPresenceCheck;
import org.mapstruct.ap.internal.model.presence.OptionalPresenceCheck;
import org.mapstruct.ap.internal.model.presence.SuffixPresenceCheck;
import org.mapstruct.ap.internal.util.NullabilityUtils;
import org.mapstruct.ap.internal.util.Strings;
import org.mapstruct.ap.internal.util.accessor.PresenceCheckAccessor;

Expand Down Expand Up @@ -73,9 +74,14 @@ public NestedPropertyMappingMethod build() {

String previousPropertyName = sourceParameter.getName();
Type previousPropertyType = sourceParameter.getType();
boolean previousEntryIsNonNull = false;
for ( int i = 0; i < propertyEntries.size(); i++ ) {
PropertyEntry propertyEntry = propertyEntries.get( i );
PresenceCheck presenceCheck;
boolean currentEntryIsNonNull = NullabilityUtils.getNullability(
propertyEntry.getReadAccessor().getElement(),
previousPropertyType::isNullMarked
) == NullabilityUtils.Nullability.NON_NULL;

if ( previousPropertyType.isOptionalType() ) {
String optionalValueSafeName = Strings.getSafeVariableName(
Expand Down Expand Up @@ -112,9 +118,10 @@ public NestedPropertyMappingMethod build() {
}
else {
presenceCheck = getPresenceCheck( propertyEntry, previousPropertyName );
if ( i > 0 ) {
if ( i > 0 && !previousEntryIsNonNull ) {
// If this is not the first property entry,
// then we might need to combine the presence check with a null check of the previous property
// then we need to combine the presence check with a null check of the previous property.
// JSpecify: the null check is skipped when the previous accessor returns @NonNull.
if ( presenceCheck != null ) {
presenceCheck = new AnyPresenceChecksPresenceCheck( Arrays.asList(
new NullPresenceCheck( previousPropertyName, true ),
Expand All @@ -140,6 +147,7 @@ public NestedPropertyMappingMethod build() {
propertyEntry.getReadAccessor() ) );
previousPropertyName = safeName;
previousPropertyType = propertyEntry.getType();
previousEntryIsNonNull = currentEntryIsNonNull;
}
method.addThrownTypes( thrownTypes );
return new NestedPropertyMappingMethod( method, safePropertyEntries );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.mapstruct.ap.internal.model.source.selector.SelectionContext;
import org.mapstruct.ap.internal.model.source.selector.SelectionCriteria;
import org.mapstruct.ap.internal.util.Message;
import org.mapstruct.ap.internal.util.NullabilityUtils;

/**
* Factory for creating {@link PresenceCheck}s.
Expand Down Expand Up @@ -94,6 +95,15 @@ public static PresenceCheck getPresenceCheckForSourceParameter(
return new OptionalPresenceCheck( sourceParameter.getName(), ctx.getVersionInformation() );
}
else if ( !sourceParameter.getType().isPrimitive() ) {
// If the source parameter is @NonNull (JSpecify), skip the null guard entirely.
// Use the mapper type for @NullMarked scope resolution since the parameter
// is declared in the mapper interface.
if ( NullabilityUtils.getNullability(
sourceParameter.getElement(),
() -> ctx.getTypeFactory().getType( ctx.getMapperTypeElement().asType() ).isNullMarked() )
== NullabilityUtils.Nullability.NON_NULL ) {
return null;
}
return new NullPresenceCheck( sourceParameter.getName() );
}
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import org.mapstruct.ap.internal.model.source.selector.SelectionCriteria;
import org.mapstruct.ap.internal.util.Message;
import org.mapstruct.ap.internal.util.NativeTypes;
import org.mapstruct.ap.internal.util.NullabilityUtils;
import org.mapstruct.ap.internal.util.Strings;
import org.mapstruct.ap.internal.util.accessor.Accessor;
import org.mapstruct.ap.internal.util.accessor.AccessorType;
Expand Down Expand Up @@ -273,6 +274,26 @@ public PropertyMapping build() {
assignment = forge();
}

// JSpecify: report error when mapping @Nullable source to @NonNull constructor parameter
// without a default value. A null check would leave the variable at null, violating the
// @NonNull contract, and passing the value through is equally invalid.
if ( targetWriteAccessorType == AccessorType.PARAMETER && !hasDefaultValueOrDefaultExpression() ) {
NullabilityUtils.Nullability sourceNullability = getSourceJSpecifyNullability();
NullabilityUtils.Nullability targetNullability = NullabilityUtils.getSetterNullability(
targetWriteAccessor.getElement(), targetType::isNullMarked
);
if ( sourceNullability != NullabilityUtils.Nullability.NON_NULL
&& targetNullability == NullabilityUtils.Nullability.NON_NULL ) {
ctx.getMessager().printMessage(
method.getExecutable(),
positionHint,
Message.PROPERTYMAPPING_NULLABLE_SOURCE_TO_NON_NULL_CONSTRUCTOR_PARAM,
sourcePropertyName != null ? sourcePropertyName : targetPropertyName,
targetPropertyName
);
}
}

Type sourceType = rightHandSide.getSourceType();
if ( assignment != null ) {
ctx.getMessager().note( 2, Message.PROPERTYMAPPING_SELECT_NOTE, assignment );
Expand Down Expand Up @@ -448,13 +469,21 @@ private Assignment assignToPlainViaSetter(Type targetType, Assignment rhs) {
}

}
boolean includeSourceNullCheck = !rhs.isSourceReferenceParameter();
if ( includeSourceNullCheck ) {
// JSpecify: source @NonNull means no null check needed
NullabilityUtils.Nullability sourceNullability = getSourceJSpecifyNullability();
if ( sourceNullability == NullabilityUtils.Nullability.NON_NULL ) {
includeSourceNullCheck = false;
}
}
return new UpdateWrapper(
rhs,
method.getThrownTypes(),
factory,
isFieldAssignment(),
targetType,
!rhs.isSourceReferenceParameter(),
includeSourceNullCheck,
nvpms == SET_TO_NULL && !targetType.isPrimitive(),
nvpms == SET_TO_DEFAULT,
hasTwoOrMoreSettersWithName()
Expand Down Expand Up @@ -496,9 +525,10 @@ private boolean setterWrapperNeedsSourceNullCheck(Assignment rhs, Type targetTyp
return false;
}

if ( nvcs == ALWAYS ) {
// NullValueCheckStrategy is ALWAYS -> do a null check
return true;
// JSpecify: source @NonNull means the value is guaranteed non-null, skip all checks
NullabilityUtils.Nullability sourceNullability = getSourceJSpecifyNullability();
if ( sourceNullability == NullabilityUtils.Nullability.NON_NULL ) {
return false;
}

if ( rhs.getSourcePresenceCheckerReference() != null ) {
Expand Down Expand Up @@ -526,9 +556,42 @@ private boolean setterWrapperNeedsSourceNullCheck(Assignment rhs, Type targetTyp
return true;
}

// JSpecify annotations take precedence over NullValueCheckStrategy
NullabilityUtils.Nullability targetNullability = NullabilityUtils.getSetterNullability(
targetWriteAccessor.getElement(), targetType::isNullMarked
);
Boolean jspecifyDecision = NullabilityUtils.requiresNullCheck( sourceNullability, targetNullability );
if ( jspecifyDecision != null ) {
return jspecifyDecision;
}

if ( nvcs == ALWAYS ) {
// NullValueCheckStrategy is ALWAYS -> do a null check
return true;
}

return false;
}

private NullabilityUtils.Nullability getSourceJSpecifyNullability() {
if ( sourceReference != null && !sourceReference.getPropertyEntries().isEmpty() ) {
PropertyEntry deepestProperty = sourceReference.getDeepestProperty();
if ( deepestProperty != null && deepestProperty.getReadAccessor() != null ) {
// Determine the enclosing type for @NullMarked scope lookup.
// For simple properties (a.b), the enclosing type is the source parameter type.
// For nested properties (a.b.c), the enclosing type is the parent entry's type.
List<PropertyEntry> entries = sourceReference.getPropertyEntries();
Type enclosingType = entries.size() > 1
? entries.get( entries.size() - 2 ).getType()
: sourceReference.getParameter().getType();
return NullabilityUtils.getNullability(
deepestProperty.getReadAccessor().getElement(), enclosingType::isNullMarked
);
}
}
return NullabilityUtils.Nullability.UNKNOWN;
}

private boolean hasDefaultValueOrDefaultExpression() {
return defaultValue != null || defaultJavaExpression != null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
Expand Down Expand Up @@ -56,6 +57,7 @@
import org.mapstruct.ap.internal.util.ElementUtils;
import org.mapstruct.ap.internal.util.Executables;
import org.mapstruct.ap.internal.util.Filters;
import org.mapstruct.ap.internal.util.JSpecifyConstants;
import org.mapstruct.ap.internal.util.JavaStreamConstants;
import org.mapstruct.ap.internal.util.NativeTypes;
import org.mapstruct.ap.internal.util.Nouns;
Expand Down Expand Up @@ -159,6 +161,7 @@ public class Type extends ModelElement implements Comparable<Type> {
private Type boxedEquivalent = null;

private Boolean hasAccessibleConstructor;
private Boolean isNullMarked;
private KotlinMetadata kotlinMetadata;
private boolean kotlinMetadataInitialized;

Expand Down Expand Up @@ -324,6 +327,40 @@ public boolean isString() {
return String.class.getName().equals( getFullyQualifiedName() );
}

/**
* Whether this type is within a JSpecify {@code @NullMarked} scope.
* The result is computed once and cached.
*
* @return {@code true} if this type or an enclosing element has {@code @NullMarked}
*/
public boolean isNullMarked() {
if ( isNullMarked == null ) {
isNullMarked = resolveNullMarked();
}
return isNullMarked;
}

private boolean resolveNullMarked() {
if ( typeElement == null ) {
return false;
}
Element current = typeElement;
while ( current != null ) {
for ( AnnotationMirror mirror : current.getAnnotationMirrors() ) {
String fqn = ( (TypeElement) mirror.getAnnotationType().asElement() )
.getQualifiedName().toString();
if ( JSpecifyConstants.NULL_MARKED_FQN.equals( fqn ) ) {
return true;
}
if ( JSpecifyConstants.NULL_UNMARKED_FQN.equals( fqn ) ) {
return false;
}
}
current = current.getEnclosingElement();
}
return false;
}

/**
* @return this type's enum constants in case it is an enum, an empty list otherwise.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright MapStruct Authors.
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.internal.util;

/**
* Helper holding constants for working with JSpecify null annotations.
*
* @author Filip Hrisafov
*/
public final class JSpecifyConstants {

private JSpecifyConstants() {
}

public static final String NULLABLE_FQN = "org.jspecify.annotations.Nullable";

public static final String NON_NULL_FQN = "org.jspecify.annotations.NonNull";

public static final String NULL_MARKED_FQN = "org.jspecify.annotations.NullMarked";

public static final String NULL_UNMARKED_FQN = "org.jspecify.annotations.NullUnmarked";
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ public enum Message {
PROPERTYMAPPING_NO_SUITABLE_COLLECTION_OR_MAP_CONSTRUCTOR( "%s does not have an accessible copy or no-args constructor." ),
PROPERTYMAPPING_EXPRESSION_AND_CONDITION_QUALIFIED_BY_NAME_BOTH_DEFINED( "Expression and condition qualified by name are both defined in @Mapping, either define an expression or a condition qualified by name." ),
PROPERTYMAPPING_TARGET_HAS_NO_TARGET_PROPERTIES( "No target property found for target \"%s\".", Diagnostic.Kind.WARNING ),
PROPERTYMAPPING_NULLABLE_SOURCE_TO_NON_NULL_CONSTRUCTOR_PARAM( "Can't map potentially nullable source property \"%s\" to @NonNull constructor parameter \"%s\". Consider adding a defaultValue or defaultExpression." ),

CONVERSION_LOSSY_WARNING( "%s has a possibly lossy conversion from %s to %s.", Diagnostic.Kind.WARNING ),
CONVERSION_LOSSY_ERROR( "Can't map %s. It has a possibly lossy conversion from %s to %s." ),
Expand Down
Loading
Loading