Skip to main content

Writing Kotlin recipes

Most refactorings you want to apply to a Kotlin codebase are pattern-shaped: this expression should look like that expression. Writing a visitor by hand for each one means the boilerplate dwarfs the rewrite itself.

The Kotlin recipe domain-specific language (DSL) removes that boilerplate. It is a K2 compiler plugin shipped inside rewrite-kotlin that reads a before/after pair of Kotlin lambdas and synthesizes a Recipe subclass at compile time - the same way Refaster's annotation processor does for Java.

In this guide, we will walk you through setting up a Kotlin recipe project, writing pattern-shaped recipes with the DSL, testing them, and dropping into an imperative visitor when a change needs more than a fixed before/after pair. You'll also see how a recipe authored this way can rewrite Java, Groovy, and Scala sources, not just Kotlin ones.

If you would rather start from working code, the kotlin-recipe-starter repository contains every recipe in this guide, with tests, packaging, and CI already set up. Click Use this template on GitHub, rename the com.yourorg group to your own, and start writing.

Prerequisites

This guide assumes that:

  • You have JDK 21 installed (the rewrite-kotlin parser is built and validated on JDK 21)
  • You are comfortable writing and running Kotlin tests with Gradle
  • You have installed and configured the Moderne CLI so you can test your recipe against real repositories

How Kotlin recipes work

Before writing your own recipe, it's worth taking a few minutes to understand where Kotlin support sits in OpenRewrite.

Kotlin support has always been an extension of Java support rather than a replacement for it. The Kotlin Lossless Semantic Tree (LST) is built on top of the Java LST: classes, methods, blocks, statements, method invocations, field accesses, literals, and identifiers all parse to the same J.* nodes a Java source file would produce.

The handful of Kotlin constructs that have no Java equivalent - when expressions, the !! operator, properties as a first-class declaration kind, destructuring declarations, string templates, and type aliases - get their own K.* node types. Each of those implements J.Expression, J.Statement, or J.TypeTree, and K.CompilationUnit implements JavaSourceFile.

Two practical consequences follow from that shared model:

  • An ordinary Java or declarative YAML recipe already runs against Kotlin sources. A type-based recipe such as org.openrewrite.java.ChangeType rewrites Kotlin the same way it rewrites Java, and KotlinVisitor extends JavaVisitor, adding visitX(K.X, P) overloads for the Kotlin-specific nodes.
  • The relationship runs in both directions. A recipe authored with the Kotlin DSL compiles to a language-agnostic, MethodMatcher-driven recipe. If its pattern names a shared or pure-Java API, that recipe also rewrites Java, Groovy, and Scala sources, whose LSTs are built on the same Java model. See rewriting other JVM languages.

The three ways to write a Kotlin recipe

ApproachWhen to reach for it
Declarative DSL - rewrite { } to { }Pattern-shaped rewrites: a before expression becomes an after expression. This covers the common case.
Imperative scope - kotlin { visit… }The change depends on cursor context, annotations, or conditional logic that a fixed pattern cannot express.
Declarative YAMLYou want to compose existing type-based recipes, which apply to Kotlin as readily as to Java.

Setting up your project

The DSL needs a Gradle build with the Kotlin plugin applied, because the compiler plugin has to be on the Kotlin compiler's plugin classpath. Here are the pieces that matter in a build.gradle.kts file:

build.gradle.kts
plugins {
id("org.openrewrite.build.recipe-library-base") version "latest.release"

// The Kotlin recipe DSL is authored in Kotlin and compiled by the K2 compiler.
kotlin("jvm") version "2.3.20"
}

group = "com.yourorg"
description = "OpenRewrite recipes for Kotlin, authored with the Kotlin recipe DSL."

dependencies {
// The bom aligns every org.openrewrite module - including rewrite-kotlin - to one version.
implementation(platform("org.openrewrite:rewrite-bom:latest.release"))

// The Kotlin LST extends the Java LST, so recipes lean on both modules.
implementation("org.openrewrite:rewrite-java")
implementation("org.openrewrite:rewrite-kotlin")

// This is the line that enables the DSL: the K2 compiler plugin ships inside rewrite-kotlin.
kotlinCompilerPluginClasspath(platform("org.openrewrite:rewrite-bom:latest.release"))
kotlinCompilerPluginClasspath("org.openrewrite:rewrite-kotlin")

// Parse Kotlin sources under test against a real JDK 21 runtime.
runtimeOnly("org.openrewrite:rewrite-java-21")

testImplementation("org.openrewrite:rewrite-test")
}

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

