Skip to content

Commit 4d98294

Browse files
authored
Align Kotlin jvmTarget with the Java version during UpgradeJavaVersion (#1120)
1 parent d4df178 commit 4d98294

5 files changed

Lines changed: 668 additions & 16 deletions

File tree

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ dependencies {
5757

5858
implementation(platform("org.openrewrite:rewrite-bom:${rewriteVersion}"))
5959
implementation("org.openrewrite:rewrite-java")
60+
implementation("org.openrewrite:rewrite-kotlin")
6061
implementation("org.openrewrite:rewrite-properties")
6162
implementation("org.openrewrite:rewrite-xml")
6263
implementation("org.openrewrite:rewrite-json")
@@ -81,7 +82,6 @@ dependencies {
8182
testImplementation("org.junit-pioneer:junit-pioneer:2.0.0")
8283

8384
testImplementation("org.openrewrite:rewrite-test")
84-
testImplementation("org.openrewrite:rewrite-kotlin")
8585
testImplementation("org.openrewrite.gradle.tooling:model:$rewriteVersion")
8686

8787
testImplementation("org.assertj:assertj-core:latest.release")

src/main/java/org/openrewrite/java/migrate/UpgradeJavaVersion.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ public List<Recipe> getRecipeList() {
5555
new org.openrewrite.jenkins.UpgradeJavaVersion(version, null),
5656
new UpdateJavaCompatibility(version, null, null, false, null),
5757
new UpdateSdkMan(String.valueOf(version), null),
58-
new UpgradeDockerImageVersion(version)
58+
new UpgradeDockerImageVersion(version),
59+
new UpgradeKotlinJvmTargetVersion(version)
5960
);
6061
}
6162

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (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://docs.moderne.io/licensing/moderne-source-available-license
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.java.migrate;
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.Option;
24+
import org.openrewrite.Recipe;
25+
import org.openrewrite.SourceFile;
26+
import org.openrewrite.Tree;
27+
import org.openrewrite.TreeVisitor;
28+
import org.openrewrite.groovy.GroovyIsoVisitor;
29+
import org.openrewrite.java.MethodMatcher;
30+
import org.openrewrite.java.tree.Expression;
31+
import org.openrewrite.java.tree.J;
32+
import org.openrewrite.kotlin.KotlinIsoVisitor;
33+
import org.openrewrite.maven.MavenIsoVisitor;
34+
import org.openrewrite.xml.ChangeTagValueVisitor;
35+
import org.openrewrite.xml.tree.Xml;
36+
37+
import java.util.Collections;
38+
import java.util.Iterator;
39+
40+
@EqualsAndHashCode(callSuper = false)
41+
@Value
42+
public class UpgradeKotlinJvmTargetVersion extends Recipe {
43+
44+
// Gradle build scripts usually lack type attribution, so match by name + arity (matchUnknownTypes=true at call sites).
45+
private static final MethodMatcher KOTLIN_OPTIONS = new MethodMatcher("* kotlinOptions(..)");
46+
private static final MethodMatcher COMPILER_OPTIONS = new MethodMatcher("* compilerOptions(..)");
47+
private static final MethodMatcher JVM_TARGET_SET = new MethodMatcher("* set(..)");
48+
49+
@Option(displayName = "Java version",
50+
description = "The Java version to align Kotlin's `jvmTarget` with.",
51+
example = "21")
52+
Integer version;
53+
54+
String displayName = "Upgrade Kotlin `jvmTarget` to match the Java version";
55+
56+
String description = "Align the Kotlin `jvmTarget` with the project's Java version so the Kotlin compiler emits " +
57+
"bytecode at the same level as `javac`. Covers `kotlin-maven-plugin` `<jvmTarget>` configuration and the " +
58+
"Gradle `kotlinOptions { jvmTarget = ... }` / `compilerOptions { jvmTarget = ... }` blocks (Groovy and " +
59+
"Kotlin DSL). Will not downgrade if the existing Kotlin target is higher than the requested version.";
60+
61+
@Override
62+
public TreeVisitor<?, ExecutionContext> getVisitor() {
63+
String newVersion = version.toString();
64+
int target = version;
65+
// Each language visitor already accepts only its own source kind (the Maven visitor even narrows to actual
66+
// Maven poms), so route on `isAcceptable` rather than re-checking compilation-unit types here.
67+
TreeVisitor<?, ExecutionContext> maven = mavenVisitor(target);
68+
TreeVisitor<?, ExecutionContext> groovy = gradleGroovyVisitor(newVersion, target);
69+
TreeVisitor<?, ExecutionContext> kotlin = gradleKotlinDslVisitor(newVersion, target);
70+
return new TreeVisitor<Tree, ExecutionContext>() {
71+
@Override
72+
public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
73+
if (tree instanceof SourceFile) {
74+
SourceFile sourceFile = (SourceFile) tree;
75+
if (maven.isAcceptable(sourceFile, ctx)) {
76+
return maven.visit(tree, ctx);
77+
}
78+
if (groovy.isAcceptable(sourceFile, ctx)) {
79+
return groovy.visit(tree, ctx);
80+
}
81+
if (kotlin.isAcceptable(sourceFile, ctx)) {
82+
return kotlin.visit(tree, ctx);
83+
}
84+
}
85+
return tree;
86+
}
87+
};
88+
}
89+
90+
private static MavenIsoVisitor<ExecutionContext> mavenVisitor(int target) {
91+
return new MavenIsoVisitor<ExecutionContext>() {
92+
@Override
93+
public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) {
94+
Xml.Tag t = super.visitTag(tag, ctx);
95+
if (!isPluginTag("org.jetbrains.kotlin", "kotlin-maven-plugin")) {
96+
return t;
97+
}
98+
Xml.Tag jvmTarget = t.getChild("configuration")
99+
.flatMap(config -> config.getChild("jvmTarget"))
100+
.orElse(null);
101+
if (jvmTarget == null) {
102+
return t;
103+
}
104+
Integer current = parseJvmTarget(jvmTarget.getValue().orElse(null));
105+
if (current == null || current >= target) {
106+
return t;
107+
}
108+
return (Xml.Tag) new ChangeTagValueVisitor<>(jvmTarget, Integer.toString(target)).visitNonNull(t, ctx);
109+
}
110+
};
111+
}
112+
113+
private static GroovyIsoVisitor<ExecutionContext> gradleGroovyVisitor(String newVersion, int target) {
114+
return new GroovyIsoVisitor<ExecutionContext>() {
115+
@Override
116+
public J.Assignment visitAssignment(J.Assignment assignment, ExecutionContext ctx) {
117+
J.Assignment a = super.visitAssignment(assignment, ctx);
118+
// A string `jvmTarget` is only valid in the legacy `kotlinOptions` block; bumping one inside
119+
// `compilerOptions` would leave a non-compiling String assignment to a `Property<JvmTarget>`.
120+
if (!"kotlinOptions".equals(enclosingKotlinCompilerBlock(getCursor()))) {
121+
return a;
122+
}
123+
Expression variable = a.getVariable();
124+
if (!(variable instanceof J.Identifier)
125+
|| !"jvmTarget".equals(((J.Identifier) variable).getSimpleName())) {
126+
return a;
127+
}
128+
Expression rhs = a.getAssignment();
129+
if (!(rhs instanceof J.Literal)) {
130+
return a;
131+
}
132+
J.Literal literal = (J.Literal) rhs;
133+
Object value = literal.getValue();
134+
if (!(value instanceof String)) {
135+
return a;
136+
}
137+
Integer current = parseJvmTarget((String) value);
138+
if (current == null || current >= target) {
139+
return a;
140+
}
141+
// Preserve the original quote style (Groovy allows both ' and ").
142+
String original = literal.getValueSource();
143+
char quote = original != null && !original.isEmpty() ? original.charAt(0) : '\'';
144+
return a.withAssignment(literal
145+
.withValue(newVersion)
146+
.withValueSource(quote + newVersion + quote));
147+
}
148+
};
149+
}
150+
151+
private static KotlinIsoVisitor<ExecutionContext> gradleKotlinDslVisitor(String newVersion, int target) {
152+
return new KotlinIsoVisitor<ExecutionContext>() {
153+
@Override
154+
public J.Assignment visitAssignment(J.Assignment assignment, ExecutionContext ctx) {
155+
J.Assignment a = super.visitAssignment(assignment, ctx);
156+
if (!isInsideKotlinCompilerBlock(getCursor())) {
157+
return a;
158+
}
159+
Expression variable = a.getVariable();
160+
if (!(variable instanceof J.Identifier)
161+
|| !"jvmTarget".equals(((J.Identifier) variable).getSimpleName())) {
162+
return a;
163+
}
164+
Expression rhs = a.getAssignment();
165+
if (rhs instanceof J.Literal) {
166+
// A string-literal `jvmTarget` is only valid in the legacy `kotlinOptions` block. Inside
167+
// `compilerOptions` it is `Property<JvmTarget>` and a String assignment does not compile, so
168+
// leave such (already-invalid) input untouched — `UseJvmTargetProviderSyntax` converts it.
169+
if ("kotlinOptions".equals(enclosingKotlinCompilerBlock(getCursor()))) {
170+
return bumpLiteralAssignment(a, (J.Literal) rhs, newVersion, target);
171+
}
172+
return a;
173+
}
174+
if (rhs instanceof J.FieldAccess) {
175+
J.FieldAccess fa = (J.FieldAccess) rhs;
176+
J.FieldAccess bumped = bumpedJvmTargetFieldAccess(fa, newVersion, target);
177+
return bumped == fa ? a : a.withAssignment(bumped);
178+
}
179+
return a;
180+
}
181+
182+
@Override
183+
public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) {
184+
J.MethodInvocation mi = super.visitMethodInvocation(method, ctx);
185+
// Match the Provider-style setter `jvmTarget.set(JvmTarget.JVM_X)`.
186+
if (!JVM_TARGET_SET.matches(mi, true) || !(mi.getSelect() instanceof J.Identifier)) {
187+
return mi;
188+
}
189+
if (!"jvmTarget".equals(((J.Identifier) mi.getSelect()).getSimpleName())) {
190+
return mi;
191+
}
192+
if (!isInsideKotlinCompilerBlock(getCursor())) {
193+
return mi;
194+
}
195+
if (mi.getArguments().size() != 1) {
196+
return mi;
197+
}
198+
Expression arg = mi.getArguments().get(0);
199+
if (arg instanceof J.FieldAccess) {
200+
J.FieldAccess fa = (J.FieldAccess) arg;
201+
J.FieldAccess bumped = bumpedJvmTargetFieldAccess(fa, newVersion, target);
202+
if (bumped != fa) {
203+
return mi.withArguments(Collections.singletonList(bumped));
204+
}
205+
}
206+
return mi;
207+
}
208+
};
209+
}
210+
211+
private static boolean isInsideKotlinCompilerBlock(Cursor cursor) {
212+
return enclosingKotlinCompilerBlock(cursor) != null;
213+
}
214+
215+
/**
216+
* The simple name of the nearest enclosing Kotlin compile-config DSL block (`kotlinOptions` or
217+
* `compilerOptions`), or {@code null} if there is none. Gradle build scripts lack type attribution, so the
218+
* enclosing block is matched by name (matchUnknownTypes=true), the same approach
219+
* `org.openrewrite.gradle.UpdateJavaCompatibility` uses for `java { }` / `sourceCompatibility`.
220+
* <p>
221+
* The distinction matters: `jvmTarget` is a `String` in the legacy `kotlinOptions` DSL but a
222+
* `Property<JvmTarget>` in `compilerOptions`, so a string-literal value is only valid under `kotlinOptions`.
223+
*/
224+
private static @Nullable String enclosingKotlinCompilerBlock(Cursor cursor) {
225+
Iterator<Object> path = cursor.getPath();
226+
while (path.hasNext()) {
227+
Object o = path.next();
228+
if (o instanceof J.MethodInvocation) {
229+
J.MethodInvocation mi = (J.MethodInvocation) o;
230+
if (KOTLIN_OPTIONS.matches(mi, true)) {
231+
return "kotlinOptions";
232+
}
233+
if (COMPILER_OPTIONS.matches(mi, true)) {
234+
return "compilerOptions";
235+
}
236+
}
237+
}
238+
return null;
239+
}
240+
241+
private static J.Assignment bumpLiteralAssignment(J.Assignment a, J.Literal literal, String newVersion, int target) {
242+
Object value = literal.getValue();
243+
if (!(value instanceof String)) {
244+
return a;
245+
}
246+
Integer current = parseJvmTarget((String) value);
247+
if (current == null || current >= target) {
248+
return a;
249+
}
250+
return a.withAssignment(literal
251+
.withValue(newVersion)
252+
.withValueSource("\"" + newVersion + "\""));
253+
}
254+
255+
/**
256+
* If the FieldAccess is a {@code JvmTarget.JVM_X} reference with X less than {@code target},
257+
* return a new FieldAccess with the bumped enum name. Returns the original instance unchanged otherwise.
258+
*/
259+
private static J.FieldAccess bumpedJvmTargetFieldAccess(J.FieldAccess fa, String newVersion, int target) {
260+
if (!(fa.getTarget() instanceof J.Identifier)
261+
|| !"JvmTarget".equals(((J.Identifier) fa.getTarget()).getSimpleName())) {
262+
return fa;
263+
}
264+
String enumName = fa.getSimpleName();
265+
if (!enumName.startsWith("JVM_")) {
266+
return fa;
267+
}
268+
// Enum constants spell the version with underscores ("JVM_1_8", "JVM_11"); normalize to the dotted/plain
269+
// form parseJvmTarget understands ("1.8", "11").
270+
Integer current = parseJvmTarget(enumName.substring("JVM_".length()).replace('_', '.'));
271+
if (current == null || current >= target) {
272+
return fa;
273+
}
274+
return fa.withName(fa.getName().withSimpleName("JVM_" + newVersion));
275+
}
276+
277+
/**
278+
* Parse a Kotlin {@code jvmTarget} string ("1.8", "11", "21") to the corresponding major Java version.
279+
* Returns {@code null} if the value cannot be parsed (do-no-harm).
280+
*/
281+
private static @Nullable Integer parseJvmTarget(@Nullable String raw) {
282+
if (raw == null) {
283+
return null;
284+
}
285+
String trimmed = raw.trim();
286+
if ("1.8".equals(trimmed)) {
287+
return 8;
288+
}
289+
try {
290+
return Integer.parseInt(trimmed);
291+
} catch (NumberFormatException e) {
292+
return null;
293+
}
294+
}
295+
}

0 commit comments

Comments
 (0)