小さいことから試すKSP

Kotlin Symbol Processing (KSP) は、軽量なコンパイラプラグインの開発に使えるAPI です
本記事では、「sealed class/interface を継承・実装するobject をリストアップする拡張プロパティを自動生成する」というテーマでAndroid プロジェクトでKSP を試してみます

https://kotlinlang.org/docs/ksp-overview.html

## 環境
Android Studio Dolphin | 2021.3.1 Beta 1
Kotlin 1.6.21
KSP 1.6.21-1.0.5

## 試してみる
具体的には、以下のような `ListSealed` アノテーションを用意したとき、

```kotlin:ListSealed.kt
@Target(AnnotationTarget.CLASS)
annotation class ListSealed
```

以下のように、`ListSealed` アノテーションを付加した sealed class/interface に対し、続くコードブロックのような拡張プロパティを生成します

```kotlin:MySealedInterface.kt
@ListSealed
sealed interface MySealedInterface {
    object Hoge : MySealedInterface
    object Fuga : MySealedInterface

    companion object
}

object Poyo : MySealedInterface
```

```kotlin:MySealedInterfaceListSealed.kt
val io.github.warahiko.kspsample.MySealedInterface.Companion.objects
        : List<io.github.warahiko.kspsample.MySealedInterface>
    get() = listOf(
        io.github.warahiko.kspsample.MySealedInterface.Hoge,
        io.github.warahiko.kspsample.MySealedInterface.Fuga,
        io.github.warahiko.kspsample.Poyo,
    )
```

### モジュールの追加
Android Studio のメニューから `File > New > New Module...` を選択して作成するのが簡単です
`Java or Kotlin Library` を選択し、適当にライブラリ名などを入れていきます
今回は `listsealed` という名前のモジュールにしています

![スクリーンショット 2022-06-10 0.25.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/156b0608-d69a-9e4e-95dd-6c173678103a.png)

### build.gradle の編集
以下を参考にしながらKSP に関する設定をしていきます

https://kotlinlang.org/docs/ksp-quickstart.html

`org.jetbrains.kotlin.jvm` に関する設定はモジュールの追加時にされているはずなので、それ以外についてみていきます

```diff:(root)/build.gradle
buildscript {
    ...
+    dependencies {
+        classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21'
+    }
}
```

```diff:listsealed/build.gradle
...

+dependencies {
+    implementation("com.google.devtools.ksp:symbol-processing-api:1.6.21-1.0.5")
+}
```

```diff:app/build.gradle
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
+    id 'com.google.devtools.ksp' version '1.6.21-1.0.5'
}

android {
    ...
    // Android Studio にKSP での生成結果を認識させる
+    applicationVariants.all {
+        kotlin.sourceSets {
+            getByName(name) {
+                kotlin.srcDir("$buildDir/generated/ksp/$name/kotlin")
+            }
+        }
+    }
}

dependencies {
    ...
+    implementation(project(':listsealed'))
+    ksp(project(':listsealed'))
}
```

### SymbolProcessor, Provider の用意
次に、実際に処理を行う `SymbolProcessor`, `SymbolProcessorProvider` についてです
`SymbolProcessor` はKSP におけるメインロジックを担う `process()` メソッドをもつinterface であり、これを継承したクラスを用意します
ここで、`CodeGenerator` はファイルの生成および管理を行い、`KSPLogger` はログ出力を行うためのinterface です

```kotlin:ListSealedProcessor.kt
class ListSealedProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger,
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        return emptyList()
    }
}
```

`SymbolProcessorProvider` は　`SymbolProcessor` を生成するための `create()` メソッドをもつinterface であり、これを継承したクラスも用意します

```kotlin:ListSealedProcessorProvider.kt
class ListSealedProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return ListSealedProcessor(
            environment.codeGenerator,
            environment.logger,
        )
    }
}
```

また、このSymbolProcessorProvider を　`com.google.devtools.ksp.processing.SymbolProcessorProvider` というファイルに完全修飾名で記述しておく必要があります
このファイルはKSP モジュールの `resources/META-INF/services/` ディレクトリに配置します
（プロジェクトルートから見ると `listsealed/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider`）

```text:com.google.devtools.ksp.processing.SymbolProcessorProvider
io.github.warahiko.listsealed.ListSealedProcessorProvider
```

### SymbolProcessor の実装

最初に、 `Resolver#getSymbolsWithAnnotation()` メソッドを用いて処理する必要があるシンボルをリストアップします
今回処理するのは sealed class/interface であるため、 そのリストを`KSClassDeclaration` でフィルタリングします

```kotlin:ListSealedProcessor.kt
override fun process(resolver: Resolver): List<KSAnnotated> {
    val symbols = resolver
        .getSymbolsWithAnnotation("io.github.warahiko.listsealed.ListSealed")
        .filterIsInstance<KSClassDeclaration>()
    ...
}
```

次に、処理すべきシンボルを `KSAnnotated#validate()` メソッドで判断します
現在処理できないシンボルのリストは `process()` メソッドの返り値となります
どのようにして処理すべきかが決まるかは以下を参照してください（また、記事の最後に記載するリンクも参考になります）

https://kotlinlang.org/docs/ksp-multi-round.html

