diff --git a/src/main/java/org/openrewrite/staticanalysis/RemoveUnusedParams.java b/src/main/java/org/openrewrite/staticanalysis/RemoveUnusedParams.java new file mode 100644 index 0000000000..c20ddcaae1 --- /dev/null +++ b/src/main/java/org/openrewrite/staticanalysis/RemoveUnusedParams.java @@ -0,0 +1,311 @@ +/* + * 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.RequiredArgsConstructor; +import org.jspecify.annotations.Nullable; +import org.openrewrite.*; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.MethodMatcher; +import org.openrewrite.java.NoMissingTypes; +import org.openrewrite.java.search.SemanticallyEqual; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.Statement; + +import java.util.*; + +import static java.util.Collections.emptySet; +import static java.util.Objects.requireNonNull; + +public class RemoveUnusedParams extends ScanningRecipe { + public static class Accumulator { + /** + * Signatures of all methods that override or implement a supertype method. + * Each entry is a string of the form + * "fully.qualified.ClassName#methodName(paramType1,paramType2,...)". + * Parameters of these methods are considered part of the public API + * and will not be removed even if they appear unused. + */ + private final Set overrideSignatures = new HashSet<>(); + + private final Map> originalSignatures = new HashMap<>(); + } + + @Override + public String getDisplayName() { + return "Remove obsolete constructor and method parameters"; + } + + @Override + public String getDescription() { + return "Removes obsolete method parameters from signature, not used in body."; + } + + @Override + public Accumulator getInitialValue(ExecutionContext ctx) { + return new Accumulator(); + } + + @Override + public TreeVisitor getScanner(Accumulator acc) { + return new JavaIsoVisitor() { + @Override + public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, ExecutionContext ctx) { + J.MethodDeclaration m = super.visitMethodDeclaration(method, ctx); + JavaType.Method mt = requireNonNull(m.getMethodType()); + String className = mt.getDeclaringType().toString(); + acc.originalSignatures.computeIfAbsent(className, k -> new HashSet<>()) + .add(MethodMatcher.methodPattern(mt)); + if (mt.isOverride()) { + while (mt != null) { + acc.overrideSignatures.add(MethodMatcher.methodPattern(mt)); + mt = mt.getOverride(); + } + } + return m; + } + }; + } + + @Override + public TreeVisitor getVisitor(Accumulator acc) { + return Preconditions.check( + new NoMissingTypes(), + Repeat.repeatUntilStable(new RemoveUnusedParametersVisitor(acc))); + } + + @RequiredArgsConstructor + private static class RemoveUnusedParametersVisitor extends JavaIsoVisitor { + private final Accumulator acc; + + @Override + public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, ExecutionContext ctx) { + J.MethodDeclaration m = super.visitMethodDeclaration(method, ctx); + if (shouldPruneParameters(m)) { + List prunedParams = filterUnusedParameters(m, collectUsedParameters(m)); + return prunedParams == m.getParameters() ? m : applyPrunedSignature(m, prunedParams); + } + return m; + } + + private boolean shouldPruneParameters(J.MethodDeclaration m) { + if (m.getBody() == null || + m.getMethodType() == null || + m.hasModifier(J.Modifier.Type.Native) || + !m.getLeadingAnnotations().isEmpty()) { + return false; + } + return !acc.overrideSignatures.contains(MethodMatcher.methodPattern(m.getMethodType())); + } + + private Set collectUsedParameters(J.MethodDeclaration m) { + Deque> shadowStack = new ArrayDeque<>(); + return new JavaIsoVisitor>() { + @Override + public J.Block visitBlock(J.Block block, Set u) { + shadowStack.push(new HashSet<>()); + try { + return super.visitBlock(block, u); + } finally { + shadowStack.pop(); + } + } + + @Override + public J.VariableDeclarations visitVariableDeclarations(J.VariableDeclarations decl, Set u) { + decl.getVariables().forEach(v -> shadowStack.peek().add(v.getName())); + return super.visitVariableDeclarations(decl, u); + } + + @Override + public J.Identifier visitIdentifier(J.Identifier id, Set u) { + if (isVisibleParameter(id, m, shadowStack)) { + u.add(id.getSimpleName()); + } + return id; + } + }.reduce(m.getBody(), new HashSet<>()); + } + + private boolean isVisibleParameter(J.Identifier id, J.MethodDeclaration m, Deque> shadowStack) { + return !isShadowed(id, shadowStack) && isDeclaredAsParameter(id, m); + } + + private boolean isShadowed(J.Identifier id, Deque> shadowStack) { + for (Set scope : shadowStack) { + for (J.Identifier local : scope) { + if (SemanticallyEqual.areEqual(id, local)) { + return true; + } + } + } + return false; + } + + private boolean isDeclaredAsParameter(J.Identifier id, J.MethodDeclaration m) { + for (Statement p : m.getParameters()) { + if (p instanceof J.VariableDeclarations) { + for (J.VariableDeclarations.NamedVariable v : ((J.VariableDeclarations) p).getVariables()) { + if (SemanticallyEqual.areEqual(v.getName(), id)) { + return true; + } + } + } + } + return false; + } + + private List filterUnusedParameters(J.MethodDeclaration m, Set usedParams) { + return ListUtils.map( + m.getParameters(), + p -> { + if (!(p instanceof J.VariableDeclarations)) { + return p; + } + return processVariableDeclaration((J.VariableDeclarations) p, usedParams); + } + ); + } + + private @Nullable Statement processVariableDeclaration(J.VariableDeclarations decl, Set usedParams) { + List kept = keepUsedVariables(decl, usedParams); + if (!kept.isEmpty()) { + return decl.withVariables(kept); + } + if (!decl.getLeadingAnnotations().isEmpty()) { + return decl; + } + return null; + } + + private List keepUsedVariables(J.VariableDeclarations decl, Set usedParams) { + List kept = new ArrayList<>(decl.getVariables().size()); + for (J.VariableDeclarations.NamedVariable v : decl.getVariables()) { + if (usedParams.contains(v.getSimpleName())) { + kept.add(v); + } + } + return kept; + } + + private J.MethodDeclaration applyPrunedSignature(J.MethodDeclaration original, + List pruned) { + // Identify exactly which parameter positions were removed + List originalParams = original.getParameters(); + Set removedIndexes = new HashSet<>(); + for (int i = 0; i < originalParams.size(); i++) { + if (!pruned.contains(originalParams.get(i))) { + removedIndexes.add(i); + } + } + + // Build the pruned method declaration + JavaType.Method originalType = original.getMethodType(); + List prunedParamTypes = collectParameterTypes(pruned); + J.MethodDeclaration candidate = original + .withParameters(pruned) + .withMethodType(originalType.withParameterTypes(prunedParamTypes)); + + // Do override/original‐signature/superclass conflict checks + String fullSignature = MethodMatcher.methodPattern(candidate); + int split = fullSignature.indexOf(' '); + String qualifier = fullSignature.substring(0, split); + String signatureTail = fullSignature.substring(split + 1); + + if (acc.overrideSignatures.contains(fullSignature) || + acc.originalSignatures + .getOrDefault(qualifier, emptySet()) + .contains(fullSignature) || + conflictsWithSuperClassMethods(original, candidate, signatureTail) != null) { + return original; + } + + // Schedule a one‐off visitor to prune matching call‐site arguments + String oldSignature = MethodMatcher.methodPattern(originalType); + doAfterVisit(new JavaIsoVisitor() { + private final MethodMatcher matcher = new MethodMatcher(oldSignature); + + @Override + public J.MethodInvocation visitMethodInvocation(J.MethodInvocation invocation, + ExecutionContext ctx) { + J.MethodInvocation m = super.visitMethodInvocation(invocation, ctx); + if (matcher.matches(m) && m.getArguments().size() != prunedParamTypes.size()) { + // Trim the argument list + List keptArgs = new ArrayList<>(); + for (int i = 0; i < m.getArguments().size(); i++) { + if (!removedIndexes.contains(i)) { + keptArgs.add(m.getArguments().get(i)); + } + } + // Trim the MethodType parameter list + JavaType.Method mt = m.getMethodType(); + List keptTypes = new ArrayList<>(); + for (int i = 0; i < mt.getParameterTypes().size(); i++) { + if (!removedIndexes.contains(i)) { + keptTypes.add(mt.getParameterTypes().get(i)); + } + } + JavaType.Method updatedType = mt.withParameterTypes(keptTypes); + // Update the name identifier to carry the same type instance + J.Identifier newName = m.getName().withType(updatedType); + return m.withArguments(keptArgs) + .withMethodType(updatedType) + .withName(newName); + } + return m; + } + }); + + return candidate; + } + + private J.@Nullable MethodDeclaration conflictsWithSuperClassMethods( + J.MethodDeclaration original, J.MethodDeclaration candidate, String tail) { + JavaType.Method mt = candidate.getMethodType(); + if (mt != null && mt.getDeclaringType() instanceof JavaType.Class) { + JavaType.Class cls = (JavaType.Class) mt.getDeclaringType(); + JavaType.Class superCls = (JavaType.Class) cls.getSupertype(); + if (superCls != null) { + String superKey = superCls.getFullyQualifiedName() + " " + tail; + Set superSigs = acc.originalSignatures.getOrDefault(superCls.getFullyQualifiedName(), emptySet()); + if (superSigs.contains(superKey)) { + return original; + } + } + } + return null; + } + + private static List collectParameterTypes(List prunedParams) { + List newParamTypes = new ArrayList<>(); + for (Statement stmt : prunedParams) { + if (stmt instanceof J.VariableDeclarations) { + J.VariableDeclarations decl = (J.VariableDeclarations) stmt; + for (J.VariableDeclarations.NamedVariable v : decl.getVariables()) { + JavaType t = v.getType(); + if (t != null) { + newParamTypes.add(t); + } + } + } + } + return newParamTypes; + } + } +} diff --git a/src/test/java/org/openrewrite/staticanalysis/RemoveUnusedParamsTest.java b/src/test/java/org/openrewrite/staticanalysis/RemoveUnusedParamsTest.java new file mode 100644 index 0000000000..f8d452eaa9 --- /dev/null +++ b/src/test/java/org/openrewrite/staticanalysis/RemoveUnusedParamsTest.java @@ -0,0 +1,452 @@ +/* + * 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.Issue; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class RemoveUnusedParamsTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new RemoveUnusedParams()); + } + + @DocumentExample + @Test + void removeUnusedMethodParameter() { + rewriteRun( + java( + """ + public class Test { + void method(String unused) { + System.out.println("Hello"); + } + } + """, + """ + public class Test { + void method() { + System.out.println("Hello"); + } + } + """ + ) + ); + } + + @Test + void doNotRemoveUsedParameter() { + rewriteRun( + java( + """ + public class Test { + void method(String input) { + System.out.println(input); + } + } + """ + ) + ); + } + + @Test + void doNotRemoveOverriddenMethodParameter() { + rewriteRun( + java( + """ + class Test { + void method(String param) {} + } + class Base { + void method(String param) {} + } + class Derived extends Base { + @Override + void method(String param) { + // not used but required + } + } + """, + """ + class Test { + void method() {} + } + class Base { + void method(String param) {} + } + class Derived extends Base { + @Override + void method(String param) { + // not used but required + } + } + """ + ) + ); + } + + @Test + void doNotRemoveAnnotatedParameter() { + rewriteRun( + java( + """ + public class Test { + void method(@Deprecated String param) {} + } + """ + ) + ); + } + + @Test + void removeMultipleUnusedParams() { + rewriteRun( + java( + """ + public class Test { + void method(String a, int b, double c) { + System.out.println("Only prints this"); + } + } + """, + """ + public class Test { + void method() { + System.out.println("Only prints this"); + } + } + """ + ) + ); + } + + @Test + void preserveJavadocAndComments() { + rewriteRun( + java( + """ + public class Test { + /** + * Some doc + * @param unused this param is never used + */ + void method(String unused) { + // comment + System.out.println("used"); + } + } + """, + """ + public class Test { + /** + * Some doc + * @param unused this param is never used + */ + void method() { + // comment + System.out.println("used"); + } + } + """ + ) + ); + } + + @Issue("https://github.com/openrewrite/rewrite-static-analysis/issues/559") + @Test + void shadowedParameterShouldStillBeRemoved() { + rewriteRun( + java( + """ + public class Test { + void method(String input) { + String input = "shadowed"; + System.out.println(input); + } + } + """, + """ + public class Test { + void method() { + String input = "shadowed"; + System.out.println(input); + } + } + """ + ) + ); + } + + @Test + void removeUnusedStaticMethodParam() { + rewriteRun( + java( + """ + public class Test { + static void helper(String unused) { + // no use + } + } + """, + """ + public class Test { + static void helper() { + // no use + } + } + """ + ) + ); + } + + @Test + void removeUnusedConstructorParam() { + rewriteRun( + java( + """ + public class Test { + Test(String unused) { + // ctor body + } + } + """, + """ + public class Test { + Test() { + // ctor body + } + } + """ + ) + ); + } + + @Test + void removeUnusedVarargs() { + rewriteRun( + java( + """ + public class Test { + void m(String... args) { + System.out.println("no args used"); + } + } + """, + """ + public class Test { + void m() { + System.out.println("no args used"); + } + } + """ + ) + ); + } + + @Test + void preserveAnnotatedParamButRemoveOthers() { + rewriteRun( + java( + """ + public class Test { + void method(@Deprecated String keep, int removeMe) { + System.out.println(keep); + } + } + """, + """ + public class Test { + void method(@Deprecated String keep) { + System.out.println(keep); + } + } + """ + ) + ); + } + + @Test + void doNotRemoveNativeMethodParam() { + rewriteRun( + java( + """ + public class Test { + native void nativeCall(int mustStay); + } + """ + ) + ); + } + + @Test + void interfaceMethodUnchanged() { + rewriteRun( + java( + """ + interface I { + void foo(String param); + } + """ + ) + ); + } + + @Test + void skipWronglyDueToSignatureCollision() { + rewriteRun( + java( + """ + class Base { + void foo(int a, String b) {} + } + class Derived extends Base { + @Override + void foo(int a, String b) {} + void foo(String a, int b) { + // no use of a or b + } + } + """, + """ + class Base { + void foo(int a, String b) {} + } + class Derived extends Base { + @Override + void foo(int a, String b) {} + void foo() { + // no use of a or b + } + } + """ + ) + ); + } + + @Test + void nestedInheritanceOverrideChain() { + rewriteRun( + java( + """ + class Lone { void solo(String x) {} } + + class Grandparent { void greet(String msg) {} } + class Parent extends Grandparent { } + class Child extends Parent { + @Override + void greet(String msg) { + // required override + } + } + """, + """ + class Lone { void solo() {} } + + class Grandparent { void greet(String msg) {} } + class Parent extends Grandparent { } + class Child extends Parent { + @Override + void greet(String msg) { + // required override + } + } + """ + ) + ); + } + + @Test + void avoidDirectConflict() { + rewriteRun( + java( + """ + public class Test { + void method() { + System.out.println("Hello"); + } + void method(String unused) { + System.out.println("Hello"); + } + } + """ + ) + ); + } + + @Test + void avoidInheritedConflict() { + rewriteRun( + java( + """ + public class A { + String method() { + return "A String"; + } + } + """ + ), + java( + """ + public class B extends A { + Long method(String unused) { + return 42L; + } + } + """ + ) + ); + } + + @Test + void cascadeRemoveUnusedArguments() { + rewriteRun( + java( + """ + public class Test { + void method1(String unused) { + method2(unused); + } + void method2(String unused) { + method3(unused); + } + void method3(String unused) { + System.out.println("Hello"); + } + } + """, + """ + public class Test { + void method1() { + method2(); + } + void method2() { + method3(); + } + void method3() { + System.out.println("Hello"); + } + } + """ + ) + ); + } +}