The kotlinCompilerPluginClasspath("org.openrewrite:rewrite-kotlin") line is what turns rewrite { } to { } into a recipe. Without it, your recipe(...) declarations still compile, but they produce nothing runnable.

Recipe libraries usually target Java 8 bytecode so they load in older rewrite runtimes, while tests exercise the JDK 21 Kotlin parser. The kotlin-recipe-starter build file shows how to split the two jvmTarget settings, along with the publishing and release plugins you'll want for a real recipe module.

Writing a pattern-shaped recipe

A recipe is a top-level Kotlin property. There are no annotations, no separate template class, and no annotation processor to configure:

UseModernKotlinApis.kt
val UseUppercase: Recipe = recipe(
displayName = "Use `uppercase()` instead of `toUpperCase()`",
description = "`String.toUpperCase()` was deprecated in Kotlin 1.5 in favor of the locale-explicit `uppercase()`.",
) {
edit {
rewrite { s: String -> s.toUpperCase() } to { s -> s.uppercase() }
}
}

The rewrite { } to { } clause reads as a before/after pair of Kotlin lambdas, with parameters bound at the call sites the recipe matches. It has the same expressive power as a Refaster @BeforeTemplate and @AfterTemplate pair.

The edit block is one of three phases the DSL exposes. These phases line up with the three recipe types you would write by hand in Java:

  • scan accumulates information across files before any edits are made
  • edit rewrites trees in place
  • generate adds new source files

These phases compose, so a recipe can scan its sources, edit based on what it found, and then generate a summary report.

Keep the displayName and description arguments to recipe(...) as plain string literals. The compiler plugin silently falls back to a non-serializable recipe when they are built with + concatenation.

Binding multiple parameters

The before and after lambdas can take more than one parameter, and a parameter can itself be a lambda. Both are threaded through to the to { } side by name:

UseModernKotlinApis.kt
val UseSumOf: Recipe = recipe(
displayName = "Use `sumOf` instead of `sumBy`",
description = "`Iterable.sumBy { … }` was deprecated in Kotlin 1.5 in favor of the type-inferred `sumOf { … }`.",
) {
edit {
rewrite { xs: Iterable<Int>, selector: (Int) -> Int -> xs.sumBy(selector) } to { xs, selector -> xs.sumOf(selector) }
}
}

A zero-parameter pattern ({ -> … }) matches an expression that binds no arguments - a constant field access, for example, rather than a method call:

UseModernKotlinApis.kt
val UseKotlinMathPi: Recipe = recipe(
displayName = "Use `kotlin.math.PI` instead of `java.lang.Math.PI`",
description = "Prefer the multiplatform `kotlin.math.PI` constant over the JVM-only `Math.PI`.",
) {
edit {
rewrite { -> Math.PI } to { -> kotlin.math.PI }
}
}

When a rewrite { } lambda names a deprecated API on purpose - which migration recipes do constantly - add @file:Suppress("DEPRECATION", "DEPRECATION_ERROR") at the top of the file. The deprecated call is the pattern you are matching, not code you run.

Composing several recipes into one

recipes(...) bundles several recipes into a single runnable recipe. Point consumers at the composite and they get every sub-recipe, while each sub-recipe remains runnable on its own:

UseModernKotlinApis.kt
val UseModernKotlinApis: Recipe = recipes(
displayName = "Use modern Kotlin stdlib APIs",
description = "Replaces Kotlin stdlib APIs deprecated between 1.4 and 2.0 with their modern equivalents.",
UseUppercase,
UseLowercase,
UseCharCode,
UseSumOf,
UseKotlinMathMax,
UseKotlinMathPi,
)

What the compiler plugin generates

You never see the K2 plugin at work, but it's useful to know what it does. At recipe-compile time it reads the before lambda, extracts a MethodMatcher spec from its Front-end Intermediate Representation (FIR) resolved root call, builds the after template from the after lambda's source, and synthesizes a Recipe subclass whose getVisitor() walks the LST replacing matches.

Every generated visitor is wrapped in a Preconditions.check(...) against a UsesMethod - or UsesField for property-access patterns - derived from the matcher spec. Files that never reference the targeted member are skipped without being walked.

