Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
224 changes: 224 additions & 0 deletions src/main/java/org/openrewrite/staticanalysis/FluentSetterRecipe.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/*
* 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 FluentSetterRecipe 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.";
}

@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;
}

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())) {
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();
}

if (includeAllVoidMethods != null && includeAllVoidMethods) {
return true;
}

// Default behavior: only setter methods
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();
}
};
}
}
Loading