🔍

Kotlin Analysis APIを触ってみる

2024/12/02に公開

この記事はMoney Forward Kansai Advent Calendar 2024の2日目の記事です。


こんにちは、株式会社マネーフォワード大阪開発拠点でバックエンドエンジニアをしているTaskです。
Kotlinに新しく導入されたAnalysis APIを紹介したいと思います。

この記事では、以下の内容を扱います。

  • Kotlin Analysis APIの簡単な紹介
  • 簡単なIntelliJプラグインを使ったKotlin Analysis APIの挙動確認

以下の内容を扱いません。

  • Kotlin Analysis APIの詳細な実装の紹介
  • 既存のKotlinコンパイラの説明

また、本記事にはPSI Elementや構文解析に関係するワードが含まれています。
それらの知識があると望ましいですが、なくても内容が伝わるように努めています。

https://plugins.jetbrains.com/docs/intellij/psi-elements.html

Kotlin Analysis APIとは

Kotlin Analysis APIとは、2024年6月のKotlin Festで発表され、同年の夏頃に公開されたAPIです。

https://github.com/JetBrains/kotlin/tree/master/analysis

https://kotlin.github.io/analysis-api/index_md.html

Kotlin Analysis APIでは、Kotlinを構文解析した結果であるPSI Element(抽象構文木のノード)を経由して、Kotlinコンパイラによるコンパイルで得られた情報を取得できます。
ここでの情報とは、変数の型や、特定のスコープにおいてアクセス可能な変数群といった情報が含まれます。

これにより、既存のIntelliJ IDEAのプラグインを改善したり、Kotlinコンパイラを利用する静的解析ツールの性能を強化したりできます。

触ってみる

このAPIは現在IntelliJ Coreに依存しているため[1]、簡単なIntelliJプラグインを実装して挙動を確認してみます。
Kotlin Analysis APIが本当にコンパイル情報を取得できるかを確認するために、Kotlin 2.0で追加されたローカル変数のダウンキャスト情報の保持を題材にします。

https://kotlinlang.org/docs/whatsnew20.html#local-variables-and-further-scopes

sample.kt
class Cat {
    fun purr() {
        println("Purr purr")
    }
}

fun petAnimal(animal: Any) {
    val isCat = animal is Cat
    if (isCat) {
        // In Kotlin 2.0.0, the compiler can access
        // information about isCat, so it knows that
        // animal was smart-cast to the type Cat.
        // Therefore, the purr() function can be called.
        // In Kotlin 1.9.20, the compiler doesn't know
        // about the smart cast, so calling the purr()
        // function triggers an error.
        animal.purr()
    }
}

fun main() {
    val kitty = Cat()
    petAnimal(kitty)
    // Purr purr
}

コメントにもあるように、以前のKotlinではスマートキャストを行う際には、if文の条件式の中で型チェックを行う必要がありました。

fun petAnimal(animal: Any) {
    if (animal is Cat) {
        animal.purr()
    }
}

しかし、Kotlin 2.0では、コンパイラが isCatに関する情報にアクセスできるようになったことから、コンパイラはisCatがtrueな状況下でanimalCatにキャストできます。

1. Plugin DevKitをインストールする

IntelliJプラグイン開発を行うには、まずお手持ちのIDEにPlugin DevKitをインストールする必要があります。
昔はIntelliJ IDEAに同梱されていたのですが、IntelliJ IDEA 2023.3からはプラグインとして提供される形になりました。

https://plugins.jetbrains.com/docs/intellij/developing-plugins.html

2. Projectを作成する

プラグインをインストールしたら、"File > New > Project..."から新規プロジェクトを作成してください。
メニューから"IDE Plugin"を選択できるようになるので、必要な情報を埋めて作成します。

3. 依存を追加、更新する

IntelliJ IDEAやPlugin DevKitのバージョンによっては、依存のバージョンが古くKotlin 2.0を扱えない可能性があります。
僕の場合は、以下のように更新しました。

