Kotlin Compiler Pluginを複数の言語バージョンに対応する

こんにちは、こんばんは、kitakkunです。

筆者は Kotlin Fest 2024 で紹介した [back-in-time-plugin](https://github.com/kitakkun/back-in-time-plugin) をはじめ、[Kondition](https://github.com/kitakkun/Kondition), [suspend-kontext](https://github.com/kitakkun/suspend-kontext) というKotlin Compiler PluginをOSSで公開しています。

Konditionとsuspend-kontextに関しては、既に MavenCentral で公開しています。Kotlin 2.0.0 から最新の 2.1.0 までをサポートしていますので、ぜひ遊んでみてください。

こちらの記事は [Kotlin Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kotlin) 20日目の記事となります。

https://qiita.com/advent-calendar/2024/kotlin

# はじめに

本記事では、複数の言語バージョンを意識して Kotlin Compiler Plugin を開発する方法をご紹介します。具体的には以下の内容をカバーします。

- 言語バージョンによるAPI差異を賢く吸収する
- 環境変数で言語バージョンを切り替える
- Gradle Plugin で適切な Compiler Plugin を自動選択する

Gradleによるビルド構成が主となるので、Kotlin Compiler Plugin の理解は必要ありませんが、読者としてはコンパイラプラグインの開発者を想定しています。

通常のプロジェクトではまず必要とならない内容ですが、Gradleについて詳しくなる視点で読んでみても面白い内容かと思いますので、よろしければブラウザバックせず最後まで読んでみてください！

:::note
Kotlin Compiler Plugin 自体について詳しく知りたい方は、先にこちらをどうぞ。情報の鮮度的には Kotlin Fest 2024 のセッション動画をお勧めします。

- [もっとKotlinを好きになる！K2時代のKotlin Compiler Plugin開発 / Kotlin Fest 2024](https://youtu.be/KeQyaLF-_9A?si=dr_N3jOrHf9RCo8V)
- [【2023年最新版】 Kotlin Compiler Pluginの作り方、どこにもないやん！というあなたへ](https://qiita.com/kitakkun/items/f8702d5975809c2bea05)
:::

# 前提: なぜ複数の言語バージョンをサポートするのか

前提として、モチベーションの話をします。

Kotlin Compiler Plugin の拡張対象は、**ユーザ空間における Kotlin Compiler** です。

ユーザは、プロジェクトのステージに応じて**様々な言語バージョン**を採用しています。そのため、特別な事情で古めのバージョンを使っているかもしれません。

コンパイラプラグインが単一の言語バージョンのみを想定して開発されている場合、どんな問題が考えられそうでしょうか？

- **機会損失**: ユーザの使っている言語バージョンが未サポートで使えない
- **高い導入リスク**: 重要なバグフィックスがあっても、言語バージョンごとの更新が必要になる可能性がある

このような問題が残っていると、いくら優れたコンパイラプラグインを開発したとしても、なかなか採用してもらえません。できるだけ多くの人に使ってもらうためにも、複数の言語バージョンをサポートするのがベターです。

それでは順に実際の対応方法を見ていきましょう！

# ステップ1: 言語バージョンによるAPI差異を賢く吸収する

コンパイラも立派なソフトウェアなので、バージョンアップでAPIが変更されていきます。

ほとんどの部分は同じコードを使いまわせますが、どうしても一部APIの削除やインタフェースの変更が起こります。そこで、バージョン別に一部ソースプログラムを切り替えてビルドする仕組みが必要です。

1. 言語バージョン別にソースプログラムを分ける
2. バージョン差異をインタフェースに切り出して吸収する

上記2ステップに分けて解説していきます。

## ステップ1-1: 言語バージョン別にソースプログラムを分ける

まず最初に、言語バージョンに応じて一部のソースプログラムを分離する戦略について考えます。

先行事例として、Android の [Build Variant](https://developer.android.com/build/build-variants) という仕組みがあります。これを使うと、デバッグビルドとリリースビルドとで異なるソースセットを使うなど、一部の実装を条件で分岐することができます。

今回はある種これに近いアプローチで対応を行います。少し異なるのは、「ソースセット」を分離するのではなく、「**ソースディレクトリ**」を分離することです。

:::note
##### 「ソースセット」と「ソースディレクトリ」

念のため軽く用語の整理をします。ソースセットはソースディレクトリを束ねる単位です。例えば:

- ソースセット: `main`, `test`, `commonMain`, `commonTest`, `...`
- ソースディレクトリ: `kotlin`, `java`

といった具合ですね。
:::


具体的に以下のようにソースディレクトリを構成します。

- `core`: 全バージョン共通
- `latest`: 最新バージョン または 専用ソースディレクトリがない場合のフォールバック先
- `v_x_y_z`: Kotlin x.y.z 限定
- `pre_x_y_z`: Kotlin x.y.z 以前（x.y.zを含む）

例えば:
- 2.1.0（最新）の場合は、`core` + `latest`
- 2.0.21 の場合は、`core` + (`v_2_0_21` または `pre_x_y_z` (`x.y.z >= 2.0.21`))

をそれぞれ自動的に選択します。図にするとこんな具合ですね。

```mermaid
graph TD

2.1.0 -----> latest
2.1.0 -----> core
2.0.21 -----> core
2.0.21 -----> pre_2_0_21
2.0.20 -----> core
2.0.20 -----> pre_2_0_21
2.0.10 -----> core
2.0.10 -----> pre_2_0_21
2.0.0 -----> core
2.0.0 -----> v_2_0_0

style 2.1.0 fill:#B125EA
style latest fill:#B125EA
style core fill:#E24462
style 2.0.21 fill:#7F52FF
style pre_2_0_21 fill:#7F52FF
```

`pre_x_y_z` という範囲による区分を用意しているのは、「隣り合うバージョンではAPIの差分が小さい」という特徴があるためです。

:::note info
この方式で管理すれば、新しい言語バージョン `2.1.x` がリリースされても比較的簡単に対処できます。

##### API変更の影響がない場合

そのままバージョンを上げれば対応が完了します（緑は新規追加部分）。
```mermaid
graph TD

2.1.x -----> latest
2.1.x -----> core
2.1.0 -----> latest
2.1.0 -----> core
2.0.21 -----> core
2.0.21 -----> pre_2_0_21
2.0.20 -----> core
2.0.20 -----> pre_2_0_21
2.0.10 -----> core
2.0.10 -----> pre_2_0_21
2.0.0 -----> core
2.0.0 -----> v_2_0_0

style 2.1.x fill:green
```

##### API変更の影響がある場合

影響箇所をバージョン固有APIのクラス（後述）に切り出して旧最新バージョン用のソースディレクトリを作成します（緑は新規追加部分、オレンジは差分が発生する箇所）。

```mermaid
graph TD
    2.1.x -----> latest
    2.1.x -----> core
    2.1.0 -----> pre_2_1_0
    2.1.0 -----> core
    2.0.21 -----> core
    2.0.21 -----> pre_2_0_21
    2.0.20 -----> core
    2.0.20 -----> pre_2_0_21
    2.0.10 -----> core
    2.0.10 -----> pre_2_0_21
    2.0.0 -----> core
    2.0.0 -----> v_2_0_0

    style 2.1.x fill:green
    style pre_2_1_0 fill:green
    style latest fill:darkorange
    style core fill:darkorange
    style pre_2_0_21 fill:darkorange
    style v_2_0_0 fill:darkorange
```
:::

### Gradleで実際に構成する方法

それでは実際に上記のディレクトリ構成を実現するビルドロジックを実装していきます。

1. `core` をソースディレクトリに追加
1. 使用中のKotlinバージョンを取得してパース（x.y.zに分離）
1. バージョン固有ディレクトリを適切に選択してソースディレクトリに追加

ちょっと長くなりますが頑張ってついてきてください :sob: 

#### `core` をソースディレクトリに追加

ソースセット(`sourceSet`)に含めるソースディレクトリ(`srcDirs`)は、自由に指定できます。

例えば、`core` というディレクトリをソースディレクトリに追加する Gradle スクリプトは以下のようになります:

```kotlin
kotlin {
    sourceSets.forEach { sourceSet ->
        val srcDirs = sourceSet.kotlin.srcDirs // ex) [.../src/main/kotlin, .../src/main/java]
        val sourceSetRootPath = srcDirs.first().toPath().parent // ex) .../src/main

        val newSrcDirs = srcDirs.toMutableSet()
        newSrcDirs += sourceSetRootPath.resolve("core").toFile()
        sourceSet.kotlin.setSrcDirs(newSrcDirs)
    }
}
```

上記ビルド設定を適用すると、IDEで 「`core`」 が `kotlin` や `java` と同様に特別なディレクトリとして認識されるようになります:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/75c687eb-f8ee-4c95-de03-817296a933de.png)

#### 使用中のKotlinのバージョンを取得してパース (x.y.zに分離)

ソースディレクトリに含めるべきバージョン固有ディレクトリ特定するために、プロジェクト内で使用している Kotlin のバージョンを解析する必要があります。

バージョンの取得方法は様々かと思いますが、ここでは VersionCatalogs を使っている想定でそこから引っ張ってくることにします。

```kotlin:gradle/libs.versions.toml
[versions]
kotlin = "2.1.0"
```

```kotlin
val rawKotlinVersion = libs.versions.kotlin.get() // ex) 2.0.0, 2.1.0, ...
```

続いて、バージョン比較がしやすいように便宜上 `KotlinVersion` という独自のデータクラスを作り、`compareTo` を実装して簡単に比較演算できるようにしておきます。

```kotlin
data class KotlinVersion(
    val majorVersion: Int,
    val minorVersion: Int,
    val patchVersion: Int,
) {
    override fun toString(): String {
        return "$majorVersion.$minorVersion.$patchVersion"
    }

    operator fun compareTo(other: KotlinVersion): Int {
        return when {
            this.majorVersion != other.majorVersion -> this.majorVersion - other.majorVersion
            this.minorVersion != other.minorVersion -> this.minorVersion - other.minorVersion
            else -> this.patchVersion - other.patchVersion
        }
    }
}
```

文字列を `KotlinVersion` へと変換する関数を作り:

```kotlin
fun String.parseToKotlinVersion(): KotlinVersion {
    val (major, minor, patch) = substringBefore("-").split(".").map { it.toInt() } // substringBefore("-") は後ろにくっつく RC1 とか Beta1 とかを取り除くため
    return KotlinVersion(major, minor, patch)
}
```

`KotlinVersion` としてバージョンを取得します。
```kotlin
val kotlinVersion = libs.versions.kotlin.get().parseToKotlinVersion()
```

例えば、`2.0.0`, `2.0.0-RC1` であれば `KotlinVersion(2, 0, 0)` になります。

#### バージョン固有ディレクトリを適切に選択してソースディレクトリに追加

取得した `KotlinVersion` を使って、バージョン固有ディレクトリを選択します。

まずRegexパターンを用いて、`sourceSetRootPath` 配下に存在するバージョン固有のソースディレクトリをかき集めます:
```diff_kotlin
// matches:
// - v_1
// - v_1_9
// - v_1_9_2
// - v_1_9_24
// - pre_1_9_20
// etc.
val versionSpecificSrcDirRegex = "^(v|pre)(_\\d){1,3}\\d?$".toRegex()

val versionSpecificSrcDirs = sourceSetRootPath.toFile().listFiles().orEmpty().filter {
    it.name.matches(versionSpecificSrcDirRegex) && it.exists()
}
```

続いて、`pre_x_y_z` と `v_x_y_z` を区別するために `RangedKotlinVersion` クラスを便宜上作成しておきます:
```kotlin
sealed interface RangedKotlinVersion {
    fun matches(kotlinVersion: KotlinVersion): Boolean

    data class Direct(val kotlinVersion: KotlinVersion) : RangedKotlinVersion {
        override fun matches(kotlinVersion: KotlinVersion) = kotlinVersion == this.kotlinVersion
    }

    data class Pre(val kotlinVersion: KotlinVersion) : RangedKotlinVersion {
        override fun matches(kotlinVersion: KotlinVersion) = kotlinVersion <= this.kotlinVersion
    }
}
```

`matches` を呼び出すことで、引数で渡した`kotlinVersion`が対応バージョンかを判定します。

次に、`versionSpecificSrcDirs` と `RangedKotlinVersion` の対応づけを行います:
```kotlin
val versionSpecificSrcDirsMap = versionSpecificSrcDirs.associateBy {
    val kotlinVersion = it.name.substringAfter("_") // v_2_0_0 -> 2_0_0, pre_2_0_0 -> 2_0_0
        .replace("_", ".") // 2_0_0 -> 2.0.0
        .parseToKotlinVersion()
    when {
        it.name.startsWith("v_") -> RangedKotlinVersion.Direct(kotlinVersion)
        it.name.startsWith("pre_") -> RangedKotlinVersion.Pre(kotlinVersion)
        else -> error("Unexpected source directory name: ${it.name}.")
    }
}.toList()
```

最後に、`KotlinVersion(x, y, z)` について、以下のアルゴリズムでディレクトリを選択します。

```mermaid
flowchart LR
    A["v_x_y_z が存在する？"] -->|YES| B["v_x_y_zを選択"]
    A -->|NO| C["pre_x'_y'_z' が存在する？ ※ x'.y'.z' >= x.y.z"]
    C -->|YES| D["pre_x_y_zを選択"]
    C -->|NO| E["latestを選択"]
```



このロジックをビルドスクリプトの一部として記述するとこうなります:

```kotlin
val directVersionSpecificSrcDir = versionSpecificSrcDirsMap.find { (version, _) ->
    version is RangedKotlinVersion.Direct && version.matches(kotlinVersion)
}?.second

val preVersionSpecificSrcDir = versionSpecificSrcDirsMap.find { (version, _) ->
    version is RangedKotlinVersion.Pre && version.matches(kotlinVersion)
}?.second

val targetVersionSpecificSrcDir = when {
    directVersionSpecificSrcDir != null -> directVersionSpecificSrcDir
    preVersionSpecificSrcDir != null -> preVersionSpecificSrcDir
    else -> sourceSetRootPath.resolve("latest").toFile()
}
```

あとは `targetVersionSpecificSrcDir` を `newSrcDirs` に `add` すればOKです。
ここまでで Kotlin のバージョンに応じたソースセットの自動選択ができるようになりました！（[差分はこちらを参照](https://github.com/kitakkun/multi-kotlin-support-example/commit/64a85b14ff3cd2b45ad7a5238c258ff45f067c2c)）

2.1.0 | 2.0.21 ~ 2.0.10 | 2.0.0
:--: | :--: | :--:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/e498d9cd-0ecf-648f-b58d-34fa95a4c9b5.png) | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/85ccd89a-4b3b-9738-0092-e046f119b732.png) | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/f0d68f08-87fa-0568-ffe5-334df1bf3785.png)



## ステップ1-2: バージョン固有APIクラスの定義

ここからは、言語バージョンによるAPI差異を特別なインタフェースに切り出すことで解決します（下図）。

```mermaid
graph LR

core ---> SUPER[VersionSpecificAPI]
latest --> A[VersionSpecificAPIImpl]
pre_2_0_21 --> B[VersionSpecificAPIImpl]
v_2_0_0 --> C[VersionSpecificAPIImpl]

A --実装--> SUPER
B --実装--> SUPER
C --実装--> SUPER
```

`core` モジュールに `VersionSpecificAPI` インタフェースを用意して、各ソースディレクトリで個別に実装します。

バージョンによって一意に実装クラスが選択されるので、クラスの多重定義エラーを避けてAPI差異を吸収することができます。

```kotlin:src/main/core/VersionSpecificAPI.kt
interface VersionSpecificAPI {
    companion object {
        lateinit var INSTANCE: VersionSpecificAPI
    }

    fun someVersionSpecificTask()
}
```

```kotlin:src/main/latest/VersionSpecificAPIImpl.kt
object VersionSpecificAPIImpl: VersionSpecificAPI {
    override fun someVersionSpecificTask() {
        println("latest Kotlin!")
    }
}
```

```kotlin:使用前の準備・関数呼び出し
fun main() {
    // 適当な場所でインスタンスを注入
    VersionSpecificAPI.INSTANCE = VersionSpecificAPIImpl
    VersionSpecificAPI.INSTANCE.someVersionSpecificTask()
}
```

このステップの差分は[こちら](https://github.com/kitakkun/multi-kotlin-support-example/commit/d48a3255301257fbaa774973b28d88000d79c15f)です。確認しやすいように `application` プラグインを適用しているので `./gradlew run` を実行することで動作確認できます。

```:Kotlin 2.1.0 の時の挙動
> Task :multi-kotlin-module:run
latest Kotlin!
```
```:Kotlin 2.0.21 ~ 2.0.10 の時の挙動
> Task :multi-kotlin-module:run
Kotlin pre 2.0.21!
```
```:Kotlin 2.0.0 の時の挙動
> Task :multi-kotlin-module:run
Kotlin 2.0.0!
```

# ステップ2: 言語バージョンを環境変数で切り替える

CIなどで自動的に複数の言語バージョンをテスト・デプロイするためには、環境変数で言語バージョンを切り替えられると便利です。今回は、`KOTLIN_VERSION` という環境変数を参照して言語バージョンを上書きすることを試みます。

gradleディレクトリ配下に libs.versions.toml を配置すると、自動的にバージョンカタログが生成されますが、このようにして生成されるバージョンカタログは上書きができません。そのため、自前で `libs` バージョンカタログを作成する必要があります。

`gradle/libs.versions.toml` を `versions-root` 配下へ移動して、`settings.gradle.kts` の `dependencyResoulutionManagement` を次のように変更します:

```kotlin
dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from(files(rootDir.toPath().resolve("versions-root/libs.versions.toml")))

            System.getenv("KOTLIN_VERSION")?.let {
                version("kotlin", it)
            }
        }
    }
}
```

ターミナルで実行前に環境変数を export して試してみましょう。差分は[こちら](https://github.com/kitakkun/multi-kotlin-support-example/commit/59b123d5350ddc6324f24a40fd45b8b9f12b3fdb)。
```shell
$ export KOTLIN_VERSION=2.0.0
$ ./gradlew run
> Task :multi-kotlin-module:run
Kotlin 2.0.0!
```

うまく切り替えることができていますね！

# ステップ3: Gradle Plugin で適切な Compiler Plugin を自動選択する

最後に、Gradle Plugin を用いて自動的に適用先プロジェクトの言語バージョン合致するコンパイラプラグインをクラスパスに追加したくなるはずです。

詳細までは説明しませんが、ここで必要なのはどうやって適用先プロジェクトで使用している言語バージョンを取得するか、でしょう。

まず、Kotlin Gradle Plugin (以後簡単にKGP) のAPIを使いたいので `compileOnly` で依存を追加します。
```kotlin
[libraries]
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
```
```kotlin
dependencies {
    compileOnly(libs.kotlin.gradle.plugin)
}
```

（バージョンの取得方法は様々ありますし、今後変更される可能性はありますが）一番簡単かつ直感的なのは `getKotlinPluginVersion` を使う方法でしょう。

```kotlin
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion

class PrintTargetProjectKotlinVersionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        val targetKotlinVersion = target.getKotlinPluginVersion()
        println("The target project is using Kotlin $targetKotlinVersion!")
    }
}
```

これで適用されている KGP のバージョンを取得できます。あとは `KotlinCompilerPluginSupportPlugin` のインスタンスをバージョン情報を用いて作成し `apply` するか、`dependencies` に `add("kotlinCompilerPluginClasspath", "...")` するなどすれば大丈夫です。お疲れ様でした（[差分はこちらになります！](https://github.com/kitakkun/multi-kotlin-support-example/commit/846d020e0f27767a797f09de9b27ddc9aba6346d)）。

# まとめ

本記事では、Kotlin Compiler Pluginで複数言語バージョン対応するための戦略をまとめました。サンプルコードは GitHub にて公開しておりますので、必要に応じてご参照ください。

https://github.com/kitakkun/multi-kotlin-support-example

:::note
また、ご紹介した内容は [kotlinx.rpc](https://github.com/Kotlin/kotlinx-rpc) のソースコードを参考にしています。`gradle-conventions`(`build-logic`) を作成したい場合はこちらのリポジトリのように `gradle-conventions-settings` の導入が必要となるかもしれません。

settings.gradle.kts を src 内に配置することで、`settings.gradle.kts` の内容を使い回すことができます。よければ[こちらの記事](https://qiita.com/kitakkun/items/1817c0d1ad9ce97f7e97)も合わせてご覧ください。
:::

# 宣伝
最後に、個人で開発しているKotlin Compiler Pluginについてのご紹介です。

### Kondition

Kondition は、値に特定の条件を課したり、値を条件に合わせて自動調整するためのコンパイラプラグインです。アノテーションを用いてボイラープレートを軽減します。

https://github.com/kitakkun/Kondition

値に条件を課す例:
```kotlin
fun divide10(@NonZero value: Int) : Int {
    return 10 / value
}

// コンパイラで変換されたコードのイメージ:
fun divide10(@NonZero value: Int) : Int {
    require(value != 0) { "error!" }
    return 10 / value
}
```

値を条件に合わせて自動調整する例
```kotlin
fun someTask(@CoereceIn(0, 1) range: Double) {
    println(range)
}

// コンパイラで変換されたコードのイメージ:
fun someTask(@CoereceIn(0, 1) range: Double) {
    var temp = range.coerceIn(0.0, 1.0)
    println(temp) // 0 ~ 1の範囲でのみprintされる
}
```

### suspend-kontext

suspend-kontext は、suspend関数のデフォルトコンテキストをアノテーションを用いて指定することで、コードの可読性を向上させるためのコンパイラプラグインです。

https://github.com/kitakkun/suspend-kontext

`withContext` を suspend関数 の内部で呼び出すと、`CoroutineDispatcher`を切り替えて実行することができます:

```kotlin
suspend fun loadText(file: File): String {
    return withContext(Dispatchers.IO) {
        return@withContext file.readText()
    }
}
```

とはいえ、値を返したりするとネストが深くなりコードがやや読みづらくなってしまいます。このプラグインは以下のコードをコンパイルタイムに上のコードと同等になるように変換します。

```kotlin
@IOContext
suspend fun loadText(file: File): String {
    return file.readText()
}
```

どちらも軽い思いつきで作ったものですが、よければ遊んでみてください！

ここまで読んでいただきありがとうございました！！ [back-in-time-plugin](https://github.com/kitakkun/back-in-time-plugin)の方も早いところ MavenCentral から落とせるようにしたいんですが、色々調整が必要な部分があり難航しています :innocent:

Kotlin Compiler Plugin は難しいと感じるかもしれませんが、色々いじってると言語の詳細部分がわかってきますし、黒魔術感あって楽しいです。

冬休みの宿題としてやってみるのもいいかもしれません！それでは！！