Skip to content

Commit ceb4af5

Browse files
Extend Use{List,Set,Map}Of to recognise prose-statement chains (#1145)
* Extend Use{List,Set,Map}Of to recognise prose-statement chains UseListOf, UseSetOf, and UseMapOf previously matched only the anonymous-class idiom: List<String> l = new ArrayList<>() {{ add("a"); add("b"); }}; Extends each recipe to also recognise the more common prose-statement shape, where a no-arg constructor declaration is followed by a chain of add(..) or put(..) calls on the declared variable: List<String> l = new ArrayList<>(); l.add("a"); l.add("b"); These are collapsed into the constructor with the immutable factory: List<String> l = new ArrayList<>(List.of("a", "b")); Output is always wrapped in the mutable ArrayList/HashSet/HashMap so downstream mutations (.sort(), .add(), etc.) remain valid. Recognised shapes per recipe: - UseListOf: target.add(arg) chains; output uses List.of(..). - UseSetOf: target.add(arg) chains; output uses Set.of(..). - UseMapOf: target.put(k, v) chains; output uses Map.of(k, v, ..) up to 10 pairs, Map.ofEntries(Map.entry(k, v), ..) beyond. Bail conditions (all three): raw LHS, non-no-arg constructor (e.g. capacity hint), intervening non-recognised statement, an argument that references the target variable (depends on step-by-step mutation), null literal argument. Threshold: at least 2 add/put statements; a single call is not worth the rewrite churn. Implementation: a visitBlock pre-pass scans for prose patterns and records (initializer UUID -> [args]) on a cursor message; the existing visitNewClass override consults the map and applies the JavaTemplate at the correct cursor depth. After super.visitBlock, the post-pass filters absorbed add/put statements from the block. Regenerate recipes.csv to reflect the new descriptions. * Simplify Use{List,Set,Map}Of prose helpers with ListUtils.filter and reduce * Preserve comments and one-per-line formatting in UseMapOf prose chains * Extend prose comment + one-per-line formatting to UseListOf and UseSetOf --------- Co-authored-by: Tim te Beek <tim@moderne.io>
1 parent 8ec6841 commit ceb4af5

7 files changed

Lines changed: 1245 additions & 12 deletions

File tree

src/main/java/org/openrewrite/java/migrate/util/UseListOf.java

Lines changed: 212 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import org.openrewrite.Preconditions;
2121
import org.openrewrite.Recipe;
2222
import org.openrewrite.TreeVisitor;
23+
import org.openrewrite.internal.ListUtils;
24+
import org.openrewrite.java.JavaIsoVisitor;
2325
import org.openrewrite.java.JavaTemplate;
2426
import org.openrewrite.java.JavaVisitor;
2527
import org.openrewrite.java.MethodMatcher;
@@ -30,19 +32,32 @@
3032
import org.openrewrite.java.tree.Statement;
3133

3234
import java.util.ArrayList;
35+
import java.util.Collections;
36+
import java.util.HashMap;
37+
import java.util.HashSet;
3338
import java.util.List;
39+
import java.util.Map;
40+
import java.util.Set;
3441
import java.util.StringJoiner;
42+
import java.util.UUID;
43+
import java.util.concurrent.atomic.AtomicBoolean;
3544

3645
public class UseListOf extends Recipe {
3746
private static final MethodMatcher NEW_ARRAY_LIST = new MethodMatcher("java.util.ArrayList <constructor>()", true);
3847
private static final MethodMatcher LIST_ADD = new MethodMatcher("java.util.List add(..)", true);
3948

49+
private static final String PROSE_REWRITES_KEY = "use-list-of.prose-rewrites";
50+
4051
@Getter
4152
final String displayName = "Prefer `List.of(..)`";
4253

4354
@Getter
44-
final String description = "Prefer `List.of(..)` instead of using `java.util.List#add(..)` in anonymous ArrayList initializers in Java 10 or higher. " +
45-
"This recipe will not modify code where the List is later mutated since `List.of` returns an immutable list.";
55+
final String description = "Prefer `List.of(..)` in Java 10 or higher. Two input shapes are recognised:\n\n" +
56+
"- Anonymous-class initialization (`new ArrayList<>() {{ add(\"a\"); add(\"b\"); }}`), " +
57+
"which is replaced wholesale with `List.of(\"a\", \"b\")` (immutable result, matching the " +
58+
"anonymous-class idiom's typical intent).\n" +
59+
"- A `new ArrayList<>()` declaration followed by a chain of `target.add(..)` statements, " +
60+
"which is collapsed to `new ArrayList<>(List.of(..))` (preserving the mutable `ArrayList`).";
4661

4762
@Override
4863
public TreeVisitor<?, ExecutionContext> getVisitor() {
@@ -54,6 +69,32 @@ public TreeVisitor<?, ExecutionContext> getVisitor() {
5469
@Override
5570
public J visitNewClass(J.NewClass newClass, ExecutionContext ctx) {
5671
J.NewClass n = (J.NewClass) super.visitNewClass(newClass, ctx);
72+
73+
// Prose-pattern: see if visitBlock (above us on the cursor) decided this
74+
// initializer should be wrapped with `new ArrayList<>(List.of(..))`.
75+
Map<UUID, List<J.MethodInvocation>> rewrites = getCursor().getNearestMessage(PROSE_REWRITES_KEY);
76+
if (rewrites != null) {
77+
List<J.MethodInvocation> adds = rewrites.get(n.getId());
78+
if (adds != null) {
79+
List<Expression> args = new ArrayList<>();
80+
StringJoiner joiner = new StringJoiner(", ", "new ArrayList<>(List.of(", "))");
81+
for (J.MethodInvocation add : adds) {
82+
args.add(add.getArguments().get(0));
83+
joiner.add("#{any()}");
84+
}
85+
maybeAddImport("java.util.List");
86+
J applied = JavaTemplate.builder(joiner.toString())
87+
.contextSensitive()
88+
.imports("java.util.ArrayList", "java.util.List")
89+
.build()
90+
.apply(updateCursor(n), n.getCoordinates().replace(), args.toArray());
91+
// Reattach each add's prefix so the elements land one-per-line and any
92+
// leading comments survive, then autoformat to nest the indentation.
93+
return autoFormat(reattachElementPrefixes(applied, adds), ctx);
94+
}
95+
}
96+
97+
// Anonymous-class form (original UseListOf logic, unchanged).
5798
J.Block body = n.getBody();
5899
if (NEW_ARRAY_LIST.matches(n) && body != null && body.getStatements().size() == 1) {
59100
Statement statement = body.getStatements().get(0);
@@ -65,7 +106,6 @@ public J visitNewClass(J.NewClass newClass, ExecutionContext ctx) {
65106
return n;
66107
}
67108
J.MethodInvocation add = (J.MethodInvocation) stat;
68-
// List.add() takes only one argument
69109
if (add.getArguments().size() != 1) {
70110
return n;
71111
}
@@ -85,7 +125,175 @@ public J visitNewClass(J.NewClass newClass, ExecutionContext ctx) {
85125

86126
return n;
87127
}
128+
129+
/**
130+
* Re-applies the absorbed add statements' prefixes to the generated
131+
* {@code new ArrayList<>(List.of(..))} so each element keeps its own line and any
132+
* leading comments. {@code adds} holds one invocation per element, in order.
133+
*/
134+
private J reattachElementPrefixes(J applied, List<J.MethodInvocation> adds) {
135+
if (!(applied instanceof J.NewClass)) {
136+
return applied;
137+
}
138+
J.NewClass nc = (J.NewClass) applied;
139+
if (nc.getArguments().size() != 1 || !(nc.getArguments().get(0) instanceof J.MethodInvocation)) {
140+
return applied;
141+
}
142+
J.MethodInvocation listCall = (J.MethodInvocation) nc.getArguments().get(0);
143+
List<Expression> listArgs = listCall.getArguments();
144+
List<Expression> withPrefixes = new ArrayList<>(listArgs.size());
145+
for (int i = 0; i < listArgs.size(); i++) {
146+
withPrefixes.add(listArgs.get(i).withPrefix(adds.get(i).getPrefix()));
147+
}
148+
return nc.withArguments(Collections.singletonList(listCall.withArguments(withPrefixes)));
149+
}
150+
151+
@Override
152+
public J visitBlock(J.Block block, ExecutionContext ctx) {
153+
// Pre-pass: scan the ORIGINAL block to identify which initializers to
154+
// rewrite and which `add(..)` statements to absorb. UUIDs are stable
155+
// through super.visitBlock unless a child visitor rebuilds the node,
156+
// and nothing else in this recipe touches the targeted initializers
157+
// before visitNewClass fires.
158+
Map<UUID, List<J.MethodInvocation>> rewrites = new HashMap<>();
159+
Set<UUID> absorbedAddIds = new HashSet<>();
160+
identifyProseRewrites(block, rewrites, absorbedAddIds);
161+
162+
if (!rewrites.isEmpty()) {
163+
getCursor().putMessage(PROSE_REWRITES_KEY, rewrites);
164+
}
165+
166+
J.Block b = (J.Block) super.visitBlock(block, ctx);
167+
168+
// Post-pass: drop the now-absorbed `add(..)` statements from the block.
169+
return b.withStatements(ListUtils.filter(b.getStatements(), s -> !absorbedAddIds.contains(s.getId())));
170+
}
171+
172+
/**
173+
* Walk the block's statements looking for:
174+
* <pre>
175+
* List&lt;T&gt; name = new ArrayList&lt;&gt;();
176+
* name.add(x1);
177+
* name.add(x2);
178+
* ...
179+
* </pre>
180+
* For each such sequence with at least two adds, record
181+
* (initializer UUID, the absorbed {@code add(..)} invocations in order) in
182+
* {@code rewrites} and the absorbed add statement UUIDs in {@code absorbedAddIds}.
183+
*/
184+
private void identifyProseRewrites(
185+
J.Block block,
186+
Map<UUID, List<J.MethodInvocation>> rewrites,
187+
Set<UUID> absorbedAddIds) {
188+
List<Statement> stmts = block.getStatements();
189+
int i = 0;
190+
while (i < stmts.size()) {
191+
Statement stmt = stmts.get(i);
192+
if (!(stmt instanceof J.VariableDeclarations)) {
193+
i++;
194+
continue;
195+
}
196+
J.VariableDeclarations decl = (J.VariableDeclarations) stmt;
197+
String targetName = matchingTargetName(decl);
198+
if (targetName == null) {
199+
i++;
200+
continue;
201+
}
202+
J.NewClass initializer = (J.NewClass) decl.getVariables().get(0).getInitializer();
203+
// (matchingTargetName already verified the initializer is a J.NewClass)
204+
205+
List<J.MethodInvocation> adds = new ArrayList<>();
206+
List<UUID> absorbedHere = new ArrayList<>();
207+
int j = i + 1;
208+
while (j < stmts.size()) {
209+
Statement next = stmts.get(j);
210+
Expression arg = matchAddCallOn(next, targetName);
211+
if (arg == null || expressionReferences(arg, targetName)) {
212+
break;
213+
}
214+
adds.add((J.MethodInvocation) next);
215+
absorbedHere.add(next.getId());
216+
j++;
217+
}
218+
if (adds.size() >= 2 && initializer != null) {
219+
rewrites.put(initializer.getId(), adds);
220+
absorbedAddIds.addAll(absorbedHere);
221+
i = j;
222+
} else {
223+
i++;
224+
}
225+
}
226+
}
227+
228+
/**
229+
* Returns the variable name if {@code decl} is a single-variable, parameterized
230+
* {@code List<T>} declaration whose initializer is a no-arg {@code new ArrayList<>()}
231+
* with no anonymous-class body. Returns {@code null} otherwise.
232+
*/
233+
private String matchingTargetName(J.VariableDeclarations decl) {
234+
if (decl.getVariables().size() != 1) {
235+
return null;
236+
}
237+
// Require parameterized LHS; for raw `List` we'd be guessing at a type argument.
238+
if (!(decl.getTypeExpression() instanceof J.ParameterizedType)) {
239+
return null;
240+
}
241+
J.VariableDeclarations.NamedVariable nv = decl.getVariables().get(0);
242+
if (!(nv.getInitializer() instanceof J.NewClass)) {
243+
return null;
244+
}
245+
J.NewClass nc = (J.NewClass) nv.getInitializer();
246+
if (!NEW_ARRAY_LIST.matches(nc)) {
247+
return null;
248+
}
249+
// A body would put us in the anonymous-class case handled by visitNewClass directly.
250+
if (nc.getBody() != null) {
251+
return null;
252+
}
253+
return nv.getSimpleName();
254+
}
255+
256+
/**
257+
* If {@code stmt} is {@code targetName.add(arg)} matching {@link #LIST_ADD},
258+
* returns the single argument expression; otherwise {@code null}. Also returns
259+
* {@code null} when the argument is the {@code null} literal, since
260+
* {@code List.of(..)} rejects nulls.
261+
*/
262+
private Expression matchAddCallOn(Statement stmt, String targetName) {
263+
if (!(stmt instanceof J.MethodInvocation)) {
264+
return null;
265+
}
266+
J.MethodInvocation mi = (J.MethodInvocation) stmt;
267+
if (!LIST_ADD.matches(mi)) {
268+
return null;
269+
}
270+
if (mi.getArguments().size() != 1) {
271+
return null;
272+
}
273+
if (!(mi.getSelect() instanceof J.Identifier)) {
274+
return null;
275+
}
276+
if (!targetName.equals(((J.Identifier) mi.getSelect()).getSimpleName())) {
277+
return null;
278+
}
279+
Expression arg = mi.getArguments().get(0);
280+
if (arg instanceof J.Literal && ((J.Literal) arg).getValue() == null) {
281+
return null;
282+
}
283+
return arg;
284+
}
285+
286+
private boolean expressionReferences(Expression expr, String name) {
287+
return new JavaIsoVisitor<AtomicBoolean>() {
288+
@Override
289+
public J.Identifier visitIdentifier(J.Identifier id, AtomicBoolean f) {
290+
if (name.equals(id.getSimpleName())) {
291+
f.set(true);
292+
}
293+
return id;
294+
}
295+
}.reduce(expr, new AtomicBoolean(false)).get();
296+
}
88297
});
89298
}
90-
91299
}

0 commit comments

Comments
 (0)