Skip to content

Commit 0f1d5db

Browse files
timtebeekclaude
andauthored
Annotate required parameters with @NonNull and remove throws (#782)
* Annotate required parameters with `@NonNull` and remove check + throw * Update expectations around `&&` * Add a test for three parameters * Do not remove `if` statement when there are other conditions * Do not remove `if` statement when there are other conditions * Apply suggestion to inline return * Leverage existing recipe visitors to clean up conditions and if statements * Directly build a set of identifiers * Swap order of visitors to match order of use * Remove unnecessary methods * Final bit of polish * Add example documentation for AnnotateRequiredParameters recipe Generated documentation using rewriteRun to showcase the AnnotateRequiredParameters recipe functionality. Also removed unused junit-platform.properties file. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Also remove existing nullable annotations from the same package --------- Co-authored-by: Claude <[email protected]>
1 parent 02e09d0 commit 0f1d5db

File tree

4 files changed

+973
-39
lines changed

4 files changed

+973
-39
lines changed
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
/*
2+
* Copyright 2025 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.staticanalysis;
17+
18+
import lombok.EqualsAndHashCode;
19+
import lombok.RequiredArgsConstructor;
20+
import lombok.Value;
21+
import org.jspecify.annotations.Nullable;
22+
import org.openrewrite.*;
23+
import org.openrewrite.internal.ListUtils;
24+
import org.openrewrite.java.*;
25+
import org.openrewrite.java.search.FindAnnotations;
26+
import org.openrewrite.java.search.SemanticallyEqual;
27+
import org.openrewrite.java.tree.*;
28+
import org.openrewrite.staticanalysis.java.MoveFieldAnnotationToType;
29+
30+
import java.util.*;
31+
32+
import static java.util.Comparator.comparing;
33+
34+
@EqualsAndHashCode(callSuper = false)
35+
@Value
36+
public class AnnotateRequiredParameters extends Recipe {
37+
38+
private static final String DEFAULT_NONNULL_ANN_CLASS = "org.jspecify.annotations.NonNull";
39+
40+
@Option(displayName = "`@NonNull` annotation class",
41+
description = "The fully qualified name of the @NonNull annotation. The annotation should be meta annotated with `@Target(TYPE_USE)`. Defaults to `org.jspecify.annotations.NonNull`",
42+
example = "org.jspecify.annotations.NonNull",
43+
required = false)
44+
@Nullable
45+
String nonNullAnnotationClass;
46+
47+
@Override
48+
public String getDisplayName() {
49+
return "Annotate required method parameters with `@NonNull`";
50+
}
51+
52+
@Override
53+
public String getDescription() {
54+
return "Add `@NonNull` to parameters of public methods that are explicitly checked for `null` and throw an exception if null. " +
55+
"By default `org.jspecify.annotations.NonNull` is used, but through the `nonNullAnnotationClass` option a custom annotation can be provided. " +
56+
"When providing a custom `nonNullAnnotationClass` that annotation should be meta annotated with `@Target(TYPE_USE)`. " +
57+
"This recipe scans for methods that do not already have parameters annotated with `@NonNull` annotation and checks for " +
58+
"null validation patterns that throw exceptions, such as `if (param == null) throw new IllegalArgumentException()`.";
59+
}
60+
61+
@Override
62+
public Validated<Object> validate() {
63+
return super.validate().and(Validated.test(
64+
"nonNullAnnotationClass",
65+
"Property `nonNullAnnotationClass` must be a fully qualified classname.",
66+
nonNullAnnotationClass,
67+
it -> it == null || it.contains(".")));
68+
}
69+
70+
@Override
71+
public TreeVisitor<?, ExecutionContext> getVisitor() {
72+
String fullyQualifiedName = nonNullAnnotationClass != null ? nonNullAnnotationClass : DEFAULT_NONNULL_ANN_CLASS;
73+
String fullyQualifiedPackage = fullyQualifiedName.substring(0, fullyQualifiedName.lastIndexOf('.'));
74+
String simpleName = fullyQualifiedName.substring(fullyQualifiedName.lastIndexOf('.') + 1);
75+
return new JavaIsoVisitor<ExecutionContext>() {
76+
@Override
77+
public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration methodDeclaration, ExecutionContext ctx) {
78+
J.MethodDeclaration md = super.visitMethodDeclaration(methodDeclaration, ctx);
79+
80+
// Supporting only public methods atm
81+
if (!md.hasModifier(J.Modifier.Type.Public) || md.getBody() == null ||
82+
md.getParameters().isEmpty() || md.getParameters().get(0) instanceof J.Empty ||
83+
md.getMethodType() == null || md.getMethodType().isOverride()) {
84+
return md;
85+
}
86+
87+
// Analyze all parameters to find required ones and statements to remove
88+
RequiredParameterAnalysis analysis = new RequiredParameterVisitor(getAllParameters(md))
89+
.reduce(md.getBody(), new RequiredParameterAnalysis());
90+
91+
if (analysis.requiredIdentifiers.isEmpty()) {
92+
return md;
93+
}
94+
95+
md = addAnnotationsToParameters(md, analysis, ctx);
96+
97+
// Replace null checks on required parameters with false
98+
md = md.withBody((J.Block) new ReplaceNullChecksWithFalse(analysis.requiredIdentifiers).visit(md.getBody(), ctx));
99+
100+
// Simplify boolean expressions (e.g., "false || x" becomes "x")
101+
doAfterVisit(new SimplifyBooleanExpression().getVisitor());
102+
// Simplify constant if branches (e.g., "if (false) throw ..." will be removed)
103+
doAfterVisit(new SimplifyConstantIfBranchExecution().getVisitor());
104+
105+
return md;
106+
}
107+
108+
private J.MethodDeclaration addAnnotationsToParameters(J.MethodDeclaration md, RequiredParameterAnalysis analysis, ExecutionContext ctx) {
109+
maybeAddImport(fullyQualifiedName);
110+
String nullableFqn = fullyQualifiedPackage + ".Nullable";
111+
112+
return md.withParameters(ListUtils.map(md.getParameters(), stm -> {
113+
if (stm instanceof J.VariableDeclarations) {
114+
J.VariableDeclarations vd = (J.VariableDeclarations) stm;
115+
J.Identifier identifier = vd.getVariables().get(0).getName();
116+
if (containsIdentifierByName(analysis.requiredIdentifiers, identifier) &&
117+
FindAnnotations.find(vd, "@" + fullyQualifiedName).isEmpty()) {
118+
119+
// Remove any @Nullable annotation from the same package before adding @NonNull
120+
maybeRemoveImport(nullableFqn);
121+
vd = (J.VariableDeclarations) new RemoveAnnotationVisitor(new AnnotationMatcher(nullableFqn)).visit(vd, ctx, getCursor());
122+
123+
// Add @NonNull annotation
124+
J.VariableDeclarations annotated = JavaTemplate.builder("@" + fullyQualifiedName)
125+
.javaParser(JavaParser.fromJavaVersion().dependsOn(
126+
String.format("package %s;public @interface %s {}", fullyQualifiedPackage, simpleName)))
127+
.build()
128+
.apply(new Cursor(getCursor(), vd),
129+
vd.getCoordinates().addAnnotation(comparing(J.Annotation::getSimpleName)));
130+
doAfterVisit(ShortenFullyQualifiedTypeReferences.modifyOnly(annotated));
131+
doAfterVisit(new MoveFieldAnnotationToType(fullyQualifiedName).getVisitor());
132+
return annotated.withModifiers(ListUtils.mapFirst(annotated.getModifiers(), first -> first.withPrefix(Space.SINGLE_SPACE)));
133+
}
134+
}
135+
return stm;
136+
}));
137+
}
138+
};
139+
}
140+
141+
private static boolean containsIdentifierByName(Collection<J.Identifier> identifiers, J.@Nullable Identifier target) {
142+
if (target == null) {
143+
return false;
144+
}
145+
for (J.Identifier identifier : identifiers) {
146+
if (SemanticallyEqual.areEqual(identifier, target)) {
147+
return true;
148+
}
149+
}
150+
return false;
151+
}
152+
153+
/**
154+
* Gets all method parameters.
155+
*
156+
* @param md the method declaration to analyze
157+
* @return set of all parameter declarations
158+
*/
159+
private Set<J.Identifier> getAllParameters(J.MethodDeclaration md) {
160+
Set<J.Identifier> allParams = new LinkedHashSet<>();
161+
for (Statement parameter : md.getParameters()) {
162+
if (parameter instanceof J.VariableDeclarations) {
163+
allParams.add(((J.VariableDeclarations) parameter).getVariables().get(0).getName());
164+
}
165+
}
166+
return allParams;
167+
}
168+
169+
/**
170+
* Result of analyzing a method body for required parameters.
171+
*/
172+
private static class RequiredParameterAnalysis {
173+
final Set<J.Identifier> requiredIdentifiers = new HashSet<>();
174+
}
175+
176+
/**
177+
* Visitor that traverses method bodies to identify parameters that are required (non-null).
178+
* This visitor looks for:
179+
* <ul>
180+
* <li>If statements with null checks that throw exceptions (if (param == null) throw new Exception())</li>
181+
* <li>Objects.requireNonNull calls which implicitly throw NullPointerException</li>
182+
* </ul>
183+
*/
184+
@RequiredArgsConstructor
185+
private static class RequiredParameterVisitor extends JavaIsoVisitor<RequiredParameterAnalysis> {
186+
private static final MethodMatcher REQUIRE_NON_NULL = new MethodMatcher("java.util.Objects requireNonNull(..)");
187+
188+
private final Collection<J.Identifier> parameterIdentifiers;
189+
190+
@Override
191+
public J.If visitIf(J.If iff, RequiredParameterAnalysis analysis) {
192+
iff = super.visitIf(iff, analysis);
193+
194+
// Check if the condition contains null checks
195+
Expression condition = iff.getIfCondition().getTree();
196+
List<J.Identifier> nullCheckedParams = extractNullCheckedParameters(condition);
197+
198+
if (!nullCheckedParams.isEmpty()) {
199+
// Check if the then-body throws an exception
200+
if (bodyThrowsException(iff.getThenPart())) {
201+
// Add all null-checked parameters as required
202+
for (J.Identifier param : nullCheckedParams) {
203+
if (containsIdentifierByName(parameterIdentifiers, param)) {
204+
analysis.requiredIdentifiers.add(param);
205+
}
206+
}
207+
}
208+
}
209+
210+
return iff;
211+
}
212+
213+
@Override
214+
public Statement visitStatement(Statement statement, RequiredParameterAnalysis analysis) {
215+
// Handle standalone Objects.requireNonNull calls (as expression statements)
216+
if (statement instanceof J.MethodInvocation) {
217+
J.MethodInvocation method = (J.MethodInvocation) statement;
218+
if (REQUIRE_NON_NULL.matches(method) && !method.getArguments().isEmpty() &&
219+
method.getArguments().get(0) instanceof J.Identifier) {
220+
J.Identifier firstArgument = (J.Identifier) method.getArguments().get(0);
221+
if (containsIdentifierByName(parameterIdentifiers, firstArgument)) {
222+
analysis.requiredIdentifiers.add(firstArgument);
223+
}
224+
}
225+
}
226+
return super.visitStatement(statement, analysis);
227+
}
228+
229+
/**
230+
* Extracts all parameter identifiers from a null check condition.
231+
* Handles patterns like:
232+
* - param == null
233+
* - null == param
234+
* - param1 == null || param2 == null (both required)
235+
* <p>
236+
* Does NOT handle:
237+
* - param1 == null && param2 == null (at most one may be null, not both required)
238+
*/
239+
private List<J.Identifier> extractNullCheckedParameters(Expression condition) {
240+
List<J.Identifier> params = new ArrayList<>();
241+
extractNullCheckedParametersRecursive(condition, params);
242+
return params;
243+
}
244+
245+
private void extractNullCheckedParametersRecursive(Expression condition, List<J.Identifier> params) {
246+
if (condition instanceof J.Binary) {
247+
J.Binary binary = (J.Binary) condition;
248+
J.Binary.Type operator = binary.getOperator();
249+
250+
// Only handle OR operator - means any parameter being null throws exception
251+
// Do NOT handle AND - "if (a == null && b == null)" means only when BOTH are null is there a problem
252+
if (operator == J.Binary.Type.Or) {
253+
extractNullCheckedParametersRecursive(binary.getLeft(), params);
254+
extractNullCheckedParametersRecursive(binary.getRight(), params);
255+
}
256+
// Check for == null comparisons
257+
else if (operator == J.Binary.Type.Equal) {
258+
if (J.Literal.isLiteralValue(binary.getLeft(), null) && binary.getRight() instanceof J.Identifier) {
259+
params.add((J.Identifier) binary.getRight());
260+
} else if (J.Literal.isLiteralValue(binary.getRight(), null) && binary.getLeft() instanceof J.Identifier) {
261+
params.add((J.Identifier) binary.getLeft());
262+
}
263+
}
264+
}
265+
}
266+
267+
/**
268+
* Checks if a statement block throws an exception.
269+
*/
270+
private boolean bodyThrowsException(Statement body) {
271+
if (body instanceof J.Throw) {
272+
return true;
273+
}
274+
if (body instanceof J.Block) {
275+
J.Block block = (J.Block) body;
276+
// Check if any statement in the block is a throw
277+
for (Statement statement : block.getStatements()) {
278+
if (statement instanceof J.Throw) {
279+
return true;
280+
}
281+
}
282+
}
283+
return false;
284+
}
285+
}
286+
287+
/**
288+
* Visitor that replaces null checks on required parameters with false.
289+
* This allows SimplifyConstantIfBranchExecution to clean up the dead code.
290+
*/
291+
@RequiredArgsConstructor
292+
private static class ReplaceNullChecksWithFalse extends JavaVisitor<ExecutionContext> {
293+
private static final MethodMatcher REQUIRE_NON_NULL = new MethodMatcher("java.util.Objects requireNonNull(..)");
294+
private final Set<J.Identifier> requiredIdentifiers;
295+
296+
@Override
297+
public J visitBinary(J.Binary binary, ExecutionContext ctx) {
298+
J.Binary b = (J.Binary) super.visitBinary(binary, ctx);
299+
300+
// Replace "param == null" or "null == param" with false for required parameters
301+
if (b.getOperator() == J.Binary.Type.Equal) {
302+
J.Identifier paramIdentifier = null;
303+
304+
if (J.Literal.isLiteralValue(b.getLeft(), null) && b.getRight() instanceof J.Identifier) {
305+
paramIdentifier = (J.Identifier) b.getRight();
306+
} else if (J.Literal.isLiteralValue(b.getRight(), null) && b.getLeft() instanceof J.Identifier) {
307+
paramIdentifier = (J.Identifier) b.getLeft();
308+
}
309+
310+
if (containsIdentifierByName(requiredIdentifiers, paramIdentifier)) {
311+
// Replace with false literal
312+
return new J.Literal(
313+
Tree.randomId(),
314+
b.getPrefix(),
315+
b.getMarkers(),
316+
false,
317+
"false",
318+
null,
319+
JavaType.Primitive.Boolean
320+
);
321+
}
322+
}
323+
324+
return b;
325+
}
326+
327+
@Override
328+
public @Nullable J visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) {
329+
J.MethodInvocation m = (J.MethodInvocation) super.visitMethodInvocation(method, ctx);
330+
331+
// Remove Objects.requireNonNull calls on required parameters
332+
if (REQUIRE_NON_NULL.matches(m) &&
333+
m.getArguments().get(0) instanceof J.Identifier &&
334+
containsIdentifierByName(requiredIdentifiers, (J.Identifier) m.getArguments().get(0))) {
335+
return null;
336+
}
337+
338+
return m;
339+
}
340+
}
341+
}

0 commit comments

Comments
 (0)