From 91ca5b5ba2d44140d71b8a1cc4af61b5e562c92d Mon Sep 17 00:00:00 2001 From: sullis Date: Wed, 7 Oct 2026 13:24:27 -0400 Subject: [PATCH] JSpecify migrations remove nullability annotations from void methods --- .../RemoveAnnotationFromVoidMethod.java | 129 +++++++++ .../resources/META-INF/rewrite/jspecify.yml | 26 ++ .../jspecify/JSpecifyBestPracticesTest.java | 104 +++++++- .../RemoveAnnotationFromVoidMethodTest.java | 251 ++++++++++++++++++ 4 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/openrewrite/java/migrate/jspecify/RemoveAnnotationFromVoidMethod.java create mode 100644 src/test/java/org/openrewrite/java/migrate/jspecify/RemoveAnnotationFromVoidMethodTest.java diff --git a/src/main/java/org/openrewrite/java/migrate/jspecify/RemoveAnnotationFromVoidMethod.java b/src/main/java/org/openrewrite/java/migrate/jspecify/RemoveAnnotationFromVoidMethod.java new file mode 100644 index 0000000000..98b9df678f --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/jspecify/RemoveAnnotationFromVoidMethod.java @@ -0,0 +1,129 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.openrewrite.java.migrate.jspecify; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.openrewrite.*; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.TypeMatcher; +import org.openrewrite.java.search.UsesType; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.Space; +import org.openrewrite.java.tree.TypeUtils; + +import java.util.List; + +@EqualsAndHashCode(callSuper = false) +@Value +public class RemoveAnnotationFromVoidMethod extends Recipe { + + @Option(displayName = "Annotation type", + description = "The type of annotation to remove from methods returning `void`.", + example = "org.jetbrains.annotations.*ull*") + String annotationType; + + String displayName = "Remove annotation from `void` method"; + + String description = "Nullability annotations are meaningless on methods returning `void`, " + + "and type-use annotations such as JSpecify's `@Nullable` are not permitted there. " + + "This recipe removes such annotations so they are not carried over by a later `ChangeType`."; + + @Override + public TreeVisitor getVisitor() { + return Preconditions.check(new UsesType<>(annotationType, null), new JavaIsoVisitor() { + final TypeMatcher typeMatcher = new TypeMatcher(annotationType); + + @Override + public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, ExecutionContext ctx) { + J.MethodDeclaration md = super.visitMethodDeclaration(method, ctx); + + if (md.getReturnTypeExpression() == null || + md.getReturnTypeExpression().getType() != JavaType.Primitive.Void) { + return md; + } + + // Annotations following a modifier, as in `public @Nullable static void m()` + md = md.withModifiers(ListUtils.map(md.getModifiers(), + m -> m.withAnnotations(removeMatching(m.getAnnotations())))); + + // Annotations preceding type parameters, as in `public @Nullable void m()` + J.TypeParameters typeParameters = md.getAnnotations().getTypeParameters(); + if (typeParameters != null) { + md = md.getAnnotations().withTypeParameters( + typeParameters.withAnnotations(removeMatching(typeParameters.getAnnotations()))); + } + + // Annotations directly preceding the return type, as in `public @Nullable void m()` + if (md.getReturnTypeExpression() instanceof J.AnnotatedType) { + J.AnnotatedType annotatedType = (J.AnnotatedType) md.getReturnTypeExpression(); + List annotations = annotatedType.getAnnotations(); + List remaining = removeMatching(annotations); + if (remaining.isEmpty()) { + md = md.withReturnTypeExpression(annotatedType.getTypeExpression().withPrefix( + annotatedType.getPrefix().withWhitespace(annotations.get(0).getPrefix().getWhitespace()))); + } else if (remaining != annotations) { + Space firstAnnotationPrefix = annotations.get(0).getPrefix(); + md = md.withReturnTypeExpression(annotatedType.withAnnotations( + ListUtils.mapFirst(remaining, a -> a.withPrefix(firstAnnotationPrefix)))); + } + } + + List original = md.getLeadingAnnotations(); + List leading = removeMatching(original); + if (leading == original) { + return md; + } + + Space firstPrefix = original.get(0).getPrefix(); + if (!leading.isEmpty()) { + leading = ListUtils.mapFirst(leading, a -> a.withPrefix(firstPrefix)); + return md.withLeadingAnnotations(leading); + } + + md = md.withLeadingAnnotations(leading); + if (!md.getModifiers().isEmpty()) { + md = md.withModifiers(Space.formatFirstPrefix(md.getModifiers(), + Space.firstPrefix(md.getModifiers()).withWhitespace(firstPrefix.getWhitespace()))); + } else if (md.getPadding().getTypeParameters() != null) { + md = md.getPadding().withTypeParameters(md.getPadding().getTypeParameters().withPrefix( + md.getPadding().getTypeParameters().getPrefix().withWhitespace(firstPrefix.getWhitespace()))); + } else { + md = md.withReturnTypeExpression(md.getReturnTypeExpression().withPrefix( + md.getReturnTypeExpression().getPrefix().withWhitespace(firstPrefix.getWhitespace()))); + } + return md; + } + + private List removeMatching(List annotations) { + return ListUtils.map(annotations, a -> { + if (matchesType(a)) { + maybeRemoveImport(TypeUtils.asFullyQualified(a.getType())); + return null; + } + return a; + }); + } + + private boolean matchesType(J.Annotation ann) { + JavaType.FullyQualified fq = TypeUtils.asFullyQualified(ann.getType()); + return fq != null && typeMatcher.matches(fq); + } + }); + } +} diff --git a/src/main/resources/META-INF/rewrite/jspecify.yml b/src/main/resources/META-INF/rewrite/jspecify.yml index 8ed33a6b17..55a68c9ce1 100644 --- a/src/main/resources/META-INF/rewrite/jspecify.yml +++ b/src/main/resources/META-INF/rewrite/jspecify.yml @@ -66,6 +66,13 @@ recipeList: version: 1.0.0 onlyIfUsing: javax.annotation.*ull* acceptTransitive: true + # Not `*ull*`, which would also remove the `*ByDefault` annotations; those are meaningful on methods + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: javax.annotation.Nullable + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: javax.annotation.CheckForNull + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: javax.annotation.Nonnull - org.openrewrite.java.migrate.jspecify.MoveAnnotationToArrayType: annotationType: javax.annotation.*ull* - org.openrewrite.java.ChangeType: @@ -102,6 +109,8 @@ recipeList: version: 1.0.0 onlyIfUsing: jakarta.annotation.*ull* acceptTransitive: true + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: jakarta.annotation.*ull* # Not moving jakarta annotations to array brackets; they target TYPE_USE, # so `@Nullable X[]` (element nullable) already differs from `X @Nullable[]` (array nullable). - org.openrewrite.java.ChangeType: @@ -130,6 +139,8 @@ recipeList: version: 1.0.0 onlyIfUsing: org.jetbrains.annotations.*ull* acceptTransitive: true + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: org.jetbrains.annotations.*ull* # Not moving JetBrains annotations to array brackets; they target TYPE_USE, # so `@Nullable X[]` (element nullable) already differs from `X @Nullable[]` (array nullable). - org.openrewrite.java.ChangeType: @@ -158,6 +169,8 @@ recipeList: version: 1.0.0 onlyIfUsing: org.springframework.lang.*ull* acceptTransitive: true + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: io.micrometer.core.lang.*ull* - org.openrewrite.java.migrate.jspecify.MoveAnnotationToArrayType: annotationType: io.micrometer.core.lang.*ull* - org.openrewrite.java.ChangeType: @@ -186,6 +199,8 @@ recipeList: version: 1.0.0 onlyIfUsing: org.springframework.lang.*ull* acceptTransitive: true + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: org.springframework.lang.*ull* - org.openrewrite.java.migrate.jspecify.MoveAnnotationToArrayType: annotationType: org.springframework.lang.*ull* - org.openrewrite.java.ChangeType: @@ -214,6 +229,8 @@ recipeList: version: 1.0.0 onlyIfUsing: io.micronaut.core.annotation.*ull* acceptTransitive: true + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: io.micronaut.core.annotation.*ull* # Not moving Micronaut annotations to array brackets; they target TYPE_USE, # so `@Nullable X[]` (element nullable) already differs from `X @Nullable[]` (array nullable). - org.openrewrite.java.ChangeType: @@ -242,6 +259,15 @@ recipeList: version: 1.0.0 onlyIfUsing: edu.umd.cs.findbugs.annotations.*ull* acceptTransitive: true + # Not `*ull*`, which would also remove the `*ByDefault` annotations; those are meaningful on methods + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: edu.umd.cs.findbugs.annotations.Nullable + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: edu.umd.cs.findbugs.annotations.CheckForNull + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: edu.umd.cs.findbugs.annotations.PossiblyNull + - org.openrewrite.java.migrate.jspecify.RemoveAnnotationFromVoidMethod: + annotationType: edu.umd.cs.findbugs.annotations.NonNull - org.openrewrite.java.migrate.jspecify.MoveAnnotationToArrayType: annotationType: edu.umd.cs.findbugs.annotations.*ull* - org.openrewrite.java.ChangeType: diff --git a/src/test/java/org/openrewrite/java/migrate/jspecify/JSpecifyBestPracticesTest.java b/src/test/java/org/openrewrite/java/migrate/jspecify/JSpecifyBestPracticesTest.java index c89c632301..01d6236be9 100644 --- a/src/test/java/org/openrewrite/java/migrate/jspecify/JSpecifyBestPracticesTest.java +++ b/src/test/java/org/openrewrite/java/migrate/jspecify/JSpecifyBestPracticesTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 the original author or authors. + * Copyright 2026 the original author or authors. *

* Licensed under the Moderne Source Available License (the "License"); * you may not use this file except in compliance with the License. @@ -261,6 +261,108 @@ public void bar(@Nullable String baz) { ); } + @Test + void jetbrainsAnnotationMethodWithVoidReturnType() { + rewriteRun( + //language=java + java( + """ + class Foo { + @org.jetbrains.annotations.Nullable + public void bar(int i) { + } + } + """, + """ + class Foo { + public void bar(int i) { + } + } + """ + ) + ); + } + + @Test + void jakartaAnnotationMethodWithVoidReturnType() { + rewriteRun( + //language=java + java( + """ + class Foo { + @jakarta.annotation.Nullable + public void bar(int i) { + } + } + """, + """ + class Foo { + public void bar(int i) { + } + } + """ + ) + ); + } + + @Test + void javaxParametersAreNonnullByDefaultOnVoidMethodMigratedToNullMarked() { + rewriteRun( + //language=java + java( + """ + import javax.annotation.Nullable; + import javax.annotation.ParametersAreNonnullByDefault; + + class Foo { + @Nullable + @ParametersAreNonnullByDefault + public void bar(String s) { + } + } + """, + """ + import org.jspecify.annotations.NullMarked; + + class Foo { + @NullMarked + public void bar(String s) { + } + } + """ + ) + ); + } + + @Test + void spotBugsReturnValuesAreNonnullByDefaultOnVoidMethodRetained() { + rewriteRun( + //language=java + java( + """ + import edu.umd.cs.findbugs.annotations.Nullable; + import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault; + + class Foo { + @Nullable + @ReturnValuesAreNonnullByDefault + public void bar(String s) { + } + } + """, + """ + import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault; + + class Foo { + @ReturnValuesAreNonnullByDefault + public void bar(String s) { + } + } + """ + ) + ); + } + @Test void migrateFromJakartaAnnotationApiToJspecify() { rewriteRun( diff --git a/src/test/java/org/openrewrite/java/migrate/jspecify/RemoveAnnotationFromVoidMethodTest.java b/src/test/java/org/openrewrite/java/migrate/jspecify/RemoveAnnotationFromVoidMethodTest.java new file mode 100644 index 0000000000..748781646c --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/jspecify/RemoveAnnotationFromVoidMethodTest.java @@ -0,0 +1,251 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.openrewrite.java.migrate.jspecify; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class RemoveAnnotationFromVoidMethodTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec + .recipe(new RemoveAnnotationFromVoidMethod("org.jetbrains.annotations.*ull*")) + .parser(JavaParser.fromJavaVersion().classpath("annotations")); + } + + @DocumentExample + @Test + void removeNullableFromVoidMethod() { + rewriteRun( + //language=java + java( + """ + import org.jetbrains.annotations.Nullable; + + class Foo { + @Nullable + public void bar() { + } + } + """, + """ + class Foo { + public void bar() { + } + } + """ + ) + ); + } + + @Test + void removeAllMatchingAnnotations() { + rewriteRun( + //language=java + java( + """ + import org.jetbrains.annotations.NotNull; + import org.jetbrains.annotations.Nullable; + + class Foo { + @NotNull + @Nullable + public void bar() { + } + } + """, + """ + class Foo { + public void bar() { + } + } + """ + ) + ); + } + + @Test + void keepOtherAnnotations() { + rewriteRun( + //language=java + java( + """ + import org.jetbrains.annotations.Nullable; + + class Foo { + @Nullable + @Deprecated + public void bar() { + } + + @Deprecated + @Nullable + public void baz() { + } + } + """, + """ + class Foo { + @Deprecated + public void bar() { + } + + @Deprecated + public void baz() { + } + } + """ + ) + ); + } + + @Test + void voidMethodWithoutModifiers() { + rewriteRun( + //language=java + java( + """ + class Foo { + @org.jetbrains.annotations.Nullable + void bar() { + } + + @org.jetbrains.annotations.Nullable + void baz(T t) { + } + } + """, + """ + class Foo { + void bar() { + } + + void baz(T t) { + } + } + """ + ) + ); + } + + @Test + void removeAnnotationsFollowingModifiers() { + rewriteRun( + //language=java + java( + """ + import org.jetbrains.annotations.Nullable; + + class Foo { + public @Nullable void a() { + } + + public @Nullable static void b() { + } + + public static @Nullable void c(T t) { + } + + public @Deprecated @Nullable void d() { + } + + public @Nullable @Deprecated void e() { + } + } + """, + """ + class Foo { + public void a() { + } + + public static void b() { + } + + public static void c(T t) { + } + + public @Deprecated void d() { + } + + public @Deprecated void e() { + } + } + """ + ) + ); + } + + @Test + void keepAnnotationOnNonVoidMethod() { + rewriteRun( + //language=java + java( + """ + import org.jetbrains.annotations.Nullable; + + class Foo { + @Nullable + public String bar() { + return null; + } + } + """ + ) + ); + } + + @Test + void keepAnnotationOnParameterOfVoidMethod() { + rewriteRun( + //language=java + java( + """ + import org.jetbrains.annotations.Nullable; + + class Foo { + public void bar(@Nullable String baz) { + } + } + """ + ) + ); + } + + @Test + void keepAnnotationOnBoxedVoidMethod() { + rewriteRun( + //language=java + java( + """ + import org.jetbrains.annotations.Nullable; + + class Foo { + @Nullable + public Void bar() { + return null; + } + } + """ + ) + ); + } +}