Consumers see an ordinary Recipe instance with serializable metadata and a working getVisitor(). No reflection and no ServiceLoader wiring are involved.

Testing your recipe

Kotlin recipes are tested with the same RewriteTest harness every other OpenRewrite recipe uses, via the kotlin(...) source helper from rewrite-kotlin. The helper accepts either one or two arguments:

  • Two arguments (kotlin(before, after)) assert that the recipe transforms before into after
  • One argument (kotlin(before)) asserts that the recipe makes no change
UseModernKotlinApisTest.kt
class UseModernKotlinApisTest : RewriteTest {

@Test
fun `toUpperCase becomes uppercase`() = rewriteRun(
{ spec -> spec.recipe(UseUppercase) },
kotlin(
"""
val s: String = "hello".toUpperCase()
""",
"""
val s: String = "hello".uppercase()
""",
),
)

@Test
fun `unrelated calls are left alone`() = rewriteRun(
{ spec -> spec.recipe(UseUppercase) },
kotlin(
"""
val s: String = "hello".trim()
""",
),
)
}

Always include at least one no-change test so that you can be confident your recipe does not touch code it should not. For more on structuring recipe tests, see the recipe testing guide.

Rewriting other JVM languages with a Kotlin recipe

Kotlin is not the only language whose LST is built on the Java one. G.CompilationUnit (Groovy) and S.CompilationUnit (Scala) implement JavaSourceFile just as K.CompilationUnit does. Because the compiled recipe is MethodMatcher-driven over that shared Java LST, a pattern whose before and after templates name a pure-Java API rewrites Java, Groovy, and Scala sources as readily as Kotlin ones.

Two conditions have to hold for that reach:

  • The API named in the rewrite { } and to { } lambdas is a pure-Java one, so the matcher resolves to a type every JVM language sees rather than to a Kotlin-only synthetic facade
  • The replacement is valid syntax in each target language, which for a plain method rename it generally is

Here, Character.isSpace(char) has been deprecated since JDK 1.1 in favor of the identically shaped Character.isWhitespace(char). Only the method name changes, so the rewrite holds in every one of those languages:

UseIsWhitespace.kt
@file:Suppress("DEPRECATION", "DEPRECATION_ERROR")

val UseIsWhitespace: Recipe = recipe(
displayName = "Use `Character.isWhitespace` instead of the deprecated `Character.isSpace`",
description = "`java.lang.Character.isSpace(char)` has been deprecated since JDK 1.1 in favor of `Character.isWhitespace(char)`, which also recognizes Unicode whitespace.",
) {
edit {
rewrite { c: Char -> Character.isSpace(c) } to { c -> Character.isWhitespace(c) }
}
}

You can prove that by running the same recipe through more than one assertion helper in a single test class. Each language module ships its own: kotlin(...) from rewrite-kotlin, java(...) from rewrite-java, groovy(...) from rewrite-groovy, and scala(...) from rewrite-scala. Here are the Kotlin and Java halves:

UseIsWhitespaceTest.kt
@Test
fun `rewrites the call in Kotlin`() = rewriteRun(
kotlin(
"""
fun blank(c: Char): Boolean = Character.isSpace(c)
""",
"""
fun blank(c: Char): Boolean = Character.isWhitespace(c)
""",
),
)

@Test
fun `rewrites the same call in Java`() = rewriteRun(
java(
"""
class Chars {
boolean blank(char c) {
return Character.isSpace(c);
}
}
""",
"""
class Chars {
boolean blank(char c) {
return Character.isWhitespace(c);
}
}
""",
),
)

Dropping into an imperative visitor

When a change depends on where a call sits, what annotations surround it, or how two call sites relate, a fixed before/after pair cannot express it. The kotlin { visit… } scope gives you the full visitor underneath - the DSL composes with KotlinVisitor; it does not replace it.

The recipe below flags println and print calls, but leaves alone any call inside a fun main, where console output is expected. That "look at where the call sits" check uses the cursor to walk enclosing declarations:

FindPrintlnCalls.kt
private const val PRINTLN_SPEC = "kotlin.io.ConsoleKt println(..)"
private const val PRINT_SPEC = "kotlin.io.ConsoleKt print(..)"

private val PRINTLN_MATCHER = MethodMatcher(PRINTLN_SPEC)
private val PRINT_MATCHER = MethodMatcher(PRINT_SPEC)

