diff --git a/src/main/java/org/openrewrite/java/migrate/lombok/AdoptLombokSetterMethodNames.java b/src/main/java/org/openrewrite/java/migrate/lombok/AdoptLombokSetterMethodNames.java new file mode 100644 index 0000000000..7729b8f916 --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/lombok/AdoptLombokSetterMethodNames.java @@ -0,0 +1,136 @@ +/* + * Copyright 2024 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.migrate.lombok; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.ExecutionContext; +import org.openrewrite.ScanningRecipe; +import org.openrewrite.Tree; +import org.openrewrite.TreeVisitor; +import org.openrewrite.java.ChangeMethodName; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.MethodMatcher; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.TypeUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Value +@EqualsAndHashCode(callSuper = false) +public class AdoptLombokSetterMethodNames extends ScanningRecipe> { + + private final static String DO_NOT_RENAME = "DO_NOT_RENAME"; + + @Override + public String getDisplayName() { + return "Rename setter methods to fit Lombok"; + } + + @Override + public String getDescription() { + return "Rename methods that are effectively setter to the name Lombok would give them.\n" + + "Limitations:\n" + + " - If two methods in a class are effectively the same setter then one's name will be corrected and the others name will be left as it is.\n" + + " - If the correct name for a method is already taken by another method then the name will not be corrected.\n" + + " - Method name swaps or circular renaming within a class cannot be performed because the names block each other.\n" + + "E.g. `int getFoo() { return ba; } int getBa() { return foo; }` stays as it is."; + } + + @Value + public static class RenameRecord { + String methodPattern; + String newMethodName; + } + + @Override + public List getInitialValue(ExecutionContext ctx) { + return new ArrayList<>(); + } + + @Override + public TreeVisitor getScanner(List renameRecords) { + return new JavaIsoVisitor() { + @Override + public J.CompilationUnit visitCompilationUnit(J.CompilationUnit cu, ExecutionContext ctx) { + // Cheaply collect all declared methods; this also means we do not support clashing nested class methods + Set declaredMethods = cu.getTypesInUse().getDeclaredMethods(); + List existingMethodNames = new ArrayList<>(); + for (JavaType.Method method : declaredMethods) { + existingMethodNames.add(method.getName()); + } + getCursor().putMessage(DO_NOT_RENAME, existingMethodNames); + return super.visitCompilationUnit(cu, ctx); + } + + @Override + public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, ExecutionContext ctx) { + if (method.getMethodType() == null || method.getBody() == null || + !LombokUtils.isEffectivelySetter(method) || + TypeUtils.isOverride(method.getMethodType())) { + return method; + } + + JavaType.Variable fieldType; + Expression variable = ((J.Assignment) method.getBody().getStatements().get(0)).getVariable(); + if (variable instanceof J.FieldAccess) { + fieldType = ((J.FieldAccess) variable).getName().getFieldType(); + } else if (variable instanceof J.Identifier) { + fieldType = ((J.Identifier) variable).getFieldType(); + } else { + return method; + } + + // If method already has the name it should have, then nothing to be done + String expectedMethodName = LombokUtils.deriveSetterMethodName(fieldType); + if (expectedMethodName.equals(method.getSimpleName())) { + return method; + } + + // If the desired method name is already taken by an existing method, the current method cannot be renamed + List doNotRename = getCursor().getNearestMessage(DO_NOT_RENAME); + assert doNotRename != null; + if (doNotRename.contains(expectedMethodName)) { + return method; + } + + renameRecords.add(new RenameRecord(MethodMatcher.methodPattern(method), expectedMethodName)); + doNotRename.remove(method.getSimpleName()); //actual method name becomes available again + doNotRename.add(expectedMethodName); //expected method name now blocked + return method; + } + }; + } + + @Override + public TreeVisitor getVisitor(List renameRecords) { + return new TreeVisitor() { + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + for (RenameRecord rr : renameRecords) { + tree = new ChangeMethodName(rr.methodPattern, rr.newMethodName, true, null) + .getVisitor().visit(tree, ctx); + } + return tree; + } + }; + } +} diff --git a/src/main/java/org/openrewrite/java/migrate/lombok/LombokUtils.java b/src/main/java/org/openrewrite/java/migrate/lombok/LombokUtils.java index b5484db79f..0f1a1f0f57 100644 --- a/src/main/java/org/openrewrite/java/migrate/lombok/LombokUtils.java +++ b/src/main/java/org/openrewrite/java/migrate/lombok/LombokUtils.java @@ -103,7 +103,6 @@ public static boolean isEffectivelyGetter(J.MethodDeclaration method) { } public static String deriveGetterMethodName(@Nullable JavaType type, String fieldName) { - if (type == JavaType.Primitive.Boolean) { boolean alreadyStartsWithIs = fieldName.length() >= 3 && fieldName.substring(0, 3).matches("is[A-Z]"); @@ -173,6 +172,40 @@ private static boolean hasMatchingSetterMethodName(J.MethodDeclaration method, S return method.getSimpleName().equals("set" + StringUtils.capitalize(simpleName)); } + public static boolean isEffectivelySetter(J.MethodDeclaration method) { + if (method.getType() != JavaType.Primitive.Void) { + return false; + } + if (method.getParameters().size() != 1 || method.getParameters().get(0) instanceof J.Empty) { + return false; + } + + J.VariableDeclarations variableDeclarations = (J.VariableDeclarations) method.getParameters().get(0); + J.VariableDeclarations.NamedVariable param = variableDeclarations.getVariables().get(0); + String paramName = param.getName().toString(); + + if (method.getBody() == null || + method.getBody().getStatements().size() != 1 || + !(method.getBody().getStatements().get(0) instanceof J.Assignment)) { + return false; + } + J.Assignment assignment = (J.Assignment) method.getBody().getStatements().get(0); + + if (!(assignment.getVariable() instanceof J.FieldAccess) && !(assignment.getVariable() instanceof J.Identifier)) { + return false; + } + + JavaType fieldType = assignment.getVariable().getType(); + // assigned value is exactly the parameter + return assignment.getAssignment().toString().equals(paramName) && + param.getType() != null && + param.getType().equals(fieldType); // type of parameter and field have to match + } + + public static String deriveSetterMethodName(JavaType.Variable fieldType) { + return "set" + StringUtils.capitalize(fieldType.getName()); + } + static AccessLevel getAccessLevel(J.MethodDeclaration methodDeclaration) { if (methodDeclaration.hasModifier(Public)) { return PUBLIC; diff --git a/src/test/java/org/openrewrite/java/migrate/lombok/AdoptLombokSetterMethodNamesTest.java b/src/test/java/org/openrewrite/java/migrate/lombok/AdoptLombokSetterMethodNamesTest.java new file mode 100644 index 0000000000..eaa136a747 --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/lombok/AdoptLombokSetterMethodNamesTest.java @@ -0,0 +1,594 @@ +/* + * Copyright 2024 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.migrate.lombok; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.ExpectedToFail; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class AdoptLombokSetterMethodNamesTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new AdoptLombokSetterMethodNames()); + } + + @DocumentExample + @Test + void renameInSingleClass() { + rewriteRun(// language=java + java( + """ + class A { + int foo = 9; + public void storeFoo(int foo) { + this.foo = foo; + } + } + """, + """ + class A { + int foo = 9; + public void setFoo(int foo) { + this.foo = foo; + } + } + """ + ) + ); + } + + @Test + void renameWithoutFieldAccess() { + rewriteRun(// language=java + java( + """ + class A { + int foo = 9; + public void storeFoo(int newfoo) { + foo = newfoo; + } + } + """, + """ + class A { + int foo = 9; + public void setFoo(int newfoo) { + foo = newfoo; + } + } + """ + ) + ); + } + + @Test + void renameInSingleClassWhitespace() { + rewriteRun(// language=java + java( + """ + class A { + int foo = 9; + public void storeFoo( int foo ) { + this .foo = foo; + } + } + """, + """ + class A { + int foo = 9; + public void setFoo( int foo ) { + this .foo = foo; + } + } + """ + ) + ); + } + + @Test + void renamePrimitiveBooleanInSingleClass() { + rewriteRun(// language=java + java( + """ + class A { + boolean foo; + void storeFoo(boolean foo) { this.foo = foo; } + } + """, + """ + class A { + boolean foo; + void setFoo(boolean foo) { this.foo = foo; } + } + """ + ) + ); + } + + @Test + void renameClassBooleanInSingleClass() { + rewriteRun(// language=java + java( + """ + class A { + Boolean foo; + void storeFoo(Boolean foo) { this.foo = foo; } + } + """, + """ + class A { + Boolean foo; + void setFoo(Boolean foo) { this.foo = foo; } + } + """ + ) + ); + } + + @Test + void renameAcrossClasses() { + rewriteRun(// language=java + java( + """ + class A { + int foo = 9; + void storeFoo(int foo) { this.foo = foo; } + } + """, + """ + class A { + int foo = 9; + void setFoo(int foo) { this.foo = foo; } + } + """ + ),// language=java + java( + """ + class B { + void useIt() { + var a = new A(); + a.storeFoo(4); + } + } + """, + """ + class B { + void useIt() { + var a = new A(); + a.setFoo(4); + } + } + """ + ) + ); + } + + @Test + void withoutPackage() { + rewriteRun(// language=java + java( + """ + class A { + + private long foo; + + public void setTime(long foo) { + this.foo = foo; + } + } + """, + """ + class A { + + private long foo; + + public void setFoo(long foo) { + this.foo = foo; + } + } + """ + ) + ); + } + + @Test + void shouldChangeOverridesOfInternalMethods() { + rewriteRun(// language=java + java( + """ + class A { + + private long foo; + + public void setTime(long foo) { + this.foo = foo; + } + } + """, + """ + class A { + + private long foo; + + public void setFoo(long foo) { + this.foo = foo; + } + } + """ + ),// language=java + java( + """ + class B extends A { + + @Override + public void setTime(long foo) { + } + } + """, + """ + class B extends A { + + @Override + public void setFoo(long foo) { + } + } + """ + ) + ); + } + + /** + * If two methods are effectively the same setter then only one can be renamed. + * Renaming both would result in a duplicate method definition, so we cannot do this. + * Ideally the other effective setter would have their usages renamed but be themselves deleted... + * TODO: create a second cleanup recipe that identifies redundant Setters (isEffectiveSetter + field already has the setter annotation) + * and redirects their usage (ChangeMethodName with both flags true) and then deletes them. + */ + @Test + void shouldNotRenameTwoToTheSame() { + rewriteRun(// language=java + java( + """ + class A { + + private long foo; + + public void firstToBeRenamed(long foo) { + this.foo = foo; + } + + public void secondToBeRenamed(long foo) { + this.foo = foo; + } + } + """, + """ + class A { + + private long foo; + + public void setFoo(long foo) { + this.foo = foo; + } + + public void secondToBeRenamed(long foo) { + this.foo = foo; + } + } + """ + ) + ); + } + + /** + * Methods in inner classes should be renamed as well. + */ + @Test + void shouldWorkOnInnerClasses() { + rewriteRun(// language=java + java( + """ + class A { + + class B { + + private long foo; + + public void storeFoo(long foo) { + this.foo = foo; + } + } + } + """, + """ + class A { + + class B { + + private long foo; + + public void setFoo(long foo) { + this.foo = foo; + } + } + } + """ + ) + ); + } + + @Test + void shouldWorkOnInnerClasses2() { + rewriteRun(// language=java + java( + """ + class A { + + class B { + + class C { + + private long foo; + + public void giveFoo(long foo) { + this.foo = foo; + } + }} + } + """, + """ + class A { + + class B { + + class C { + + private long foo; + + public void setFoo(long foo) { + this.foo = foo; + } + }} + } + """ + ) + ); + } + + /** + * Methods on top level should be renamed just as well when there is an inner class. + */ + @ExpectedToFail("We use `cu.getTypesInUse().getDeclaredMethods()` as a performance optimization to avoid visits") + @Test + void shouldWorkDespiteInnerClassesSameNameMethods() { + rewriteRun(// language=java + java( + """ + class A { + + private long foo; + + public void storeFoo(long foo) { + this.foo = foo; + } + + class B { + + private long foo; + + public void storeFoo(long foo) { + this.foo = foo; + } + } + } + """, + """ + class A { + + private long foo; + + public void setFoo(long foo) { + this.foo = foo; + } + + class B { + + private long foo; + + public void setFoo(long foo) { + this.foo = foo; + } + } + } + """ + ) + ); + } + + /** + * Methods on top level should be renamed just as well when there is an inner class. + */ + @Test + void shouldWorkDespiteInnerClassesDifferentNameMethods() { + rewriteRun(// language=java + java( + """ + class A { + + private long foo; + + public void storeFoo(long foo) { + this.foo = foo; + } + + class B { + + private long ba; + + public void storeBa(long ba) { + this.ba = ba; + } + } + } + """, + """ + class A { + + private long foo; + + public void setFoo(long foo) { + this.foo = foo; + } + + class B { + + private long ba; + + public void setBa(long ba) { + this.ba = ba; + } + } + } + """ + ) + ); + } + + /** + * If existing method names need to be rotated in a loop the recipe should still work. + * For now this is not planned. + */ + @ExpectedToFail("Not implemented yet") + @Test + void shouldWorkOnCircleCasesButDoesntYet() { + rewriteRun(// language=java + java( + """ + class A { + + int foo; + int bar; + + public void setBar(int bar) { + this.foo = bar; + } + + public void setFoo(int foo) { + this.bar = foo; + } + + } + """, + """ + class A { + + int foo; + int bar; + + public void setFoo(int foo) { + this.foo = foo; + } + + public void setBar(int bar) { + this.bar = bar; + } + + } + """ + ) + ); + } + + @Nested + class NoChange { + + @Test + void noBoxing1() { + rewriteRun(// language=java + java( + """ + class A { + Boolean Foo; + void storeFoo(boolean foo) { this.foo = foo; } + } + """ + ) + ); + } + + @Test + void noBoxing2() { + rewriteRun(// language=java + java( + """ + class A { + boolean Foo; + void storeFoo(Boolean foo) { this.foo = foo; } + } + """ + ) + ); + } + + @Test + void shouldNotChangeOverridesOfExternalMethods() { + rewriteRun(// language=java + java( + """ + import java.util.Date; + + class A extends Date { + + private long foo; + + @Override + public long setTime(long time) { + this.foo = time; + } + } + """ + ) + ); + } + + @Test + void shouldNotRenameToExistingMethods() { + rewriteRun(// language=java + java( + """ + class A { + + private long foo; + + public void setTime(long foo) { + this.foo = foo; + } + + public void setFoo(long foo) { + } + } + """ + ) + ); + } + } +}