build.gradle.kts
plugins {
    id("java")

    // old: id("org.jetbrains.kotlin.jvm") version "1.9.25"
    id("org.jetbrains.kotlin.jvm") version "2.0.21"

    // old: id("org.jetbrains.intellij") version "1.17.4"
    id("org.jetbrains.intellij.platform") version "2.1.0"
}
build.gradle.kts
/*
old:
intellij {
    version.set("2023.2.8")
    type.set("IC") // Target IDE Platform

    plugins.set(listOf(/* Plugin Dependencies */))
}
 */

dependencies {
    intellijPlatform {
        intellijIdeaCommunity("2024.3")

        instrumentationTools()

        bundledPlugin("org.jetbrains.kotlin")
    }
}

また、src/resources/META-INF/plugin.xmlに以下を追記します。

plugin.xml
<idea-plugin>
    ...
    <depends>com.intellij.modules.platform</depends>
    <depends>org.jetbrains.kotlin</depends>
</idea-plugin>

4. コードを書く

次に、コード解析を行うコードを実装していきます。
今回は、題材のanimal変数の型を表示するプラグインを作成します。

IntelliJプラグインでは、AnAction#actionPerformedがプラグイン実行のエントリーポイントとなるため、AnActionを継承します。
Kotlin Analysis APIでは、変数の型を取得するexpressionTypeという拡張プロパティが提供されているため、それを利用します[2]

SampleAction.kt
class SampleAction : AnAction() {
    override fun actionPerformed(e: AnActionEvent) {
        val project = e.project ?: return
        // 現在エディタで開いているファイルを取得する
        val currentOpeningFile = FileEditorManager.getInstance(project).selectedFiles[0]
        // ファイルからKotlinファイルの構文情報を取得する
        // この時点では、コンパイラの情報を持っていない
        val ktFile = PsiManager.getInstance(project).findFile(currentOpeningFile) as KtFile
        ApplicationManager.getApplication().executeOnPooledThread {
            ApplicationManager.getApplication().runReadAction { // Analysis APIはReadスレッドからしか使えない
                // Kotlinファイルから、`animal.purr()`の`animal`変数を取得する
                // KtExpressionはPSI Elementの一種
                val animal = ktFile.children[4].children[1].children[1].children[1].children[0].children[0].children[0] as KtExpression
                // Kotlin Analysis APIを利用できるスコープ
                analyze(animal) {
                    // Kotlin Analysis APIを利用し、`animal`の型を取得する
                    val animalTypeGottenByAnalysisApi = animal.expressionType
                    Notifications.Bus.notify(
                        Notification(
                            "Kotlin Analysis API sample",
                            "Type of animal is $animalTypeGottenByAnalysisApi",
                            NotificationType.INFORMATION,
                        ),
                        project,
                    )
                }
            }
        }
    }
}

最後に、SampleActionを実行するためのユーザの操作を定義します。
今回はToolsメニューバーにボタンを追加し、そこから実行できるようにします。

plugin.xml
<idea-plugin>
    ...
    <actions>
        <action class="io.github.t45k.kotlin_analysis_api_trial.SampleAction" text="Sample">
            <add-to-group group-id="ToolsMenu" anchor="first"/>
        </action>
    </actions>
</idea-plugin>

ここまでで、K1コンパイラに対応したプラグインが完成しました。
コードは以下から参照できます。

https://github.com/T45K/kotlin_analysis_api_trial/tree/k1

5. 実行してみる(K1コンパイラ)

それでは、K1コンパイラ上で実行してみます。
./gradlew runIdeでサンドボックス環境を起動できます。
サンドボックスのIntelliJ IDEAが起動したら、そこにサンプルコードを書いて"Tools > Sample"からプラグインを実行します。

実行結果は以下の通りです。

右下のNotificationに表示されている通り、animalはAny型として解決されています。
(余談ですが、サンドボックス環境のK1コンパイラもanimalCat型だと解決できないので、エディタ上でもpurrメソッド呼び出しに対してエラーが起きています)

6. コードを書く(K2コンパイラ対応)

