Skip to content

Commit d9fa7fb

Browse files
timtebeekgithub-actions[bot]knutwannhedenTim te Beekclaude
authored
Add recipes for Kotlinx coroutines and serialization, based on ReplaceWith (#6561)
* Add `ReplaceDeprecatedKotlinMethod` with `template` argument Follows the pattern we already have for `InlineMethodCalls`, but now for Kotlin. Related openrewrite/rewrite-third-party#57 * Update recipes.csv * Move and add scanner/generator for faster iterations * Improve the scanner, generator and replacement recipe * Apply suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Move filter to scanner; keep Kotlin types and use `*` for generic types * Handle extension functions and receivers * Add comments to show original expression * Apply suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Special handling for suspend and coroutines * Add recipes for Kotlinx * Polish `ReplaceDeprecatedKotlinMethod` * Also support replacements for constructors * Rename recipe * Add a test for constructor replacement * Quick renames * Expect explicit groupId passed in * Apply formatter * Polish DeprecatedMethodScanner * Collapse catch blocks * Use `<init>` Co-authored-by: Knut Wannheden <knut@moderne.io> * Exclude transitive kotlin-stdlib from kotlin-metadata-jvm (#6726) The kotlin-metadata-jvm:2.1.0 dependency pulls in kotlin-stdlib:2.1.0, which conflicts with the parser's embedded kotlin-stdlib:1.9.25 and causes existing Kotlin tests to fail with metadata version errors. Co-authored-by: Tim te Beek <tim@mac.home> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Comment out the testRuntime dependencies when not generating * Increase heap size * Update recipes.csv --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Knut Wannheden <knut@moderne.io> Co-authored-by: Tim te Beek <tim@mac.home> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ab26c72 commit d9fa7fb

File tree

13 files changed

+2192
-0
lines changed

13 files changed

+2192
-0
lines changed

rewrite-kotlin/build.gradle.kts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ dependencies {
1818
implementation(kotlin("compiler-embeddable", kotlinVersion))
1919
implementation(kotlin("stdlib", kotlinVersion))
2020

21+
testImplementation("org.jetbrains.kotlin:kotlin-metadata-jvm:2.1.0") {
22+
exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib")
23+
}
24+
2125
testImplementation("org.junit-pioneer:junit-pioneer:latest.release")
2226
testImplementation(project(":rewrite-test"))
2327
testRuntimeOnly(project(":rewrite-java-21"))
@@ -27,6 +31,19 @@ dependencies {
2731
testImplementation("com.github.ajalt.clikt:clikt:3.5.0")
2832
testImplementation("com.squareup:javapoet:1.13.0")
2933
testImplementation("com.google.testing.compile:compile-testing:0.+")
34+
35+
// Kotlin libraries for KotlinDeprecationRecipeGenerator
36+
// Pin to versions compiled with Kotlin 1.9 metadata (compatible with parser's Kotlin 1.9.25 compiler)
37+
testRuntimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
38+
testRuntimeOnly("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3")
39+
}
40+
41+
recipeDependencies {
42+
// Kotlin libraries with @Deprecated(replaceWith=ReplaceWith(...)) annotations
43+
// Use the JVM variant artifact names since Kotlin multiplatform resolves to these
44+
// Pin to versions compiled with Kotlin 1.9 metadata (compatible with parser's Kotlin 1.9.25 compiler)
45+
testParserClasspath("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.1")
46+
testParserClasspath("org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3")
3047
}
3148

3249
java {
@@ -42,3 +59,22 @@ tasks.withType<KotlinCompile>().configureEach {
4259
jvmTarget.set(if (name.contains("Test")) JvmTarget.JVM_21 else JvmTarget.JVM_1_8)
4360
}
4461
}
62+
63+
tasks.withType<Test> {
64+
maxHeapSize = "6g"
65+
}
66+
67+
68+
tasks {
69+
val generateKotlinDeprecatedReplaceWithRecipes by registering(JavaExec::class) {
70+
group = "generate"
71+
description = "Generate recipes from Kotlin `@Deprecated` annotations using `ReplaceWith`."
72+
mainClass = "org.openrewrite.kotlin.replace.KotlinDeprecatedRecipeGenerator"
73+
classpath = sourceSets.getByName("test").runtimeClasspath
74+
args(
75+
"org.jetbrains.kotlinx:kotlinx-coroutines-core",
76+
"org.jetbrains.kotlinx:kotlinx-serialization-core"
77+
)
78+
finalizedBy("licenseFormat")
79+
}
80+
}
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
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.kotlin.replace;
17+
18+
import lombok.EqualsAndHashCode;
19+
import lombok.Value;
20+
import org.jspecify.annotations.Nullable;
21+
import org.openrewrite.*;
22+
import org.openrewrite.internal.StringUtils;
23+
import org.openrewrite.java.MethodMatcher;
24+
import org.openrewrite.java.search.UsesMethod;
25+
import org.openrewrite.java.tree.Expression;
26+
import org.openrewrite.java.tree.J;
27+
import org.openrewrite.java.tree.JavaType;
28+
import org.openrewrite.java.tree.MethodCall;
29+
import org.openrewrite.kotlin.KotlinParser;
30+
import org.openrewrite.kotlin.KotlinTemplate;
31+
import org.openrewrite.kotlin.KotlinVisitor;
32+
33+
import java.util.*;
34+
import java.util.regex.Matcher;
35+
import java.util.regex.Pattern;
36+
37+
/**
38+
* Replaces deprecated Kotlin method calls based on {@code @Deprecated(replaceWith=ReplaceWith(...))} annotations.
39+
* <p>
40+
* This recipe takes a method pattern to match and a replacement expression that follows the Kotlin
41+
* {@code ReplaceWith} annotation format.
42+
*/
43+
@Incubating(since = "8.43.0")
44+
@EqualsAndHashCode(callSuper = false)
45+
@Value
46+
public class ReplaceKotlinMethod extends Recipe {
47+
48+
private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("\\b(\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\b");
49+
private static final Pattern TEMPLATE_PLACEHOLDER = Pattern.compile("#\\{([^}]+)}");
50+
private static final Collection<String> KOTLIN_KEYWORDS = new HashSet<>(Arrays.asList(
51+
"as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", "interface", "is",
52+
"null", "object", "package", "return", "super", "this", "throw", "true", "try", "typealias", "typeof",
53+
"val", "var", "when", "while"));
54+
55+
@Option(displayName = "Method pattern",
56+
description = "A method pattern that is used to find matching method invocations.",
57+
example = "arrow.core.MapKt mapOrAccumulate(kotlin.Function2)")
58+
String methodPattern;
59+
60+
@Option(displayName = "Replacement",
61+
description = "The replacement expression from `@Deprecated(replaceWith=ReplaceWith(...))`. " +
62+
"Parameter names from the original method can be used directly.",
63+
example = "mapValuesOrAccumulate(transform)")
64+
String replacement;
65+
66+
@Option(displayName = "Imports",
67+
description = "List of imports to add when the replacement is made.",
68+
required = false,
69+
example = "[\"arrow.core.Either\"]")
70+
@Nullable
71+
List<String> imports;
72+
73+
@Option(displayName = "Classpath from resources",
74+
description = "List of classpath resource names for parsing the replacement template.",
75+
required = false,
76+
example = "[\"arrow-core-2\"]")
77+
@Nullable
78+
List<String> classpathFromResources;
79+
80+
String displayName = "Replace Kotlin method";
81+
String description = "Replaces Kotlin method calls based on `@Deprecated(replaceWith=ReplaceWith(...))` annotations.";
82+
Set<String> tags = new HashSet<>(Arrays.asList("kotlin", "deprecated"));
83+
84+
@Override
85+
public TreeVisitor<?, ExecutionContext> getVisitor() {
86+
MethodMatcher matcher = new MethodMatcher(methodPattern, true);
87+
return Preconditions.check(new UsesMethod<>(methodPattern, true), new KotlinVisitor<ExecutionContext>() {
88+
@Override
89+
public J visitNewClass(J.NewClass newClass, ExecutionContext ctx) {
90+
MethodCall mc = (MethodCall) super.visitNewClass(newClass, ctx);
91+
if (matcher.matches(mc)) {
92+
return replaceMethod(mc, ctx);
93+
}
94+
return mc;
95+
}
96+
97+
@Override
98+
public J visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) {
99+
MethodCall mc = (MethodCall) super.visitMethodInvocation(method, ctx);
100+
if (matcher.matches(mc)) {
101+
return replaceMethod(mc, ctx);
102+
}
103+
return mc;
104+
}
105+
106+
private J replaceMethod(MethodCall method, ExecutionContext ctx) {
107+
JavaType.Method methodType = method.getMethodType();
108+
if (methodType == null) {
109+
return method;
110+
}
111+
112+
// Add imports if specified
113+
if (imports != null) {
114+
for (String imp : imports) {
115+
int lastDot = imp.lastIndexOf('.');
116+
if (lastDot > 0) {
117+
maybeAddImport(imp.substring(0, lastDot), imp.substring(lastDot + 1), false);
118+
}
119+
}
120+
}
121+
122+
// Build and apply the template
123+
TemplateConversion conversion = convertToTemplate(method, methodType);
124+
KotlinTemplate.Builder templateBuilder = KotlinTemplate.builder(conversion.templateString);
125+
if (imports != null) {
126+
templateBuilder.imports(imports.toArray(new String[0]));
127+
}
128+
if (classpathFromResources != null && !classpathFromResources.isEmpty()) {
129+
templateBuilder.parser(KotlinParser.builder()
130+
.classpathFromResources(ctx, classpathFromResources.toArray(new String[0])));
131+
}
132+
133+
J result = templateBuilder.build()
134+
.apply(getCursor(), method.getCoordinates().replace(), conversion.parameters.toArray());
135+
136+
return result.withPrefix(method.getPrefix());
137+
}
138+
139+
private TemplateConversion convertToTemplate(MethodCall method, JavaType.Method methodType) {
140+
String templateString = replacement;
141+
List<Object> parameters = new ArrayList<>();
142+
Map<String, Expression> parameterLookup = new HashMap<>();
143+
144+
// Map 'this' to the select expression (receiver)
145+
Expression select = method instanceof J.MethodInvocation ? ((J.MethodInvocation) method).getSelect() : null;
146+
if (select != null) {
147+
parameterLookup.put("this", select);
148+
}
149+
150+
// Map parameter names to their argument expressions
151+
List<String> parameterNames = methodType.getParameterNames();
152+
List<Expression> arguments = method.getArguments();
153+
// For extension functions, the method type includes the receiver as the first
154+
// parameter, but the arguments don't include it (it's the select/receiver).
155+
// Detect this by checking if there are more parameter names than arguments.
156+
int paramOffset = parameterNames.size() > arguments.size() && select != null ?
157+
parameterNames.size() - arguments.size() : 0;
158+
for (int i = 0; i < arguments.size() && i + paramOffset < parameterNames.size(); i++) {
159+
parameterLookup.put(parameterNames.get(i + paramOffset), arguments.get(i));
160+
}
161+
162+
// Also support positional references like p0, p1, etc.
163+
for (int i = 0; i < arguments.size(); i++) {
164+
parameterLookup.put("p" + i, arguments.get(i));
165+
}
166+
167+
// Determine if this is an instance method call that needs a receiver.
168+
// The replacement needs a receiver prepended if:
169+
// - The original call has a receiver (select)
170+
// - The replacement doesn't explicitly use 'this' (handled separately)
171+
// - The replacement isn't a static/constructor call (starts with uppercase)
172+
boolean needsReceiver = select != null &&
173+
!replacement.matches(".*\\bthis\\b.*") &&
174+
!isLikelyStaticReplacement(replacement);
175+
176+
// Convert the replacement expression to a template
177+
// Replace 'this.' prefix with receiver placeholder
178+
if (templateString.startsWith("this.")) {
179+
if (select != null) {
180+
templateString = "#{any()}." + templateString.substring(5);
181+
parameters.add(select);
182+
} else {
183+
// No select, just remove 'this.'
184+
templateString = templateString.substring(5);
185+
}
186+
} else if (needsReceiver) {
187+
// Prepend the receiver for instance method calls
188+
templateString = "#{any()}." + templateString;
189+
parameters.add(select);
190+
}
191+
192+
// Now replace 'this' references that appear elsewhere
193+
if (templateString.contains("this") && select != null) {
194+
int thisCount = StringUtils.countOccurrences(templateString, "this");
195+
templateString = templateString.replaceAll("\\bthis\\b", "#{any()}");
196+
for (int i = 0; i < thisCount; i++) {
197+
parameters.add(select);
198+
}
199+
}
200+
201+
// Find all identifiers in the template and replace with placeholders
202+
Set<String> processedParams = new HashSet<>();
203+
StringBuilder result = new StringBuilder();
204+
Matcher identifierMatcher = IDENTIFIER_PATTERN.matcher(templateString);
205+
int lastEnd = 0;
206+
207+
while (identifierMatcher.find()) {
208+
String identifier = identifierMatcher.group(1);
209+
210+
// Skip if already a placeholder or a keyword
211+
if ("any".equals(identifier) ||
212+
KOTLIN_KEYWORDS.contains(identifier) ||
213+
processedParams.contains(identifier)) {
214+
continue;
215+
}
216+
217+
Expression expr = parameterLookup.get(identifier);
218+
if (expr != null && !processedParams.contains(identifier)) {
219+
// This identifier is a parameter reference
220+
result.append(templateString, lastEnd, identifierMatcher.start());
221+
result.append("#{any()}");
222+
parameters.add(expr);
223+
processedParams.add(identifier);
224+
lastEnd = identifierMatcher.end();
225+
}
226+
}
227+
result.append(templateString.substring(lastEnd));
228+
templateString = result.toString();
229+
230+
return new TemplateConversion(templateString, parameters);
231+
}
232+
233+
private boolean isLikelyStaticReplacement(String replacement) {
234+
// Check if the replacement looks like a constructor or static call
235+
// e.g., "EmptySerializersModule()" or "SomeClass.method()"
236+
return !replacement.isEmpty() && Character.isUpperCase(replacement.charAt(0));
237+
}
238+
});
239+
}
240+
241+
@Value
242+
private static class TemplateConversion {
243+
String templateString;
244+
List<Object> parameters;
245+
}
246+
}

0 commit comments

Comments
 (0)