Skip to content

Commit 3ada955

Browse files
authored
Add OneDependencyDeclarationPerStatement recipe to the Gradle 9 chain (#7907)
* Add disabled test for multi-coordinate `implementation` calls `GradleDependency.Matcher#test` only inspects `arguments.get(0)`, so a dependency declared as a later coordinate in a single configuration call (e.g. `implementation 'a:b:1.0', 'c:d:' + version`) is invisible to the trait and its `ext` version variable is never bumped. See moderneinc/customer-requests#2464. * Add `OneDependencyDeclarationPerStatement` recipe (Gradle 9 chain) Splits multi-coordinate Groovy DSL configuration calls into one call per coordinate (e.g. `implementation 'a:b:1.0', 'c:d:2.0'` becomes two `implementation` statements). The form is Groovy-DSL-only — Kotlin DSL forbids it — and Gradle's best practices recommend one dependency per statement. Documented as a cleanup pass to run before dependency-aware recipes (`UpgradeDependencyVersion`, `ChangeDependency`, `RemoveDependency`): those recipes use the `GradleDependency` trait, which only inspects the first argument of a configuration call, so coordinates in later positions are invisible until this recipe reshapes the source. Wired into the `MigrateToGradle9` chain. Preserves any statement-level comment on the first new statement only — not duplicated onto later splits or the trailing implicit-return. The previously committed `@Disabled` marker test for the underlying trait gap is removed; the deeper trait refactor remains tracked in moderneinc/customer-requests#2464.
1 parent 1718a36 commit 3ada955

4 files changed

Lines changed: 458 additions & 2 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.gradle.gradle9;
17+
18+
import lombok.EqualsAndHashCode;
19+
import lombok.Value;
20+
import org.jspecify.annotations.Nullable;
21+
import org.openrewrite.Cursor;
22+
import org.openrewrite.ExecutionContext;
23+
import org.openrewrite.Preconditions;
24+
import org.openrewrite.Recipe;
25+
import org.openrewrite.Tree;
26+
import org.openrewrite.TreeVisitor;
27+
import org.openrewrite.gradle.IsBuildGradle;
28+
import org.openrewrite.groovy.tree.G;
29+
import org.openrewrite.internal.ListUtils;
30+
import org.openrewrite.java.JavaIsoVisitor;
31+
import org.openrewrite.java.tree.Expression;
32+
import org.openrewrite.java.tree.J;
33+
import org.openrewrite.java.tree.JavaSourceFile;
34+
import org.openrewrite.java.tree.Space;
35+
import org.openrewrite.java.tree.Statement;
36+
37+
import java.util.ArrayList;
38+
import java.util.List;
39+
40+
import static java.util.Collections.emptyList;
41+
import static java.util.Collections.singletonList;
42+
43+
@Value
44+
@EqualsAndHashCode(callSuper = false)
45+
public class OneDependencyDeclarationPerStatement extends Recipe {
46+
47+
@Override
48+
public String getDisplayName() {
49+
return "Use one dependency declaration per statement";
50+
}
51+
52+
@Override
53+
public String getDescription() {
54+
return "The Gradle Groovy DSL accepts multiple coordinates in a single configuration call " +
55+
"(e.g. `implementation 'a:b:1.0', 'c:d:2.0'`), but the Kotlin DSL does not. " +
56+
"Gradle's best practices recommend declaring a single dependency per statement; " +
57+
"see the [Gradle dependency best practices](https://docs.gradle.org/current/userguide/best_practices_dependencies.html). " +
58+
"This recipe splits multi-coordinate Groovy DSL configuration calls into one call per coordinate. " +
59+
"Run this as a cleanup pass before other dependency-aware recipes (e.g. `UpgradeDependencyVersion`, " +
60+
"`ChangeDependency`, `RemoveDependency`): those recipes use the `GradleDependency` trait, which only " +
61+
"inspects the first argument of a configuration call. Coordinates in later positions are invisible " +
62+
"to them until this recipe reshapes the source into one declaration per statement.";
63+
}
64+
65+
@Override
66+
public TreeVisitor<?, ExecutionContext> getVisitor() {
67+
return Preconditions.check(new IsBuildGradle<>(), new JavaIsoVisitor<ExecutionContext>() {
68+
@Override
69+
public @Nullable J visit(@Nullable Tree tree, ExecutionContext ctx) {
70+
if (tree instanceof JavaSourceFile && !(tree instanceof G.CompilationUnit)) {
71+
// Kotlin DSL cannot express the multi-coordinate-per-call form
72+
return (J) tree;
73+
}
74+
return super.visit(tree, ctx);
75+
}
76+
77+
@Override
78+
public J.Block visitBlock(J.Block block, ExecutionContext ctx) {
79+
J.Block b = super.visitBlock(block, ctx);
80+
if (!isInsideDependenciesButNotConstraints(getCursor())) {
81+
return b;
82+
}
83+
return b.withStatements(ListUtils.flatMap(b.getStatements(), s -> {
84+
J.MethodInvocation m;
85+
boolean wrappedInReturn;
86+
if (s instanceof J.MethodInvocation) {
87+
m = (J.MethodInvocation) s;
88+
wrappedInReturn = false;
89+
} else if (s instanceof J.Return && ((J.Return) s).getExpression() instanceof J.MethodInvocation) {
90+
m = (J.MethodInvocation) ((J.Return) s).getExpression();
91+
wrappedInReturn = true;
92+
} else {
93+
return s;
94+
}
95+
if (m.getSelect() != null) {
96+
return s;
97+
}
98+
List<Expression> args = m.getArguments();
99+
if (args.size() < 2) {
100+
return s;
101+
}
102+
// A trailing closure configures a single dependency; leave it alone
103+
if (args.get(args.size() - 1) instanceof J.Lambda) {
104+
return s;
105+
}
106+
for (Expression arg : args) {
107+
if (!isCoordinateShape(arg)) {
108+
return s;
109+
}
110+
}
111+
if (isMultiComponentLiterals(args)) {
112+
return s;
113+
}
114+
115+
// The original statement's prefix may carry a comment that referred to the whole
116+
// multi-coordinate line. Keep it attached to the first split only — duplicating
117+
// it onto every new statement (including the trailing implicit-return) would be
118+
// both misleading and visually noisy.
119+
Space stmtPrefix = s.getPrefix();
120+
Space subsequentPrefix = Space.build(stmtPrefix.getLastWhitespace(), emptyList());
121+
122+
List<Statement> split = new ArrayList<>(args.size());
123+
for (int i = 0; i < args.size(); i++) {
124+
Expression coord = args.get(i).withPrefix(Space.format(" "));
125+
boolean isLast = i == args.size() - 1;
126+
if (isLast && wrappedInReturn) {
127+
J.MethodInvocation innerMi = m.withArguments(singletonList(coord));
128+
split.add(((J.Return) s).withPrefix(subsequentPrefix).withExpression(innerMi));
129+
} else {
130+
Space prefix = i == 0 ? stmtPrefix : subsequentPrefix;
131+
split.add(m.withPrefix(prefix).withArguments(singletonList(coord)));
132+
}
133+
}
134+
return split;
135+
}));
136+
}
137+
});
138+
}
139+
140+
private static boolean isInsideDependenciesButNotConstraints(Cursor cursor) {
141+
boolean insideDependencies = false;
142+
Cursor c = cursor.getParent();
143+
while (c != null) {
144+
Object value = c.getValue();
145+
if (value instanceof J.MethodInvocation) {
146+
String name = ((J.MethodInvocation) value).getSimpleName();
147+
if ("constraints".equals(name)) {
148+
return false;
149+
}
150+
if ("dependencies".equals(name)) {
151+
insideDependencies = true;
152+
}
153+
}
154+
c = c.getParent();
155+
}
156+
return insideDependencies;
157+
}
158+
159+
private static boolean isCoordinateShape(Expression arg) {
160+
if (arg instanceof J.Literal || arg instanceof J.Binary || arg instanceof G.GString) {
161+
return true;
162+
}
163+
if (arg instanceof J.MethodInvocation) {
164+
String name = ((J.MethodInvocation) arg).getSimpleName();
165+
return "platform".equals(name) || "enforcedPlatform".equals(name) || "project".equals(name);
166+
}
167+
return false;
168+
}
169+
170+
private static boolean isMultiComponentLiterals(List<Expression> args) {
171+
if (args.size() < 2 || args.size() > 4) {
172+
return false;
173+
}
174+
for (Expression arg : args) {
175+
if (!(arg instanceof J.Literal) || !(((J.Literal) arg).getValue() instanceof String)) {
176+
return false;
177+
}
178+
}
179+
String first = (String) ((J.Literal) args.get(0)).getValue();
180+
return first != null && !first.contains(":");
181+
}
182+
}

rewrite-gradle/src/main/resources/META-INF/rewrite/gradle-9.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ recipeList:
2525
addIfMissing: false
2626
# Map notation has been deprecated in 9.1+
2727
- org.openrewrite.gradle.DependencyUseStringNotation
28+
- org.openrewrite.gradle.gradle9.OneDependencyDeclarationPerStatement
2829
- org.openrewrite.gradle.gradle9.UseMainClassProperty
2930
- org.openrewrite.gradle.gradle9.UseMainClassPropertyForApplication
3031
# JavaPluginConvention was removed in Gradle 9; top-level sourceCompatibility / targetCompatibility no longer work

0 commit comments

Comments
 (0)