From eb4879cea7223951bf0b0cec332dafc02b084e34 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Fri, 3 Jul 2026 17:08:14 -0400 Subject: [PATCH 1/3] Add Reassignable trait A syntactic check that an expression could be the target of a plain assignment without introducing a compile error. Handles two shapes: - a J.Identifier referring to a non-final local declared in an enclosing block (method parameters are excluded because reassigning a parameter silently drops the caller's expected mutation). - a J.FieldAccess whose target is a bare J.Identifier and whose field is non-final. Array-element writes, parenthesized targets, method-chain receivers, and multi-hop qualified field access are intentionally not matched. Effectively-final semantics are not modelled. --- .../openrewrite/java/trait/Reassignable.java | 149 ++++++++++++++++++ .../java/trait/ReassignableTest.java | 135 ++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java create mode 100644 rewrite-java/src/test/java/org/openrewrite/java/trait/ReassignableTest.java diff --git a/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java b/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java new file mode 100644 index 00000000000..07846b6321a --- /dev/null +++ b/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java @@ -0,0 +1,149 @@ +/* + * Copyright 2026 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.openrewrite.java.trait; + +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.Cursor; +import org.openrewrite.Incubating; +import org.openrewrite.Tree; +import org.openrewrite.TreeVisitor; +import org.openrewrite.java.JavaVisitor; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.Flag; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.Statement; +import org.openrewrite.java.tree.TypeUtils; +import org.openrewrite.trait.SimpleTraitMatcher; +import org.openrewrite.trait.Trait; +import org.openrewrite.trait.VisitFunction2; + +/** + * A syntactic check that an expression could be the target of a plain assignment + * {@code target = ...} without introducing a compile error. Handles two shapes: + *

+ * Array-element writes, parenthesized targets, method-chain receivers, and + * multi-hop qualified field access (e.g. {@code a.b.c}) are intentionally not + * matched. Effectively-final semantics are not modelled. + */ +@Incubating(since = "8.68.0") +@Value +public class Reassignable implements Trait { + Cursor cursor; + + public static class Matcher extends SimpleTraitMatcher { + + @Override + public

TreeVisitor asVisitor(VisitFunction2 visitor) { + return new JavaVisitor

() { + @Override + public J visitIdentifier(J.Identifier ident, P p) { + Reassignable r = test(getCursor()); + return r != null ? + (J) visitor.visit(r, p) : + super.visitIdentifier(ident, p); + } + + @Override + public J visitFieldAccess(J.FieldAccess fieldAccess, P p) { + Reassignable r = test(getCursor()); + return r != null ? + (J) visitor.visit(r, p) : + super.visitFieldAccess(fieldAccess, p); + } + }; + } + + @Override + protected @Nullable Reassignable test(Cursor cursor) { + Object value = cursor.getValue(); + if (value instanceof J.Identifier && isReassignableLocalIdentifier(cursor, (J.Identifier) value)) { + return new Reassignable(cursor); + } + if (value instanceof J.FieldAccess && isReassignableFieldAccess((J.FieldAccess) value)) { + return new Reassignable(cursor); + } + return null; + } + + private static boolean isReassignableLocalIdentifier(Cursor cursor, J.Identifier ident) { + // Skip identifiers that appear as the name-part of a larger construct + // (field-access field name, method-invocation name, declaration name, etc.). + Object parent = cursor.getParentTreeCursor().getValue(); + if (parent instanceof J.FieldAccess && ((J.FieldAccess) parent).getName() == ident) { + return false; + } + if (parent instanceof J.MethodInvocation && ((J.MethodInvocation) parent).getName() == ident) { + return false; + } + if (parent instanceof J.VariableDeclarations.NamedVariable && + ((J.VariableDeclarations.NamedVariable) parent).getName() == ident) { + return false; + } + + if (ident.getFieldType() != null && ident.getFieldType().hasFlags(Flag.Final)) { + return false; + } + String name = ident.getSimpleName(); + Cursor c = cursor.getParent(); + while (c != null) { + Object v = c.getValue(); + if (v instanceof J.Block) { + for (Statement stmt : ((J.Block) v).getStatements()) { + if (stmt instanceof J.VariableDeclarations) { + J.VariableDeclarations vd = (J.VariableDeclarations) stmt; + for (J.VariableDeclarations.NamedVariable nv : vd.getVariables()) { + if (name.equals(nv.getName().getSimpleName()) && + TypeUtils.isOfType(nv.getName().getType(), ident.getType())) { + return !vd.hasModifier(J.Modifier.Type.Final); + } + } + } + } + } + if (v instanceof J.MethodDeclaration) { + for (Statement p : ((J.MethodDeclaration) v).getParameters()) { + if (p instanceof J.VariableDeclarations) { + for (J.VariableDeclarations.NamedVariable nv : ((J.VariableDeclarations) p).getVariables()) { + if (name.equals(nv.getName().getSimpleName())) { + return false; + } + } + } + } + return false; + } + c = c.getParent(); + } + return false; + } + + private static boolean isReassignableFieldAccess(J.FieldAccess fa) { + if (!(fa.getTarget() instanceof J.Identifier)) { + return false; + } + JavaType.Variable v = fa.getName().getFieldType(); + return v != null && !v.hasFlags(Flag.Final); + } + } +} diff --git a/rewrite-java/src/test/java/org/openrewrite/java/trait/ReassignableTest.java b/rewrite-java/src/test/java/org/openrewrite/java/trait/ReassignableTest.java new file mode 100644 index 00000000000..8d82687c8d8 --- /dev/null +++ b/rewrite-java/src/test/java/org/openrewrite/java/trait/ReassignableTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2026 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.openrewrite.java.trait; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.Recipe; +import org.openrewrite.marker.SearchResult; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; +import static org.openrewrite.test.RewriteTest.toRecipe; + +@SuppressWarnings("ALL") +class ReassignableTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(markReassignables()); + } + + @DocumentExample + @Test + void nonFinalLocalIsReassignable() { + rewriteRun( + java( + """ + class T { + void run() { + String local = ""; + local = "x"; + } + } + """, + """ + class T { + void run() { + String local = ""; + /*~~>*/local = "x"; + } + } + """ + ) + ); + } + + @Test + void finalLocalIsNotReassignable() { + rewriteRun( + java( + """ + class T { + void run() { + final String local = ""; + System.out.println(local); + } + } + """ + ) + ); + } + + @Test + void methodParameterIsNotReassignable() { + rewriteRun( + java( + """ + class T { + void run(String param) { + param = "x"; + } + } + """ + ) + ); + } + + @Test + void nonFinalThisFieldIsReassignable() { + rewriteRun( + java( + """ + class T { + String field = ""; + void run() { + this.field = "x"; + } + } + """, + """ + class T { + String field = ""; + void run() { + /*~~>*/this.field = "x"; + } + } + """ + ) + ); + } + + @Test + void finalThisFieldIsNotReassignable() { + rewriteRun( + java( + """ + class T { + final String field = ""; + void run() { + System.out.println(this.field); + } + } + """ + ) + ); + } + + Recipe markReassignables() { + return toRecipe(() -> new Reassignable.Matcher().asVisitor(r -> SearchResult.found(r.getTree()))); + } +} From 5a6f26d60f5039b24dcb100ec91496948b393a31 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Fri, 3 Jul 2026 17:10:24 -0400 Subject: [PATCH 2/3] Trim Reassignable javadoc --- .../org/openrewrite/java/trait/Reassignable.java | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java b/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java index 07846b6321a..c1ff71a70b0 100644 --- a/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java +++ b/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java @@ -34,17 +34,9 @@ /** * A syntactic check that an expression could be the target of a plain assignment - * {@code target = ...} without introducing a compile error. Handles two shapes: - *

    - *
  • a {@link J.Identifier} referring to a non-final local declared in an - * enclosing block (method parameters are excluded because reassigning a - * parameter silently drops the caller's expected mutation), and
  • - *
  • a {@link J.FieldAccess} whose target is a bare {@link J.Identifier} - * (e.g. {@code this.f} or {@code holder.f}) and whose field is non-final.
  • - *
- * Array-element writes, parenthesized targets, method-chain receivers, and - * multi-hop qualified field access (e.g. {@code a.b.c}) are intentionally not - * matched. Effectively-final semantics are not modelled. + * {@code target = ...}. Matches a non-final local {@link J.Identifier} declared + * in an enclosing block, or a {@link J.FieldAccess} on a non-final field whose + * target is a bare {@link J.Identifier}. */ @Incubating(since = "8.68.0") @Value From b05ec8086cee7e1452405b7932c961b81686fd4f Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Fri, 3 Jul 2026 17:11:01 -0400 Subject: [PATCH 3/3] Drop @Incubating from Reassignable --- .../src/main/java/org/openrewrite/java/trait/Reassignable.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java b/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java index c1ff71a70b0..7a37c391554 100644 --- a/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java +++ b/rewrite-java/src/main/java/org/openrewrite/java/trait/Reassignable.java @@ -18,7 +18,6 @@ import lombok.Value; import org.jspecify.annotations.Nullable; import org.openrewrite.Cursor; -import org.openrewrite.Incubating; import org.openrewrite.Tree; import org.openrewrite.TreeVisitor; import org.openrewrite.java.JavaVisitor; @@ -38,7 +37,6 @@ * in an enclosing block, or a {@link J.FieldAccess} on a non-final field whose * target is a bare {@link J.Identifier}. */ -@Incubating(since = "8.68.0") @Value public class Reassignable implements Trait { Cursor cursor;