Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions src/main/java/org/openrewrite/staticanalysis/FluentSetter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/*
* Copyright 2025 the original author or authors.
* <p>
* 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
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* 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<ExecutionContext> getVisitor() {
return new JavaIsoVisitor<ExecutionContext>() {

@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<Statement> 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<AtomicBoolean>() {
@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<Statement> statements = body.getStatements();
if (statements.isEmpty()) {
return false;
}

for (Statement statement : statements) {
if (!(statement instanceof J.Throw)) {
return false;
}
}

return true;
}
};
}
}
Loading