val FindPrintlnCalls: Recipe = recipe(
displayName = "Find `println`/`print` calls outside `main`",
description = "Flags `println` and `print` calls, which usually belong behind a logging framework in production code. Calls inside a `fun main` are left alone.",
) {
edit {
check(
or(usesMethod(PRINTLN_SPEC), usesMethod(PRINT_SPEC)),
kotlin {
visitMethodInvocation { mi ->
if (!PRINTLN_MATCHER.matches(mi) && !PRINT_MATCHER.matches(mi)) {
return@visitMethodInvocation mi
}
val enclosingFunction = cursor.firstEnclosing(J.MethodDeclaration::class.java)
if (enclosingFunction?.simpleName == "main") {
return@visitMethodInvocation mi
}
SearchResult.found(mi, "prefer a logging framework over console output") ?: mi
}
},
)
}
}

Unlike rewrite { } to { }, an imperative visitor is not wrapped in a UsesMethod precondition for you. Add one with check(...), as above, so that files which never call the targeted method are skipped before the LST is traversed.

Notice that the method specs name kotlin.io.ConsoleKt rather than a type you would see in source. For Kotlin extension functions, the matcher owner is the synthetic JVM facade class - kotlin.text.StringsKt for String.lowercase(), kotlin.io.ConsoleKt for readLine() - which no source file references directly. That is why the DSL constrains on UsesMethod and UsesField rather than UsesType. For more on writing these specs, see the method patterns reference.

Synthesizing code with KotlinTemplate

The rewrite { } to { } DSL rewrites a matched expression into a fixed after-expression. When the "after" side is a different syntactic shape - a new operator, moved arguments, added control flow - reach for KotlinTemplate, the Kotlin counterpart to JavaTemplate. It parses a snippet of Kotlin into a fresh LST and splices it in, filling each #{...} hole with a node lifted from the matched code.

The recipe below rewrites a hand-written null guard into the equivalent Elvis throw one-liner. Two things put it beyond rewrite { } to { }: the before-pattern is an if statement rather than an expression call, and the guarded value moves in front of a new ?: operator that reuses the original throw on the right:

UseElvisThrow.kt
class UseElvisThrow : Recipe() {

override fun getDisplayName(): String = "Use an Elvis `throw` for null guards"

override fun getDescription(): String =
"Replaces a hand-written `if (x == null) throw ...` guard with the equivalent `x ?: throw ...` Elvis " +
"expression, reusing the thrown exception unchanged."

override fun getVisitor(): TreeVisitor<*, ExecutionContext> =
object : KotlinVisitor<ExecutionContext>() {

override fun visitIf(iff: J.If, ctx: ExecutionContext): J {
val nullCheck = iff.ifCondition.tree as? J.Binary ?: return super.visitIf(iff, ctx)
if (nullCheck.operator != J.Binary.Type.Equal || iff.elsePart != null) {
return super.visitIf(iff, ctx)
}
val value = when {
J.Literal.isLiteralValue(nullCheck.right, null) -> nullCheck.left
J.Literal.isLiteralValue(nullCheck.left, null) -> nullCheck.right
else -> return super.visitIf(iff, ctx)
}
val exception = when (val thenPart = iff.thenPart) {
is J.Throw -> thenPart.exception
is J.Block -> (thenPart.statements.singleOrNull() as? J.Throw)?.exception
?: return super.visitIf(iff, ctx)
else -> return super.visitIf(iff, ctx)
}
return KotlinTemplate.builder("#{any()} ?: throw #{any()}").build()
.apply(cursor, iff.coordinates.replace(), value, exception)
}
}
}

The whole if is replaced by a node of a different type, so the visitor is a non-iso KotlinVisitor rather than a KotlinIsoVisitor.

Emitting a data table

A recipe can emit structured rows instead of, or alongside, changing code. Those rows are exported as CSV and surfaced on the Moderne Platform - see the data tables guide for the full picture.

Inserting a row needs the ExecutionContext, which a visit method receives directly. The kotlin { visitX { node -> … } } sugar exposes the node but not the context, so data table recipes are written as a full Recipe with a KotlinIsoVisitor:

FindKotlinFunctions.kt
class KotlinFunctions(recipe: Recipe) : DataTable<KotlinFunctions.Row>(
recipe,
"Kotlin functions",
"The name and parameter count of every Kotlin function declaration.",
) {
data class Row(
@field:Column(displayName = "Source path", description = "Path of the file that declares the function.")
val sourcePath: String,
@field:Column(displayName = "Function name", description = "The declared function name.")
val functionName: String,
@field:Column(displayName = "Parameter count", description = "Number of value parameters the function declares.")
val parameterCount: Int,
)
}

class FindKotlinFunctions : Recipe() {

override fun getDisplayName(): String = "Find Kotlin function declarations"

override fun getDescription(): String =
"Records the name and parameter count of every Kotlin function declaration in a data table."

override fun getVisitor(): TreeVisitor<*, ExecutionContext> =
object : KotlinIsoVisitor<ExecutionContext>() {
private val functions = KotlinFunctions(this@FindKotlinFunctions)

override fun visitMethodDeclaration(
method: J.MethodDeclaration,
ctx: ExecutionContext,
): J.MethodDeclaration {
val sourcePath = cursor.firstEnclosing(SourceFile::class.java)?.sourcePath?.toString() ?: ""
val parameterCount = method.parameters.count { it !is J.Empty }
functions.insertRow(ctx, KotlinFunctions.Row(sourcePath, method.simpleName, parameterCount))
return super.visitMethodDeclaration(method, ctx)
}
}
}

Running your recipe against a codebase

Once your tests pass, you'll want to try the recipe against real repositories. The Moderne CLI runs a recipe straight from your compiled classes, so each change to a recipe costs a recompile rather than a publish.

Iterating with the active recipe

Compile the recipe module, then point the CLI at the Kotlin source file that declares your recipe:

./gradlew classes
mod config recipes active set src/main/kotlin/com/yourorg/UseModernKotlinApis.kt

mod config recipes active set detects your build tool, extracts the Kotlin compile classpath, and records the recipe as the active one. When the file declares several recipes, the CLI reports which one it selected, along with the alternatives you can pick from instead.

Kotlin sources are accepted by mod config recipes active set as of Moderne CLI 4.4.2. Earlier versions take only .java, .yml, and .yaml files.

Then build the LSTs for the repositories you want to try the recipe on, and run the active recipe against them:

mod build ./workspace
mod run ./workspace --active-recipe

From there, each edit to your recipe costs one ./gradlew classes and one mod run ./workspace --active-recipe. Because the CLI loads the recipe from the Gradle or Maven classpath, there is no jar to package and no artifact to publish in between. Add --continuous to the Gradle command to have it recompile on every save.

When the recipe runs but does not change what you expected, attach a debugger to that same active recipe with modw --debug run ./workspace --active-recipe and step through your visitor.

The Moderne IntelliJ plugin's Set Active Recipe action is a UI shortcut for the same command, so you can select the recipe from the editor instead of typing its ID. The plugin needs a licensed CLI.

Knowing what your recipe is called

To name a recipe on the command line, you need to know what it compiles to. The compiler plugin synthesizes a class for each recipe declared with the DSL, and that class name - not the property name - is the recipe ID:

DeclarationRecipe ID
val UseUppercase: Recipe = recipe(…)com.yourorg.UseUppercase$KtRecipe
val UseModernKotlinApis: Recipe = recipes(…)com.yourorg.UseModernKotlinApis$KtRecipe
class UseElvisThrow : Recipe()com.yourorg.UseElvisThrow

Wrap any recipe ID containing $ in single quotes, as in 'com.yourorg.UseUppercase$KtRecipe'. An unquoted $KtRecipe is expanded to an empty string by most shells, and the CLI then reports the recipe as not found.

Publishing your recipe

Once the recipe does what you want, publish the module so that others can run it, or so that you can run it with the OpenRewrite Gradle or Maven plugin instead of the CLI:

./gradlew publishToMavenLocal

You can then install the published artifact into the Moderne CLI's recipe marketplace and run it by ID:

mod config recipes jar install com.yourorg:kotlin-recipe-starter:LATEST
mod run ./workspace --recipe='com.yourorg.UseModernKotlinApis$KtRecipe'

Publishing and installing on every edit is a much slower loop than the active recipe, so save it for sharing a finished recipe rather than for developing one.

To share the recipe more widely, publish the artifact to your organization's artifact repository and install it in the Moderne Platform so that anyone in your organization can run it.

Next steps

Now that you've written your first Kotlin recipe, you can go deeper: