Skip to content

Commit 9e138a7

Browse files
authored
Move type-use annotations to array brackets during JSpecify migration (#1038)
* Move type-use annotations to array brackets during JSpecify migration (#934) * Run MoveAnnotationToArrayType before ChangeType to preserve semantics Move the recipe before ChangeType in each migration pipeline so it matches on the old annotation type (e.g. javax.annotation.*). This avoids incorrectly moving pre-existing JSpecify annotations where @nullable String[] intentionally means "array of nullable Strings." * Add test: pre-existing JSpecify array-of-nullable-elements is not affected Verifies that a project with both javax annotations (to migrate) and pre-existing JSpecify @nullable String[] (meaning array of nullable elements) only migrates the javax annotations without touching the already-correct JSpecify annotations. * Fix copyright year, use import, and early returns in MoveAnnotationToArrayType * Narrow annotation type patterns to *ull* to match only nullability annotations * Replace AtomicReference with find-then-remove using equality check * Use ListUtils.map result equality check instead of stream * Remove AtomicReference and redundant test * Capture match from ListUtils.map lambda, drop separate for loop * Update generated recipes.csv * Add test for nested class array type annotation movement * Skip MoveAnnotationToArrayType for TYPE_USE annotations Jakarta, JetBrains, and Micronaut annotations target TYPE_USE, so `@Nullable X[]` (element nullable) already has distinct semantics from `X @nullable[]` (array nullable). Moving them would change meaning.
1 parent 276316d commit 9e138a7

5 files changed

Lines changed: 477 additions & 13 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.java.migrate.jspecify;
17+
18+
import lombok.EqualsAndHashCode;
19+
import lombok.Value;
20+
import org.openrewrite.*;
21+
import org.openrewrite.internal.ListUtils;
22+
import org.openrewrite.java.JavaIsoVisitor;
23+
import org.openrewrite.java.TypeMatcher;
24+
import org.openrewrite.java.search.UsesType;
25+
import org.openrewrite.java.tree.*;
26+
27+
import org.jspecify.annotations.Nullable;
28+
29+
import java.util.List;
30+
31+
import static java.util.Collections.singletonList;
32+
33+
@EqualsAndHashCode(callSuper = false)
34+
@Value
35+
public class MoveAnnotationToArrayType extends Recipe {
36+
37+
@Option(displayName = "Annotation type",
38+
description = "The type of annotation to move to the array type. " +
39+
"Should target the pre-migration annotation type to avoid changing the semantics " +
40+
"of pre-existing type-use annotations on object arrays.",
41+
example = "javax.annotation.*ull*")
42+
String annotationType;
43+
44+
String displayName = "Move annotation to array type";
45+
46+
String description = "When an annotation like `@Nullable` is applied to an array type in declaration position, " +
47+
"this recipe moves it to the array brackets. " +
48+
"For example, `@Nullable byte[]` becomes `byte @Nullable[]`. " +
49+
"Best used before `ChangeType` in a migration pipeline, targeting the pre-migration annotation type.";
50+
51+
@Override
52+
public TreeVisitor<?, ExecutionContext> getVisitor() {
53+
return Preconditions.check(new UsesType<>(annotationType, null), new JavaIsoVisitor<ExecutionContext>() {
54+
final TypeMatcher typeMatcher = new TypeMatcher(annotationType);
55+
56+
@Override
57+
public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, ExecutionContext ctx) {
58+
J.MethodDeclaration md = super.visitMethodDeclaration(method, ctx);
59+
60+
if (!(md.getReturnTypeExpression() instanceof J.ArrayType)) {
61+
return md;
62+
}
63+
64+
J.@Nullable Annotation[] match = {null};
65+
List<J.Annotation> leading = ListUtils.map(md.getLeadingAnnotations(), a -> {
66+
if (match[0] == null && matchesType(a)) {
67+
match[0] = a;
68+
return null;
69+
}
70+
return a;
71+
});
72+
if (leading == md.getLeadingAnnotations()) {
73+
return md;
74+
}
75+
md = md.withLeadingAnnotations(leading);
76+
77+
J.ArrayType arrayType = (J.ArrayType) md.getReturnTypeExpression();
78+
//noinspection DataFlowIssue
79+
arrayType = arrayType.withAnnotations(
80+
singletonList(match[0].withPrefix(Space.SINGLE_SPACE)));
81+
md = md.withReturnTypeExpression(arrayType);
82+
if (md.getLeadingAnnotations().isEmpty()) {
83+
md = md.withReturnTypeExpression(arrayType.withPrefix(
84+
arrayType.getPrefix().withWhitespace("")));
85+
}
86+
return autoFormat(md, arrayType, ctx, getCursor().getParentOrThrow());
87+
}
88+
89+
@Override
90+
public J.VariableDeclarations visitVariableDeclarations(J.VariableDeclarations multiVariable, ExecutionContext ctx) {
91+
J.VariableDeclarations mv = super.visitVariableDeclarations(multiVariable, ctx);
92+
93+
if (!(mv.getTypeExpression() instanceof J.ArrayType)) {
94+
return mv;
95+
}
96+
97+
J.@Nullable Annotation[] match = {null};
98+
List<J.Annotation> leading = ListUtils.map(mv.getLeadingAnnotations(), a -> {
99+
if (match[0] == null && matchesType(a)) {
100+
match[0] = a;
101+
return null;
102+
}
103+
return a;
104+
});
105+
if (leading == mv.getLeadingAnnotations()) {
106+
return mv;
107+
}
108+
mv = mv.withLeadingAnnotations(leading);
109+
110+
J.ArrayType arrayType = (J.ArrayType) mv.getTypeExpression();
111+
//noinspection DataFlowIssue
112+
arrayType = arrayType.withAnnotations(
113+
singletonList(match[0].withPrefix(Space.SINGLE_SPACE)));
114+
if (mv.getLeadingAnnotations().isEmpty()) {
115+
arrayType = arrayType.withPrefix(arrayType.getPrefix().withWhitespace(""));
116+
}
117+
mv = mv.withTypeExpression(arrayType);
118+
return autoFormat(mv, arrayType, ctx, getCursor().getParentOrThrow());
119+
}
120+
121+
private boolean matchesType(J.Annotation ann) {
122+
JavaType.FullyQualified fq = TypeUtils.asFullyQualified(ann.getType());
123+
return fq != null && typeMatcher.matches(fq);
124+
}
125+
});
126+
}
127+
}

src/main/resources/META-INF/rewrite/jspecify.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ recipeList:
6565
version: latest.release
6666
onlyIfUsing: javax.annotation.*ull*
6767
acceptTransitive: true
68+
- org.openrewrite.java.migrate.jspecify.MoveAnnotationToArrayType:
69+
annotationType: javax.annotation.*ull*
6870
- org.openrewrite.java.ChangeType:
6971
oldFullyQualifiedTypeName: javax.annotation.Nullable
7072
newFullyQualifiedTypeName: org.jspecify.annotations.Nullable
@@ -93,6 +95,8 @@ recipeList:
9395
version: 1.0.0
9496
onlyIfUsing: jakarta.annotation.*ull*
9597
acceptTransitive: true
98+
# Not moving jakarta annotations to array brackets; they target TYPE_USE,
99+
# so `@Nullable X[]` (element nullable) already differs from `X @Nullable[]` (array nullable).
96100
- org.openrewrite.java.ChangeType:
97101
oldFullyQualifiedTypeName: jakarta.annotation.Nullable
98102
newFullyQualifiedTypeName: org.jspecify.annotations.Nullable
@@ -117,6 +121,8 @@ recipeList:
117121
version: 1.0.0
118122
onlyIfUsing: org.jetbrains.annotations.*ull*
119123
acceptTransitive: true
124+
# Not moving JetBrains annotations to array brackets; they target TYPE_USE,
125+
# so `@Nullable X[]` (element nullable) already differs from `X @Nullable[]` (array nullable).
120126
- org.openrewrite.java.ChangeType:
121127
oldFullyQualifiedTypeName: org.jetbrains.annotations.Nullable
122128
newFullyQualifiedTypeName: org.jspecify.annotations.Nullable
@@ -141,6 +147,8 @@ recipeList:
141147
version: 1.0.0
142148
onlyIfUsing: org.springframework.lang.*ull*
143149
acceptTransitive: true
150+
- org.openrewrite.java.migrate.jspecify.MoveAnnotationToArrayType:
151+
annotationType: io.micrometer.core.lang.*ull*
144152
- org.openrewrite.java.ChangeType:
145153
oldFullyQualifiedTypeName: io.micrometer.core.lang.Nullable
146154
newFullyQualifiedTypeName: org.jspecify.annotations.Nullable
@@ -165,6 +173,8 @@ recipeList:
165173
version: 1.0.0
166174
onlyIfUsing: org.springframework.lang.*ull*
167175
acceptTransitive: true
176+
- org.openrewrite.java.migrate.jspecify.MoveAnnotationToArrayType:
177+
annotationType: org.springframework.lang.*ull*
168178
- org.openrewrite.java.ChangeType:
169179
oldFullyQualifiedTypeName: org.springframework.lang.Nullable
170180
newFullyQualifiedTypeName: org.jspecify.annotations.Nullable
@@ -189,6 +199,8 @@ recipeList:
189199
version: 1.0.0
190200
onlyIfUsing: io.micronaut.core.annotation.*ull*
191201
acceptTransitive: true
202+
# Not moving Micronaut annotations to array brackets; they target TYPE_USE,
203+
# so `@Nullable X[]` (element nullable) already differs from `X @Nullable[]` (array nullable).
192204
- org.openrewrite.java.ChangeType:
193205
oldFullyQualifiedTypeName: io.micronaut.core.annotation.Nullable
194206
newFullyQualifiedTypeName: org.jspecify.annotations.Nullable

0 commit comments

Comments
 (0)