```kotlin:ListSealedProcessor.kt
override fun process(resolver: Resolver): List<KSAnnotated> {
    ...
    val (processable, next) = symbols.partition { it.validate() }
    ...
    return next
}
```

処理すべきリストについて、さらに絞っていきます
今回処理する sealed 修飾子がついていない場合、また拡張プロパティを生やすためのCompanion オブジェクトを持っていない場合、error を吐くようにします
これにより、誤ったところにアノテーションをつけていても、ビルド時に落とすことができます

```kotlin:ListSealedProcessor.kt
processable.forEach { symbol ->
    if (Modifier.SEALED !in symbol.modifiers) {
        logger.error(...)
        return@forEach
    }
    if (symbol.declarations.all { (it as? KSClassDeclaration)?.isCompanionObject != true }) {
        logger.error(...)
        return@forEach
    }

    generateList(symbol)
}
```

最後に、シンボルから得られる情報から `CodeGenerator#createNewFile()` メソッドを用いてファイル生成を行います
第二、第三引数にパッケージ名と生成するファイル名を指定してやり、返ってくる　`OutputStream` に書き込んでやることでファイル生成が完了します
第一引数に渡す　`Dependencies` については、Incremental processing に関わる引数になります（こちらも、記事の最後に記載するリンクが参考になります）

https://kotlinlang.org/docs/ksp-incremental.html

```kotlin:ListSealedProcessor.kt
private fun generateList(classDeclaration: KSClassDeclaration) {
    val packageName = classDeclaration.packageName.asString()
    val className = classDeclaration.simpleName.asString()
    codeGenerator.createNewFile(
        Dependencies(aggregating = false, classDeclaration.containingFile!!),
        packageName,
        "${className}ListSealed",
    ).use { stream ->
        val code: String = ...
        stream.write(code.toByteArray())
    }
}
```

生成内容については、得られるサブクラス情報から、object をリストアップしています
object かどうかの判断には `KSClassDeclaration#classKind` が使えます

```kotlin:ListSealedProcessor.kt
val qualifiedClassName = classDeclaration.qualifiedName?.asString() ?: return
val code = buildString {
    appendLine(
        """
    package $packageName

    val $qualifiedClassName.Companion.objects: List<$qualifiedClassName>
        """.trimIndent()
    )

    val sealedSubclasses = classDeclaration.getSealedSubclasses()
        .filter { it.classKind == ClassKind.OBJECT }
    if (sealedSubclasses.none()) {
        appendLine("${indent}get() = emptyList()")
        return@buildString
    }

    appendLine("${indent}get() = listOf(")
    sealedSubclasses
        .forEach { subclass ->
            val qualifiedSubclassName = subclass.qualifiedName?.asString() ?: return@forEach
            appendLine("$indent$indent$qualifiedSubclassName,")
        }
    appendLine("$indent)")
}
```

今回作成したSymbolProcessor 全体は以下になります

https://gist.github.com/warahiko/03ed762ed7cd22d14a82c55deccb35e7

### ビルドする
あとは実際にアノテーション付加・ビルドを行うことで、最初に挙げたようなファイルが自動生成されます

## 詰まったところ

### Could not resolve エラーでビルドが失敗する
KSP モジュール側での build.gradle で、 java に関する設定？があると以下のエラーでビルドが失敗するようです（理由はわかりませんでした）
```
Could not resolve: com.google.devtools.ksp:symbol-processing-api:1.6.21-1.0.5
```
今回は、以下のように設定を削除することでビルドを成功させることができました
```diff
plugins {
-    id 'java-library'
    id 'org.jetbrains.kotlin.jvm'
}

-java {
-    sourceCompatibility = JavaVersion.VERSION_1_7
-    targetCompatibility = JavaVersion.VERSION_1_7
-}
```

### ビルドに成功するのにファイルが生成されない

KSP モジュールでの `build.gradle` で、 `org.jetbrains.kotlin.jvm` とすべきところが `org.jetbrains.kotlin.android` となっていたのが原因でした
こちらも理由はわかりませんでしたが、ビルド自体が問題なく通ってしまっていたため注意が必要かもしれません

## 所感
- 一度できてしまえば案外簡単に書けるので楽しい
- Annotation とProcessor はモジュール分けるといいかもしれない
- 生成部はKotlinPoet を使ってみたい
  - https://square.github.io/kotlinpoet/interop-ksp/
- （テーマについてはreflection 使えば？というところはなくはない）
- companion object なしでもできると嬉しい...[こちら](https://youtrack.jetbrains.com/issue/KT-11968)を待っていればいいんでしょうか？

## 参考
[KSPを使ってコード生成してみる](https://star-zero.medium.com/ksp%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%82%B3%E3%83%BC%E3%83%89%E7%94%9F%E6%88%90%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B-1c2f421aaaa9)
[Kotlin Symbol Processing (KSP) を使ったコード生成 / DroidKaigi 2021](https://speakerdeck.com/star_zero/droidkaigi-2021)
[DroidKaigi 2021 - Kotlin Symbol Processing (KSP) を使ったコード生成 / Kenji Abe [JA]](https://youtu.be/sJfURbrdau0)
[KSPのProcessor実装でハマったこと備忘録](https://qiita.com/tkhskt/items/02b9c609cd71254a45c9)