diff --git a/spring-core/src/main/java/org/springframework/core/MethodParameter.java b/spring-core/src/main/java/org/springframework/core/MethodParameter.java index f89f03b855..8070c7b216 100644 --- a/spring-core/src/main/java/org/springframework/core/MethodParameter.java +++ b/spring-core/src/main/java/org/springframework/core/MethodParameter.java @@ -18,7 +18,6 @@ package org.springframework.core; import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; -import java.lang.reflect.AnnotatedType; import java.lang.reflect.Constructor; import java.lang.reflect.Executable; import java.lang.reflect.Field; @@ -388,39 +387,18 @@ public class MethodParameter { /** * Return whether this method indicates a parameter which is not required: - * either in the form of Java 8's {@link java.util.Optional}, any variant - * of a parameter-level {@code Nullable} annotation (such as from JSpecify, - * JSR-305 or Jakarta set of annotations), or a language-level nullable type + * either in the form of Java 8's {@link java.util.Optional}, JSpecify annotations, + * any variant of a parameter-level {@code @Nullable} annotation (such as from Spring, + * JSR-305 or Jakarta set of annotations), a language-level nullable type * declaration or {@code Continuation} parameter in Kotlin. * @since 4.3 + * @see Nullness#forMethodParameter(MethodParameter) */ public boolean isOptional() { - return (getParameterType() == Optional.class || hasNullableAnnotation() || + return (getParameterType() == Optional.class || Nullness.forMethodParameter(this) == Nullness.NULLABLE || (KotlinDetector.isKotlinType(getContainingClass()) && KotlinDelegate.isOptional(this))); } - /** - * Check whether this method parameter is annotated with any variant of a - * {@code Nullable} annotation, for example, {@code org.springframework.lang.Nullable}, - * {@code org.jspecify.annotations.Nullable} or {@code jakarta.annotation.Nullable}. - */ - private boolean hasNullableAnnotation() { - for (Annotation ann : getParameterAnnotations()) { - if ("Nullable".equals(ann.annotationType().getSimpleName())) { - return true; - } - } - if (this.parameterIndex >= 0) { - AnnotatedType annotatedType = this.executable.getAnnotatedParameterTypes()[this.parameterIndex]; - for (Annotation ann : annotatedType.getAnnotations()) { - if ("Nullable".equals(ann.annotationType().getSimpleName())) { - return true; - } - } - } - return false; - } - /** * Return a variant of this {@code MethodParameter} which points to * the same parameter but one nesting level deeper in case of a diff --git a/spring-core/src/main/java/org/springframework/core/Nullness.java b/spring-core/src/main/java/org/springframework/core/Nullness.java new file mode 100644 index 0000000000..e69df973b3 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/Nullness.java @@ -0,0 +1,210 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.AnnotatedType; +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Objects; +import java.util.function.Predicate; + +import kotlin.reflect.KFunction; +import kotlin.reflect.KParameter; +import kotlin.reflect.KProperty; +import kotlin.reflect.jvm.ReflectJvmMapping; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.NullUnmarked; +import org.jspecify.annotations.Nullable; + +/** + * Constants that indicate the nullness, as well as related utility methods. + * + *

The nullness applies to a type usage, a field, a method return type or a parameter. + * JSpecify annotations are fully supported, as well as + * Kotlin null safety and {@code @Nullable} annotations + * regardless of their package (from Spring, JSR-305 or Jakarta set of annotations for example). + * + * @author Sebastien Deleuze + * @since 7.0 + */ +public enum Nullness { + + /** + * Unspecified nullness (Java and JSpecify {@code @NullUnmarked} defaults). + */ + UNSPECIFIED, + + /** + * Can include null (typically specified with a {@code @Nullable} annotation). + */ + NULLABLE, + + /** + * Will not include null (Kotlin and JSpecify {@code @NullMarked} defaults). + */ + NON_NULL; + + + /** + * Return the nullness of the given method return type. + * @param method the source for the method return type + * @return the corresponding nullness + */ + public static Nullness forMethodReturnType(Method method) { + if (KotlinDetector.isKotlinType(method.getDeclaringClass())) { + return KotlinDelegate.forMethodReturnType(method); + } + return (hasNullableAnnotation(method) ? Nullness.NULLABLE : + jSpecifyNullness(method, method.getDeclaringClass(), method.getAnnotatedReturnType())); + } + + /** + * Return the nullness of the given parameter. + * @param parameter the parameter descriptor + * @return the corresponding nullness + */ + public static Nullness forParameter(Parameter parameter) { + if (KotlinDetector.isKotlinType(parameter.getDeclaringExecutable().getDeclaringClass())) { + // TODO Optimize when kotlin-reflect provide a more direct Parameter to KParameter resolution + MethodParameter methodParameter = MethodParameter.forParameter(parameter); + return KotlinDelegate.forParameter(methodParameter.getExecutable(), methodParameter.getParameterIndex()); + } + Executable executable = parameter.getDeclaringExecutable(); + return (hasNullableAnnotation(parameter) ? Nullness.NULLABLE : + jSpecifyNullness(executable, executable.getDeclaringClass(), parameter.getAnnotatedType())); + } + + /** + * Return the nullness of the given method parameter. + * @param methodParameter the method parameter descriptor + * @return the corresponding nullness + */ + public static Nullness forMethodParameter(MethodParameter methodParameter) { + return (methodParameter.getParameterIndex() < 0 ? + forMethodReturnType(Objects.requireNonNull(methodParameter.getMethod())) : + forParameter(methodParameter.getParameter())); + } + + /** + * Return the nullness of the given field. + * @param field the field descriptor + * @return the corresponding nullness + */ + public static Nullness forField(Field field) { + if (KotlinDetector.isKotlinType(field.getDeclaringClass())) { + return KotlinDelegate.forField(field); + } + return (hasNullableAnnotation(field) ? Nullness.NULLABLE : + jSpecifyNullness(field, field.getDeclaringClass(), field.getAnnotatedType())); + } + + + // Check method and parameter level @Nullable annotations regardless of the package (including Spring and JSR 305 annotations) + private static boolean hasNullableAnnotation(AnnotatedElement element) { + for (Annotation annotation : element.getDeclaredAnnotations()) { + if ("Nullable".equals(annotation.annotationType().getSimpleName())) { + return true; + } + } + return false; + } + + private static Nullness jSpecifyNullness(AnnotatedElement annotatedElement, Class declaringClass, AnnotatedType annotatedType) { + if (annotatedType.isAnnotationPresent(Nullable.class)) { + return Nullness.NULLABLE; + } + if (annotatedType.isAnnotationPresent(NonNull.class)) { + return Nullness.NON_NULL; + } + Nullness nullness = Nullness.UNSPECIFIED; + // Package level + Package declaringPackage = declaringClass.getPackage(); + if (declaringPackage.isAnnotationPresent(NullMarked.class)) { + nullness = Nullness.NON_NULL; + } + // Class level + if (declaringClass.isAnnotationPresent(NullMarked.class)) { + nullness = Nullness.NON_NULL; + } + else if (declaringClass.isAnnotationPresent(NullUnmarked.class)) { + nullness = Nullness.UNSPECIFIED; + } + // Annotated element level + if (annotatedElement.isAnnotationPresent(NullMarked.class)) { + nullness = Nullness.NON_NULL; + } + else if (annotatedElement.isAnnotationPresent(NullUnmarked.class)) { + nullness = Nullness.UNSPECIFIED; + } + return nullness; + } + + /** + * Inner class to avoid a hard dependency on Kotlin at runtime. + */ + private static class KotlinDelegate { + + + public static Nullness forMethodReturnType(Method method) { + KFunction function = ReflectJvmMapping.getKotlinFunction(method); + if (function != null && function.getReturnType().isMarkedNullable()) { + return Nullness.NULLABLE; + } + return Nullness.NON_NULL; + } + + public static Nullness forParameter(Executable executable, int parameterIndex) { + KFunction function; + Predicate predicate; + if (executable instanceof Method method) { + function = ReflectJvmMapping.getKotlinFunction(method); + predicate = p -> KParameter.Kind.VALUE.equals(p.getKind()); + } + else { + function = ReflectJvmMapping.getKotlinFunction((Constructor) executable); + predicate = p -> (KParameter.Kind.VALUE.equals(p.getKind()) || + KParameter.Kind.INSTANCE.equals(p.getKind())); + } + if (function == null) { + return Nullness.UNSPECIFIED; + } + int i = 0; + for (KParameter kParameter : function.getParameters()) { + if (predicate.test(kParameter) && parameterIndex == i++) { + return (kParameter.getType().isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL); + } + } + return Nullness.UNSPECIFIED; + } + + public static Nullness forField(Field field) { + KProperty property = ReflectJvmMapping.getKotlinProperty(field); + if (property != null && property.getReturnType().isMarkedNullable()) { + return Nullness.NULLABLE; + } + return Nullness.NON_NULL; + } + + } + +} diff --git a/spring-core/src/test/java/org/springframework/core/NullnessTests.java b/spring-core/src/test/java/org/springframework/core/NullnessTests.java new file mode 100644 index 0000000000..2cc0954deb --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/NullnessTests.java @@ -0,0 +1,380 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.core.testfixture.nullness.ClassMarkedJSpecifyProcessor; +import org.springframework.core.testfixture.nullness.CustomNullableProcessor; +import org.springframework.core.testfixture.nullness.JSpecifyProcessor; +import org.springframework.core.testfixture.nullness.NullnessFields; +import org.springframework.core.testfixture.nullness.marked.PackageMarkedJSpecifyProcessor; +import org.springframework.core.testfixture.nullness.marked.unmarked.PackageUnmarkedJSpecifyProcessor; + +/** + * Tests for {@link Nullness}. + * + * @author Sebastien Deleuze + */ +public class NullnessTests { + + // JSpecify without @NullMarked and @NullUnmarked + + @Test + void jspecifyUnspecifiedReturnType() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED); + } + + @Test + void jspecifyNullableReturnType() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("nullableProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyNonNullReturnType() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("nonNullProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyUnspecifiedParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[0]); + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED); + } + + @Test + void jspecifyNullableParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[1]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyNonNullParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[2]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + // JSpecify with MethodParameter without @NullMarked and @NullUnmarked + + @Test + void jspecifyUnspecifiedReturnTypeWithMethodParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var methodParameter = MethodParameter.forExecutable(method, -1); + var nullness = Nullness.forMethodParameter(methodParameter); + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED); + } + + @Test + void jspecifyNullableReturnTypeWithMethodParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("nullableProcess"); + var methodParameter = MethodParameter.forExecutable(method, -1); + var nullness = Nullness.forMethodParameter(methodParameter); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyNonNullReturnTypeWithMethodParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("nonNullProcess"); + var methodParameter = MethodParameter.forExecutable(method, -1); + var nullness = Nullness.forMethodParameter(methodParameter); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyUnspecifiedParameterWithMethodParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var methodParameter = MethodParameter.forExecutable(method, 0); + var nullness = Nullness.forMethodParameter(methodParameter); + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED); + } + + @Test + void jspecifyNullableParameterWithMethodParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var methodParameter = MethodParameter.forExecutable(method, 1); + var nullness = Nullness.forMethodParameter(methodParameter); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyNonNullParameterWithMethodParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var methodParameter = MethodParameter.forExecutable(method, 2); + var nullness = Nullness.forMethodParameter(methodParameter); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + // JSpecify with Field without @NullMarked and @NullUnmarked + + @Test + void jspecifyUnspecifiedWithField() throws NoSuchFieldException { + var field = NullnessFields.class.getDeclaredField("unannotatedField"); + var nullness = Nullness.forField(field); + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED); + } + + @Test + void jspecifyNullableWithField() throws NoSuchFieldException { + var field = NullnessFields.class.getDeclaredField("jspecifyNullableField"); + var nullness = Nullness.forField(field); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyNonNullWithField() throws NoSuchFieldException { + var field = NullnessFields.class.getDeclaredField("jspecifyNonNullField"); + var nullness = Nullness.forField(field); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + // JSpecify with method-level @NullMarked + + @Test + void jspecifyMethodMarkedUnspecifiedReturnType() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("markedProcess", String.class, String.class, String.class); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyMethodMarkedNullableReturnType() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("nullableMarkedProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyMethodMarkedNonNullReturnType() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("nonNullMarkedProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyMethodMarkedUnspecifiedParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("markedProcess", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[0]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyMethodMarkedNullableParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("markedProcess", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[1]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyMethodMarkedNonNullParameter() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("markedProcess", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[2]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + // JSpecify with class-level @NullMarked + + @Test + void jspecifyClassMarkedUnspecifiedReturnType() throws NoSuchMethodException { + var method = ClassMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyClassMarkedNullableReturnType() throws NoSuchMethodException { + var method = ClassMarkedJSpecifyProcessor.class.getMethod("nullableProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyClassMarkedNonNullReturnType() throws NoSuchMethodException { + var method = ClassMarkedJSpecifyProcessor.class.getMethod("nonNullProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyClassMarkedUnspecifiedParameter() throws NoSuchMethodException { + var method = ClassMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[0]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyClassMarkedNullableParameter() throws NoSuchMethodException { + var method = ClassMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[1]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyClassMarkedNonNullParameter() throws NoSuchMethodException { + var method = ClassMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[2]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyClassMarkedMethodUnmarkedUnspecifiedReturnType() throws NoSuchMethodException { + var method = ClassMarkedJSpecifyProcessor.class.getMethod("unmarkedProcess", String.class, String.class, String.class); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED); + } + + @Test + void jspecifyClassMarkedMethodUnmarkedUnspecifiedParameter() throws NoSuchMethodException { + var method = ClassMarkedJSpecifyProcessor.class.getMethod("unmarkedProcess", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[0]); + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED); + } + + @Test + void jspecifyClassMarkedMethodUnmarkedNullableParameter() throws NoSuchMethodException { + var method = ClassMarkedJSpecifyProcessor.class.getMethod("unmarkedProcess", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[1]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyClassMarkedMethodUnmarkedNonNullParameter() throws NoSuchMethodException { + var method = ClassMarkedJSpecifyProcessor.class.getMethod("unmarkedProcess", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[2]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + // JSpecify with package-level @NullMarked + + @Test + void jspecifyPackageMarkedUnspecifiedReturnType() throws NoSuchMethodException { + var method = PackageMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyPackageMarkedNullableReturnType() throws NoSuchMethodException { + var method = PackageMarkedJSpecifyProcessor.class.getMethod("nullableProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyPackageMarkedNonNullReturnType() throws NoSuchMethodException { + var method = PackageMarkedJSpecifyProcessor.class.getMethod("nonNullProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyPackageMarkedUnspecifiedParameter() throws NoSuchMethodException { + var method = PackageMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[0]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyPackageMarkedNullableParameter() throws NoSuchMethodException { + var method = PackageMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[1]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyPackageMarkedNonNullParameter() throws NoSuchMethodException { + var method = PackageMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[2]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + // JSpecify with package-level @NullUnmarked + + @Test + void jspecifyPackageUnmarkedUnspecifiedReturnType() throws NoSuchMethodException { + var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED); + } + + @Test + void jspecifyPackageUnmarkedNullableReturnType() throws NoSuchMethodException { + var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("nullableProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyPackageUnmarkedNonNullReturnType() throws NoSuchMethodException { + var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("nonNullProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + @Test + void jspecifyPackageUnmarkedUnspecifiedParameter() throws NoSuchMethodException { + var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[0]); + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED); + } + + @Test + void jspecifyPackageUnmarkedNullableParameter() throws NoSuchMethodException { + var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[1]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void jspecifyPackageUnmarkedNonNullParameter() throws NoSuchMethodException { + var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class); + var nullness = Nullness.forParameter(method.getParameters()[2]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL); + } + + // Custom @Nullable + + @Test + void customNullableReturnType() throws NoSuchMethodException { + var method = CustomNullableProcessor.class.getMethod("process", String.class); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void customNullableParameter() throws NoSuchMethodException { + var method = CustomNullableProcessor.class.getMethod("process", String.class); + var nullness = Nullness.forParameter(method.getParameters()[0]); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + + @Test + void customNullableField() throws NoSuchFieldException { + var field = NullnessFields.class.getDeclaredField("customNullableField"); + var nullness = Nullness.forField(field); + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); + } + +} diff --git a/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt b/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt new file mode 100644 index 0000000000..74db2eae47 --- /dev/null +++ b/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core + +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import kotlin.reflect.jvm.javaMethod + +/** + * Kotlin tests for [Nullness]. + * + * @author Sebastien Deleuze + */ +class NullnessKotlinTests { + + val nullableProperty: String? = "" + val nonNullProperty: String = "" + + @Test + fun nullableReturnType() { + val method = ::nullable.javaMethod!! + val nullness = Nullness.forMethodReturnType(method) + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE) + } + + @Test + fun nullableParameter() { + val method = ::nullable.javaMethod!! + val nullness = Nullness.forParameter(method.parameters[0]) + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE) + } + + @Test + fun nonNullReturnType() { + val method = ::nonNull.javaMethod!! + val nullness = Nullness.forMethodReturnType(method) + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL) + } + + @Test + fun nonNullParameter() { + val method = ::nonNull.javaMethod!! + val nullness = Nullness.forParameter(method.parameters[0]) + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL) + } + + @Test + fun nullableProperty() { + val field = javaClass.getDeclaredField("nullableProperty") + val nullness = Nullness.forField(field) + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE) + } + + @Test + fun nonNullProperty() { + val field = javaClass.getDeclaredField("nonNullProperty") + val nullness = Nullness.forField(field) + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL) + } + + @Suppress("unused_parameter") + fun nullable(nullable: String?): String? = "foo" + + @Suppress("unused_parameter") + fun nonNull(nonNull: String): String = "foo" + +} \ No newline at end of file diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/ClassMarkedJSpecifyProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/ClassMarkedJSpecifyProcessor.java new file mode 100644 index 0000000000..e1af26ac4c --- /dev/null +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/ClassMarkedJSpecifyProcessor.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core.testfixture.nullness; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.NullUnmarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public interface ClassMarkedJSpecifyProcessor { + + String process(String unspecified, @Nullable String nullable, @NonNull String nonNull); + + @Nullable String nullableProcess(); + + @NonNull String nonNullProcess(); + + @NullUnmarked + String unmarkedProcess(String unspecified, @Nullable String nullable, @NonNull String nonNull); + +} diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/CustomNullableProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/CustomNullableProcessor.java new file mode 100644 index 0000000000..25d6ca8b3b --- /dev/null +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/CustomNullableProcessor.java @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core.testfixture.nullness; + +public interface CustomNullableProcessor { + + @org.springframework.core.testfixture.nullness.custom.Nullable + String process(@org.springframework.core.testfixture.nullness.custom.Nullable String nullable); +} diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/JSpecifyProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/JSpecifyProcessor.java new file mode 100644 index 0000000000..a3032f114d --- /dev/null +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/JSpecifyProcessor.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core.testfixture.nullness; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +public interface JSpecifyProcessor { + + String process(String unspecified, @Nullable String nullable, @NonNull String nonNull); + + @Nullable String nullableProcess(); + + @NonNull String nonNullProcess(); + + @NullMarked + String markedProcess(String unspecified, @Nullable String nullable, @NonNull String nonNull); + + @NullMarked + @Nullable String nullableMarkedProcess(); + + @NullMarked + @NonNull String nonNullMarkedProcess(); +} diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/NullnessFields.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/NullnessFields.java new file mode 100644 index 0000000000..78d7143368 --- /dev/null +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/NullnessFields.java @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core.testfixture.nullness; + +public class NullnessFields { + + public String unannotatedField = ""; + + public @org.jspecify.annotations.Nullable String jspecifyNullableField; + + public @org.jspecify.annotations.NonNull String jspecifyNonNullField = ""; + + @org.springframework.core.testfixture.nullness.custom.Nullable + public String customNullableField; +} diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/custom/Nullable.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/custom/Nullable.java new file mode 100644 index 0000000000..9834dcc086 --- /dev/null +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/custom/Nullable.java @@ -0,0 +1,24 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core.testfixture.nullness.custom; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface Nullable { +} diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/PackageMarkedJSpecifyProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/PackageMarkedJSpecifyProcessor.java new file mode 100644 index 0000000000..5bf7705ff1 --- /dev/null +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/PackageMarkedJSpecifyProcessor.java @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core.testfixture.nullness.marked; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +public interface PackageMarkedJSpecifyProcessor { + + String process(String unspecified, @Nullable String nullable, @NonNull String nonNull); + + @Nullable String nullableProcess(); + + @NonNull String nonNullProcess(); +} diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/package-info.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/package-info.java new file mode 100644 index 0000000000..e748cfa814 --- /dev/null +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.springframework.core.testfixture.nullness.marked; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/PackageUnmarkedJSpecifyProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/PackageUnmarkedJSpecifyProcessor.java new file mode 100644 index 0000000000..9f4adcc9db --- /dev/null +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/PackageUnmarkedJSpecifyProcessor.java @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core.testfixture.nullness.marked.unmarked; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +public interface PackageUnmarkedJSpecifyProcessor { + + String process(String unspecified, @Nullable String nullable, @NonNull String nonNull); + + @Nullable String nullableProcess(); + + @NonNull String nonNullProcess(); +} diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/package-info.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/package-info.java new file mode 100644 index 0000000000..63157590dd --- /dev/null +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/package-info.java @@ -0,0 +1,4 @@ +@NullUnmarked +package org.springframework.core.testfixture.nullness.marked.unmarked; + +import org.jspecify.annotations.NullUnmarked;