次に、このプラグインをK2コンパイラ環境で実行するために、いくつかの変更を加えます。

まず、プラグインの設定ファイルで、このプラグインがK2コンパイラをサポートしていることを宣言します。

plugin.xml
<idea-plugin>
    ...
    <extensions defaultExtensionNs="org.jetbrains.kotlin">
        <supportsKotlinPluginMode supportsK2="true"/>
    </extensions>
</idea-plugin>

そして、./gradlew runIdeでサンドボックス環境がK2モードで立ち上がるようにします。

build.gradle.kts
tasks {
    ...
    runIde {
        jvmArgs = listOf("-Didea.kotlin.plugin.use.k2=true")
    }
}

これで、K2コンパイラに対応したプラグインが完成しました。
コードは以下から参照できます。

https://github.com/T45K/kotlin_analysis_api_trial/tree/k2

7. 実行する(K2コンパイラ)

実行結果は以下の通りです。

右下のNotificationの通り、animalCat型として解決できています。
これにより、Kotlin Analysis APIを用いることでコンパイラが持つ型解決の情報を、対応するPSI Elementから取得できることが確認できました。
加えて、K2モードを有効化した状態だと、Kotlin 2.0でのコンパイラの改善も活用できることが分かりました。

ドキュメントを読んでみる

ここまでは使用例の紹介でしたが、最後に軽くドキュメントを紹介して、裏側の仕組みや実装時の注意点について確認していきたいと思います。

Kotlin Analysis APIに関する拡張関数

https://kotlin.github.io/analysis-api/fundamentals.html#kotlin-psi

The Analysis API is implemented on top of the Kotlin PSI, mostly as a set of extension functions and properties, providing access to semantic information.

Analysis APIは既存のKtElementの拡張関数の形で提供されます。
実際に、今回使ったexpressionTypeKtExpressionの拡張プロパティの形で宣言されています。

https://github.com/JetBrains/kotlin/blob/70c870be999f8620051dbc865c97d2c609ac5880/analysis/analysis-api/src/org/jetbrains/kotlin/analysis/api/components/KaExpressionTypeProvider.kt#L23

Kotlin Analysis APIを利用できるコンテキスト

https://kotlin.github.io/analysis-api/fundamentals.html#kasession

KaSession is the entry point for interacting with the Analysis API. It provides access to various components and utilities needed for analyzing code in Kotlin.

これらのAPIは、KaSessionをエントリーポイントとして利用できます。
analyzeメソッドはKaSession.() -> Rを引数に持ち、analyze(element) {}のラムダ内でのみAnalysis APIが提供している拡張関数を利用できます[3]

// OK
val ktElement = ...
analyze(ktElement) {
    ktElement.expressionType
}

// NG
ktElement.expressionType

そのため、analyze内の処理をメソッドに切り出す際には、KaSessionの拡張関数の形で実装する必要があります。

val ktElement = ...
analyze(ktElement) {
    check(ktElement)
}

fun KaSession.check(ktElement: KtElement) { ... }

まとめ

今回は、2024年の夏に公開されたKotlin Analysis APIを紹介し、簡単なプラグインを通してその有用性を紹介しました。
このAPIは現在活発に開発中であり、今後より便利なAPIが追加されたり、IntelliJ以外のプラットフォームでも利用できるようになることが期待されます。
特に、既存の静的解析ツール(detektSonarQube)に組み込むことで、より詳細かつ的確な解析結果を得られるようになると思うので、楽しみにしています。

脚注
  1. 今後、スタンドアロンなCLIなどでも使えるように改善されていくようです。ドキュメント中ではLSPサーバーでの活用などが言及されていました。 ↩︎

  2. 2024年12月現在、Kotlin Analysis APIはα版であるため、この記事で紹介している内容に破壊的変更が加わる可能性があります。 ↩︎

  3. Kotlin Analysis APIを利用できない環境(つまり、IntelliJプラグイン開発以外の環境)では、analyzeメソッド呼び出しは例外を投げます。 ↩︎

Money Forward Developers

Discussion