diff --git a/src/main/java/org/openrewrite/staticanalysis/FluentSetter.java b/src/main/java/org/openrewrite/staticanalysis/FluentSetter.java new file mode 100644 index 0000000000..856eecaaaf --- /dev/null +++ b/src/main/java/org/openrewrite/staticanalysis/FluentSetter.java @@ -0,0 +1,206 @@ +/* + * Copyright 2025 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.staticanalysis; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Option; +import org.openrewrite.Recipe; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.Space; +import org.openrewrite.java.tree.Statement; +import org.openrewrite.marker.Markers; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; + +import static java.util.Collections.emptyList; +import static org.openrewrite.Tree.randomId; + +@EqualsAndHashCode(callSuper = false) +@Value +public class FluentSetter extends Recipe { + + @Option(displayName = "Include all void methods", description = "Whether to convert all void methods to return `this`, not just setters. When false, only methods matching setter patterns will be converted.", required = false) + @Nullable + Boolean includeAllVoidMethods; + + @Option(displayName = "Method name pattern", description = "A regular expression pattern to match method names. Only methods matching this pattern will be converted. Defaults to setter pattern when includeAllVoidMethods is false.", example = "set.*", required = false) + @Nullable + String methodNamePattern; + + @Option(displayName = "Exclude method patterns", description = "A regular expression pattern for method names to exclude from conversion. Methods matching this pattern will not be converted.", example = "main|run", required = false) + @Nullable + String excludeMethodPattern; + + @Override + public String getDisplayName() { + return "Convert setters to return `this` for fluent interfaces"; + } + + @Override + public String getDescription() { + return "Converts void setter methods (and optionally other void methods) to return `this` to enable method chaining and fluent interfaces. " + + "For safety, only converts methods in final classes by default to avoid breaking inheritance. Use methodNamePattern to opt-in for non-final classes."; + } + + @Override + public JavaIsoVisitor getVisitor() { + return new JavaIsoVisitor() { + + @Override + public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, ExecutionContext ctx) { + method = super.visitMethodDeclaration(method, ctx); + if (!shouldConvertMethod(method)) { + return method; + } + + J.ClassDeclaration containingClass = getCursor().firstEnclosing(J.ClassDeclaration.class); + if (containingClass == null || containingClass.getType() == null) { + return method; + } + + // Extract simple class name from fully qualified name or inner class name + JavaType.FullyQualified classType = containingClass.getType(); + String className = classType.getClassName(); + if (className.contains(".")) { + className = className.substring(className.lastIndexOf('.') + 1); + } + + Space returnTypeSpace = method.getReturnTypeExpression() == null ? Space.EMPTY : method.getReturnTypeExpression().getPrefix(); + Markers returnTypeMarkers = method.getReturnTypeExpression() == null ? Markers.EMPTY : method.getReturnTypeExpression().getMarkers(); + + J.Identifier returnTypeIdentifier = new J.Identifier(randomId(), returnTypeSpace, returnTypeMarkers, emptyList(), className, classType, null); + + J.MethodDeclaration updatedMethod = method.withReturnTypeExpression(returnTypeIdentifier); + + if (updatedMethod.getBody() != null) { + Space indentation; + List statements = updatedMethod.getBody().getStatements(); + if (!statements.isEmpty()) { + indentation = statements.get(statements.size() - 1).getPrefix(); + } else { + indentation = updatedMethod.getBody().getPrefix(); + } + + J.Return returnThis = new J.Return(randomId(), Space.format("\n" + indentation.getIndent()), Markers.EMPTY, new J.Identifier(randomId(), Space.SINGLE_SPACE, Markers.EMPTY, emptyList(), "this", classType, null)); + + updatedMethod = updatedMethod.withBody(updatedMethod.getBody().withStatements(ListUtils.concat(updatedMethod.getBody().getStatements(), returnThis))); + } + + return updatedMethod; + } + + private boolean shouldConvertMethod(J.MethodDeclaration method) { + if (method.getReturnTypeExpression() == null || method.getReturnTypeExpression().getType() != JavaType.Primitive.Void) { + return false; + } + + if (method.hasModifier(J.Modifier.Type.Static)) { + return false; + } + + if (method.hasModifier(J.Modifier.Type.Abstract) || method.getBody() == null) { + return false; + } + + if (method.isConstructor()) { + return false; + } + + if (method.getBody() != null && (hasReturnStatement(method.getBody()) || onlyThrowsException(method.getBody()))) { + return false; + } + + String methodName = method.getSimpleName(); + + if (excludeMethodPattern != null && !excludeMethodPattern.trim().isEmpty()) { + Pattern excludePattern = Pattern.compile(excludeMethodPattern); + if (excludePattern.matcher(methodName).matches()) { + return false; + } + } + + if (methodNamePattern != null && !methodNamePattern.trim().isEmpty()) { + Pattern namePattern = Pattern.compile(methodNamePattern); + return namePattern.matcher(methodName).matches(); + } + + J.ClassDeclaration containingClass = getCursor().firstEnclosing(J.ClassDeclaration.class); + if (containingClass == null || !containingClass.hasModifier(J.Modifier.Type.Final)) { + return false; + } + + if (includeAllVoidMethods != null && includeAllVoidMethods) { + return true; + } + + return isSetterMethod(method); + } + + private boolean isSetterMethod(J.MethodDeclaration method) { + String methodName = method.getSimpleName(); + if (!methodName.startsWith("set") || methodName.length() <= 3) { + return false; + } + + // Must have exactly one parameter + if (method.getParameters().size() != 1) { + return false; + } + + // The character after "set" should be uppercase (setName, not setup) + char charAfterSet = methodName.charAt(3); + return Character.isUpperCase(charAfterSet); + } + + private boolean hasReturnStatement(J.Block body) { + AtomicBoolean hasReturn = new AtomicBoolean(false); + + new JavaIsoVisitor() { + @Override + public J.Return visitReturn(J.Return returnStmt, AtomicBoolean found) { + found.set(true); + return returnStmt; + } + }.visit(body, hasReturn); + + return hasReturn.get(); + } + + private boolean onlyThrowsException(J.Block body) { + List statements = body.getStatements(); + if (statements.isEmpty()) { + return false; + } + + for (Statement statement : statements) { + if (!(statement instanceof J.Throw)) { + return false; + } + } + + return true; + } + }; + } +} diff --git a/src/test/java/org/openrewrite/staticanalysis/FluentSetterTest.java b/src/test/java/org/openrewrite/staticanalysis/FluentSetterTest.java new file mode 100644 index 0000000000..677baae43d --- /dev/null +++ b/src/test/java/org/openrewrite/staticanalysis/FluentSetterTest.java @@ -0,0 +1,739 @@ +/* + * Copyright 2025 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.staticanalysis; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class FluentSetterTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new FluentSetter(false, null, null)); + } + + @DocumentExample + @Test + void convertSimpleSetterInFinalClass() { + rewriteRun( + java( + """ + public final class Person { + private String name; + + public void setName(String name) { + this.name = name; + } + } + """, + """ + public final class Person { + private String name; + + public Person setName(String name) { + this.name = name; + return this; + } + } + """ + ) + ); + } + + @Test + void skipSetterInNonFinalClass() { + rewriteRun( + java( + """ + public class Person { + private String name; + + public void setName(String name) { + this.name = name; + } + } + """ + ) + ); + } + + @Test + void convertSetterWithExtraIdent() { + rewriteRun( + java( + """ + public final class Person { + private String name; + + public void setName(String name) { + this.name = name; + } + } + """, + """ + public final class Person { + private String name; + + public Person setName(String name) { + this.name = name; + return this; + } + } + """ + ) + ); + } + + @Test + void convertMultipleSettersInFinalClass() { + rewriteRun( + java( + """ + public final class Person { + private String name; + private int age; + private String email; + + public void setName(String name) { + System.out.println(name); + this.name = name; + } + + public void setAge(int age) { + this.age = age; + } + + public void setEmail(String email) { + this.email = email; + } + } + """, + """ + public final class Person { + private String name; + private int age; + private String email; + + public Person setName(String name) { + System.out.println(name); + this.name = name; + return this; + } + + public Person setAge(int age) { + this.age = age; + return this; + } + + public Person setEmail(String email) { + this.email = email; + return this; + } + } + """ + ) + ); + } + + @Test + void skipNonSetterVoidMethodsInFinalClass() { + rewriteRun( + java( + """ + public final class Example { + private String name; + + public void setName(String name) { + this.name = name; + } + + public void doSomething() { + System.out.println("doing something"); + } + + public void run() { + // some logic + } + } + """, + """ + public final class Example { + private String name; + + public Example setName(String name) { + this.name = name; + return this; + } + + public void doSomething() { + System.out.println("doing something"); + } + + public void run() { + // some logic + } + } + """ + ) + ); + } + + @Test + void skipStaticMethods() { + rewriteRun( + java( + """ + public final class Example { + private static String globalName; + private String name; + + public static void setGlobalName(String name) { + globalName = name; + } + + public void setName(String name) { + this.name = name; + } + } + """, + """ + public final class Example { + private static String globalName; + private String name; + + public static void setGlobalName(String name) { + globalName = name; + } + + public Example setName(String name) { + this.name = name; + return this; + } + } + """ + ) + ); + } + + @Test + void skipMethodsWithExistingReturnStatement() { + rewriteRun( + java( + """ + public final class Example { + private String name; + + public void setName(String name) { + if (name == null) { + return; + } + this.name = name; + } + } + """ + ) + ); + } + + @Test + void skipNonVoidMethods() { + rewriteRun( + java( + """ + public final class Example { + private String name; + + public String setName(String name) { + this.name = name; + return "success"; + } + + public String getName() { + return name; + } + } + """ + ) + ); + } + + @Test + void skipAbstractMethods() { + rewriteRun( + java( + """ + public abstract class Example { + private String name; + + public abstract void setName(String name); + } + """ + ) + ); + } + + @Test + void skipInvalidSetterPatterns() { + rewriteRun( + java( + """ + public final class Example { + private String name; + + public void updateName(String name) { + this.name = name; + } + + public void setup() { + // setup logic + } + + public void setNameAndAge(String name, int age) { + this.name = name; + } + } + """ + ) + ); + } + + @Test + void includeAllVoidMethodsInFinalClass() { + rewriteRun( + spec -> spec.recipe(new FluentSetter(true, null, null)), + java( + """ + public final class Example { + private String name; + + public void setName(String name) { + this.name = name; + } + + void doSomething() { + System.out.println("doing something"); + } + + public void process(String data) { + System.out.println("processing something"); + } + } + """, + """ + public final class Example { + private String name; + + public Example setName(String name) { + this.name = name; + return this; + } + + Example doSomething() { + System.out.println("doing something"); + return this; + } + + public Example process(String data) { + System.out.println("processing something"); + return this; + } + } + """ + ) + ); + } + + @Test + void customMethodNamePattern() { + rewriteRun( + spec -> spec.recipe(new FluentSetter(false, "add.*|remove.*", null)), + java( + """ + public class Example { + private String name; + + public void setName(String name) { + this.name = name; + } + + public void addItem(String item) { + System.out.println("adding item"); + } + + public void removeItem(String item) { + System.out.println("removing item"); + } + + public void doSomething() { + } + } + """, + """ + public class Example { + private String name; + + public void setName(String name) { + this.name = name; + } + + public Example addItem(String item) { + System.out.println("adding item"); + return this; + } + + public Example removeItem(String item) { + System.out.println("removing item"); + return this; + } + + public void doSomething() { + } + } + """ + ) + ); + } + + @Test + void excludeMethodPattern() { + rewriteRun( + spec -> spec.recipe(new FluentSetter(true, null, "main|run|execute")), + java( + """ + public final class Example { + private String name; + + public void setName(String name) { + this.name = name; + } + + public void doSomething() { + System.out.println("doing something"); + } + + // should be excluded + public void run() { + } + + // should be excluded + public void execute() { + } + + // should be excluded (also static) + public static void main(String[] args) { + } + } + """, + """ + public final class Example { + private String name; + + public Example setName(String name) { + this.name = name; + return this; + } + + public Example doSomething() { + System.out.println("doing something"); + return this; + } + + // should be excluded + public void run() { + } + + // should be excluded + public void execute() { + } + + // should be excluded (also static) + public static void main(String[] args) { + } + } + """ + ) + ); + } + + @Test + void worksWithInnerClasses() { + rewriteRun( + java( + """ + public final class Outer { + private String outerName; + + public void setOuterName(String name) { + this.outerName = name; + } + + public static final class Inner { + private String innerName; + + public void setInnerName(String name) { + this.innerName = name; + } + } + } + """, + """ + public final class Outer { + private String outerName; + + public Outer setOuterName(String name) { + this.outerName = name; + return this; + } + + public static final class Inner { + private String innerName; + + public Inner setInnerName(String name) { + this.innerName = name; + return this; + } + } + } + """ + ) + ); + } + + @Test + void preservesMethodModifiers() { + rewriteRun( + java( + """ + public final class Example { + private String name; + + protected void setName(String name) { + this.name = name; + } + + private void setPrivateName(String name) { + this.name = name; + } + } + """, + """ + public final class Example { + private String name; + + protected Example setName(String name) { + this.name = name; + return this; + } + + private Example setPrivateName(String name) { + this.name = name; + return this; + } + } + """ + ) + ); + } + + @Test + void withPatternConvertsNonFinalClasses() { + rewriteRun( + spec -> spec.recipe(new FluentSetter(false, "set.*", null)), + java( + """ + public class Person { + private String name; + + public void setName(String name) { + this.name = name; + } + } + """, + """ + public class Person { + private String name; + + public Person setName(String name) { + this.name = name; + return this; + } + } + """ + ) + ); + } + + @Test + void excludePatternWorksWithFinalClasses() { + rewriteRun( + spec -> spec.recipe(new FluentSetter(false, null, "setName")), + java( + """ + public final class Example { + private String name; + private int age; + + public void setName(String name) { + this.name = name; + } + + public void setAge(int age) { + this.age = age; + } + } + """, + """ + public final class Example { + private String name; + private int age; + + public void setName(String name) { + this.name = name; + } + + public Example setAge(int age) { + this.age = age; + return this; + } + } + """ + ) + ); + } + + @Test + void includeAllVoidMethodsInFinalClassOnly() { + rewriteRun( + spec -> spec.recipe(new FluentSetter(true, null, null)), + java( + """ + public final class FinalExample { + private String name; + + public void setName(String name) { + this.name = name; + } + + public void doSomething() { + System.out.println("doing something"); + } + } + + public class NonFinalExample { + private String name; + + public void setName(String name) { + this.name = name; + } + + public void doSomething() { + System.out.println("doing something"); + } + } + """, + """ + public final class FinalExample { + private String name; + + public FinalExample setName(String name) { + this.name = name; + return this; + } + + public FinalExample doSomething() { + System.out.println("doing something"); + return this; + } + } + + public class NonFinalExample { + private String name; + + public void setName(String name) { + this.name = name; + } + + public void doSomething() { + System.out.println("doing something"); + } + } + """ + ) + ); + } + + @Test + void combinedPatternAndExcludePattern() { + rewriteRun( + spec -> spec.recipe(new FluentSetter(false, "set.*", "setName")), + java( + """ + public class Example { + private String name; + private int age; + + public void setName(String name) { + this.name = name; + } + + public void setAge(int age) { + this.age = age; + } + } + """, + """ + public class Example { + private String name; + private int age; + + public void setName(String name) { + this.name = name; + } + + public Example setAge(int age) { + this.age = age; + return this; + } + } + """ + ) + ); + } + + @Test + void skipMethodsThatOnlyThrowExceptions() { + rewriteRun( + java( + """ + public final class Example { + + public void setRawOffset(final int offsetMillis) { + throw new UnsupportedOperationException(); + } + + public void setImmutableValue(String value) { + throw new IllegalStateException("Cannot modify immutable value"); + } + } + """ + ) + ); + } +}