IntelliJ IDEA Plugin from Scratch

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

筆者が把握する限り、IntelliJ IDEA Plugin（以後簡単にIntelliJ Plugin）開発に関連する記事の多くは公式テンプレートの使用にとどまっています。

https://github.com/JetBrains/intellij-platform-plugin-template

しかし、公式テンプレートはCIやテストも含め網羅的にセットアップされていて考慮事項が多く、何をどう作っていったらいいか把握しにくいのが難点です。

本記事では、公式テンプレートに頼らず、自分の手で理解しながらIntelliJ Pluginを実装していきます。

重要なエッセンスのみを抽出した内容となっているので、プラグイン開発の第一歩として活用していただければと思います。

# 本記事でカバーする内容

本記事では、以下の内容をカバーします。

- IDEA Pluginの最小限のプロジェクトセットアップ
- 簡単なカウンタ機能を持つシンプルなGUIツール拡張の作り方
- ランタイム状態保持のベストプラクティス
- データ永続化のベストプラクティス
- プラグインのjarをzip形式で配布する or JetBrains Marketplaceで公開する

# プロジェクトセットアップ

最初にGradleプロジェクトのセットアップをします。

適当なディレクトリを作成し、プロジェクト全体の依存関係の解決方法などを定義する`settings.gradle.kts` を追加しましょう。

```kotlin:intellij-plugin-from-scratch/settings.gradle.kts
rootProject.name = "intellij-plugin-from-scratch"

pluginManagement {
    repositories {
        gradlePluginPortal()
        mavenCentral()
    }
}

dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
}
```

続いて、IntelliJ Pluginのビルド設定を記述した `build.gradle.kts` も追加します。

```kotlin:intellij-plugin-from-scratch/build.gradle.kts
plugins {
    kotlin("jvm") version "2.1.20"
    id("org.jetbrains.intellij.platform") version "2.5.0"
}

repositories {
    intellijPlatform {
        defaultRepositories()
    }
    mavenCentral()
}

dependencies {
    intellijPlatform {
        intellijIdeaCommunity("2025.1")
    }
}
```

最後に、プラグインの識別情報や拡張機能を定義するための `plugin.xml` を追加します。

```xml:intellij-plugin-from-scratch/src/main/resources/META-INF/plugin.xml
<idea-plugin>
    <id>greeting</id>
    <name>Greeting</name>
    <vendor>Your name</vendor>
    <depends>com.intellij.modules.platform</depends>
    <extensions defaultExtensionNs="com.intellij">
        <!-- Add your extensions here -->
    </extensions>
</idea-plugin>
```

:::note info
プラグイン開発に使用している IntelliJ に以下のプラグインを入れておくと xml に補完が効くようになるので、入れておくことをおすすめします。

https://plugins.jetbrains.com/plugin/22851-plugin-devkit
:::

ここまでやったら、以下の手順で正しく設定できているか確認してみましょう。

1. `./gradlew runIde`を実行してテスト用IDEを起動（開発中のプラグインがインストールされた状態で起動します）
1. 適当なプロジェクトを開く
1. Settings（`⌘`+`,`）->Plugins->Installedを確認して「Greeting」が追加されていればOK

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/80be73b8-a883-47e4-b323-0e7e284fe341.png)

最低限の設定は以上となります。次のセクションからは簡単な例としてカウンタ機能のみを持つGUIツールを作成します。

# ToolWindowの実装

IntelliJ プラットフォームにおいて、GUI 拡張は ToolWindow と呼ばれます。

ToolWindow の UI 開発には様々な方法が使えますが、本記事では Jetpack Compose と Jewel というIntelliJプラットフォーム用のコンポーネントライブラリを使用して開発します。

https://github.com/JetBrains/compose-multiplatform

https://github.com/JetBrains/intellij-community/tree/master/platform/jewel

## 依存設定

まず最初に Compose のGradle Pluginを適用します。`build.gradle.kts` の plugins ブロックに以下の2つのプラグインを追加します。

```kotlin
plugins {
    id("org.jetbrains.compose") version "1.7.3"
    id("org.jetbrains.kotlin.plugin.compose") version "2.1.20"
}
```

続いて、`build.gradle.kts` の `repositories` に以下を追加します。それぞれ Jetpack Compose の依存と Jewel の依存を解決するために必要な設定です。

```kotlin
repositories {
    google() // jetpack compose
    maven("https://packages.jetbrains.team/maven/p/kpm/public/") // jewel
}
```

dependenciesに以下を追加します。jewelはlaf-bridgeというサフィックスがついたバージョンを使用し、composeの依存を追加する際はmaterialおよびkotlinxの依存を除外しておきます。

```kotlin
dependencies {
    implementation("org.jetbrains.jewel:jewel-ide-laf-bridge-243:0.27.0")
    api(compose.desktop.currentOs) {
        exclude(group = "org.jetbrains.compose.material")
        exclude(group = "org.jetbrains.kotlinx")
    }
}
```

:::note warn
##### なぜ一部の依存を exclude する必要があるか？
Jetpack Compose の依存を追加する際に、`org.jetbrains.compose.material` および `org.jetbrains.kotlinx` への依存を除外していることに疑問を持つ方も多いと思います。

###### `org.jetbrains.compose.material` について

UI コンポーネントの開発には Jewel 側で定義されているコンポーネントを使用するため、不要です。

###### `org.jetbrains.kotlinx` について

IntelliJ 側でロードされる `kotlinx-coroutines` ライブラリとの衝突を回避するためです。
開発するプラグインが `kotlinx-coroutines` への依存を直接間接問わず持っていると、ランタイムのクラスロードエラーを引き起こすため注意が必要です。

クラスローダー関連のエラーに直面した際は、まずはここを疑ってみましょう。
:::

## ToolWindowFactory の実装

さて、依存の追加も済んだところで、ToolWindow 拡張の実装を進めていきます。
ToolWindow 拡張の実装には、ToolWindowFactory の実装が必要です。`ToolWindowFactory` は、以下のようなインタフェースを持ち、最低限実装が必要なメソッドは一つのみです。

```kotlin:ToolWindowFactory のインタフェース（簡略版）
package com.intellij.openapi.wm

public interface ToolWindowFactory : com.intellij.openapi.project.PossiblyDumbAware {
    public abstract fun createToolWindowContent(project: com.intellij.openapi.project.Project, toolWindow: com.intellij.openapi.wm.ToolWindow): kotlin.Unit
    // 省略
}
```

### ToolWindowFactory の実装と `plugin.xml` への登録

Jewel に定義されている `ToolWindow.addComposeTab` という拡張関数を用いて、簡単に Compose UI を ToolWindow に追加することができます。以下の GreetingToolWindowFactory を実装します。

```kotlin:intellij-plugin-from-scratch/src/main/kotlin/greeting/GreetingToolWindowFactory
package greeting

class GreetingToolWindowFactory : ToolWindowFactory {
    override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
        toolWindow.addComposeTab {
            Text("Hello, IntelliJ IDEA Plugin!!")
        }
    }
}
```

実装した ToolWindow 拡張は、`plugin.xml` の方にも定義が必要です。`plugin.xml` に以下の行を追加します。

```xml:intellij-plugin-from-scratch/src/main/resources/META-INF/plugin.xml
<idea-plugin>
    <id>greeting</id>
    <name>Greeting</name>
    <vendor>Your name</vendor>
    <depends>com.intellij.modules.platform</depends>
    <extensions defaultExtensionNs="com.intellij">
        <!-- 以下を追加 -->
        <toolWindow factoryClass="greeting.GreetingToolWindowFactory" id="Greeting" />
    </extensions>
</idea-plugin>
```

この時点で `./gradlew runIde` を実行しテスト用IDE環境を立ち上げて適当なプロジェクトを開くと、左側に新しく Greeting というタイトルの ToolWindow が追加されていることを確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/ab1198f7-9bde-4474-9db3-ab30bf1f05aa.png)

### 簡単なカウンタ機能を作成する

次に、ToolWindowFactory を改造して簡単なカウンタを実装してみます。

```kotlin:intellij-plugin-from-scratch/src/main/kotlin/greeting/GreetingToolWindowFactory
class GreetingToolWindowFactory : ToolWindowFactory {
    override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
        toolWindow.addComposeTab {
            var counter by remember { mutableIntStateOf(0) }
            DefaultButton(onClick = { counter++ }) {
                Text("Click me!! $counter")
            }
        }
    }
}
```

これで簡単なカウンタ機能を持つ ToolWindow ができました！
しかし、ToolWindow を閉じると状態が消し飛んでしまう問題を抱えていることがわかります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/1d3e6832-8236-41eb-b008-83f2667e4d6f.gif)

# ランタイム状態を適切に保持する

ToolWindow を閉じても状態が保持したい場合もあると思います。

本セクションでは、IntelliJ Platform の中で正しくプラグインのランタイム状態を保持する方法を説明します。

## 状態保持のアンチパターン

通常、アプリケーションのライフサイクルに合わせて状態を保持する場合、以下のやり方を思いつくと思います。

- シングルトンパターンの使用

IDEA Plugin の開発において、シングルトンの使用はアンチパターンとなります。なぜなら、シングルトンは大元のアプリケーション（プラグイン動かす側のIDE）のスコープで生存し、必要のない時でもメモリを占有してしまうためです。

実際に、IntelliJ Platform公式のドキュメントには以下のような記述があります。

> Do not use object but class﻿
>
> Plugins may use Kotlin classes (class keyword) to implement declarations in the plugin configuration file. When registering an extension, the platform uses a dependency injection framework to instantiate these classes at runtime. For this reason, plugins must not use Kotlin objects (object keyword) to implement any plugin.xml declarations. Managing the lifecycle of extensions is the platform's responsibility, and instantiating these classes as Kotlin singletons may cause issues.
> 
> https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#object-vs-class

上記で述べられているのは、概ね以下の内容です。

- IntelliJ は内部のDIフレームワークを用いてプラグインクラスをランタイムにインスタンス化して使用している
- プラグインのライフサイクルの管理をするのは IntelliJ Platform 側の責任であり、Kotlin のシングルトンパターンの使用は問題を起こす可能性がある

companion object や object を使ってランタイム状態を保持することもできるものの、生存スコープがプラグインのライフサイクルを超えてしまうため控えましょう、ということです（控えましょうというよりかは使うなというレベルみたい）。

## サービスを使ってランタイム状態を保持する（推奨）

IntelliJ Platform では、プロジェクト単位もしくはアプリ単位のスコープで常駐するサービスを作成することができます。こちらを使用することで、プラグインのランタイム状態を適切に保持することが可能です。

サービスを作るには、専用のアノテーションを使用します。簡単なカウンタのみを持つ `GreetingCounterStateService` を実装してみましょう。

```kotlin:intellij-plugin-from-scratch/src/main/kotlin/greeting/GreetingCounterStateService
package greeting

@Service(Service.Level.PROJECT) // まずはProjectレベルのサービスを作ってみる
class GreetingCounterStateService {
    var counter by mutableIntStateOf(0)
}
```

あとは、`createToolWindowContent` に渡ってくる `project` を使用してサービスのインスタンスを取得し、サービスが持つ `counter` を参照するように変更します。

```kotlin:intellij-plugin-from-scratch/src/main/kotlin/greeting/GreetingToolWindowFactory
class GreetingToolWindowFactory : ToolWindowFactory {
    override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
        val counterStateService = project.service<GreetingCounterStateService>()
        toolWindow.addComposeTab {
            DefaultButton(onClick = { counterStateService.counter++ }) {
                Text("Click me!! ${counterStateService.counter}")
            }
        }
    }
}
```

すると、ToolWindow を閉じたり開いたりしても、カウンターの状態は残存しリセットされなくなります！

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/f26172e7-84c9-4b76-a8af-28ed2255e7b1.gif)

また、今作成したサービスはプロジェクトレベルのスコープなので、別のプロジェクトを開き ToolWindow を開くと、状態がプロジェクト別に管理されていることを確認できると思います。

:::note
##### APPレベルのサービスに変更してみる
もう大体どうなるかはわかっていると思いますが、余裕があればServiceアノテーションの引数を `Project.Level.APP` に変更してみましょう。

```kotlin
@Service(Project.Level.APP)
class GreetingCounterStateService {
    var counter by mutableIntStateOf(0)
}
```

APPレベルのサービスは、`ApplicationManager`を経由してそのインスタンスへアクセスします。
```kotlin
val counterStateService = ApplicationManager.getApplication().service<GreetingCounterStateService>()
```

複数のプロジェクトウィンドウを跨いで状態が共通管理されていることを確認しましょう！

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/2e1a6451-e6d4-4dd1-a49d-1f32cf6e8dc3.gif)
:::

# 状態を永続化する

通常のServiceクラスが生きているのは、プロジェクトウィンドウまたはIDE自体が生きている間のみです。
プラグインの設定情報など、一部永続化したい状態もあると思います。

ここでは、例としてカウンタのインクリメント値を変更できるようにして、なおかつその設定値を永続化してみましょう。

## PersistentStateComponent で状態を永続化する

PersistentStateComponent を使用すると、JVMオブジェクトをシリアライズ、デシリアライズして状態を永続化することができます。

https://plugins.jetbrains.com/docs/intellij/persisting-state-of-components.html

ドキュメントでは最も単純な方法として、SimplePersistentStateComponentの使用が紹介されていますが、Jetpack ComposeのUI実装視点での使いやすさも考慮して、自分でPersistentStateComponentのインタフェースを実装する手法を紹介します。

PersistentStateComponent は、次の2つのメソッド宣言が必要です。

```kotlin:PersistentStateComponentのインタフェースを簡略化したもの
public interface PersistentStateComponent<T> {
    @Nullable T getState();

    void loadState(@NotNull T var1);
}
```

## カウンタのインクリメント値を永続化する

まずは一番シンプルな実装として、Serviceクラスそのものを状態管理クラスとしてしまうパターンを紹介します。PersistentStateComponentの型引数がServiceクラス自身になっていることに注意してください。

```kotlin:intellij-plugin-from-scratch/src/main/kotlin/greeting/GreetingConfigurationState.kt
package greeting

@Service(Service.Level.APP)
@State(name = "GreetingConfigurationState", storages = [Storage("Greeting.xml")])
class GreetingConfigurationState : PersistentStateComponent<GreetingConfigurationState> {
    var incrementBy: Int by mutableIntStateOf(1)

    override fun getState(): GreetingConfigurationState {
        return this
    }

    override fun loadState(state: GreetingConfigurationState) {
        XmlSerializerUtil.copyBean(state, this)
        // XmlSerializerUtil を使うサンプルが多いが必ずしもそれを使う必要はない。
        // 例えば以下のように書いても同じ。
        // 
        // incrementBy = state.incrementBy
        // 
        // ただしフィールドが増える度に追加しなきゃいけなくなるので、XmlSerializerUtilを使うと少しだけ楽
    }
}
```

IntelliJプラットフォームは永続化された状態をサービスに読み込む際に loadState を呼び出します。そのため、loadState の中で適切にフィールドを初期化し、getState で状態を返すような実装を行います。

Jetpack Composeからアクセスする際は、フィールドを MutableState 系のクラスに委譲すればコンパイラーが自動的に監視対象として認識してコンパイルしてくれるので、簡潔な実装でUIに設定値を公開することができます。

次に、GreetingToolWindowFactoryを以下のように変更します。ざっくり変更点は以下の3つです。

- TextFieldがボタンの下に追加
- TextFieldの値をInt型に変換して設定値を更新するLaunchedEffectを追加
- counter++ だったところを incrementBy だけ増やすように変更

```kotlin
class GreetingToolWindowFactory : ToolWindowFactory {
    override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
        val counterStateService = ApplicationManager.getApplication().service<GreetingCounterStateService>()
        val configurationState = ApplicationManager.getApplication().service<GreetingConfigurationState>()
        toolWindow.addComposeTab {
            val incrementByTextFieldState = rememberTextFieldState(configurationState.incrementBy.toString())

            LaunchedEffect(Unit) {
                snapshotFlow { incrementByTextFieldState.text }
                    .distinctUntilChanged()
                    .collect { text ->
                        configurationState.incrementBy = text.toString().toIntOrNull() ?: return@collect
                    }
            }

            Column {
                DefaultButton(
                    onClick = {
                        counterStateService.counter += configurationState.incrementBy
                    }
                ) {
                    Text("Click me!! ${counterStateService.counter}")
                }
                TextField(
                    state = incrementByTextFieldState,
                    placeholder = { Text("Increment by...") },
                    // 数値だけ受け付ける
                    inputTransformation = {
                        if (this.asCharSequence().any { !it.isDigit() }) {
                            this.revertAllChanges()
                        }
                    },
                )
            }
        }
    }
}
```

これで起動して incrementBy が正しく動いているか、また runIde タスクを再起動しても設定が維持されているかを確認します。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/a11c21eb-0cd5-4fc2-8fd9-11f456a43de9.gif)

問題なく永続化できていますね！

## PersistentStateComponent のハマりどころ

筆者自身この記事を整理する中で、うまく永続化されずハマった点があるので軽く紹介します。

PersistentStateComponentは、型引数に指定されたクラスをほぼ自動的にシリアライズしてくれます。しかし、デフォルトでシリアライズの対象となるのは**プリミティブな型のvarフィールドのみ**です。

例えば、前節で紹介した incrementBy を別のデータクラスの内部に定義したとします。

```kotlin
data class GreetingConfiguration(
    val incrementBy: Int = 1,
)
```

そして、GreetingConfigurationState クラスを以下のように変更したとします。

```kotlin
@Service(Service.Level.APP)
@State(name = "GreetingConfigurationState", storages = [Storage("Greeting.xml")])
class GreetingConfigurationState : PersistentStateComponent<GreetingConfiguration> {
    var configuration by mutableStateOf(GreetingConfiguration())

    override fun getState(): GreetingConfiguration {
        return configuration
    }

    override fun loadState(state: GreetingConfiguration) {
        configuration = state
    }
}
```

そして適宜インタフェースの変更をUIにも反映してビルドしてみると・・
きっと状態がうまく永続化されないという事象にぶち当たります。

GreetingConfigurationのincrementByがvarではなくvalになっているためデフォルトではシリアライズの対象とならないのです。incrementBy を var に変更して再度試してみると、うまくいくはずです。

ただ、せっかくイミュータブルな data class なのに、事故の元にもなる var は使いたくないはずです。その場合は `@Tag("タグ名")` のアノテーションをフィールドに付与してあげることでもシリアライズの対象とできるようです。

```kotlin
data class GreetingConfiguration(
    @Tag("incrementBy")
    val incrementBy: Int = 1,
)
```

若干トリッキーな挙動をしており、扱いが難しいため結構な人がハマると思います。
また、紹介した方法はあくまでプリミティブな型に限定した話です。独自の型がフィールドに含まれる場合は、公式ドキュメントの[こちら](https://plugins.jetbrains.com/docs/intellij/persisting-state-of-components.html#converter-example)を参考に Converter を実装し `@OptionTag` を付与してあげる必要があります。

# プラグインを配布する

お疲れ様でした。最後に、プラグインの配布方法について紹介します。

IntelliJプラットフォームのGradleプラグインによって`buildPlugin`タスクが追加されています。こちらを実行すると build/distributions の配下に zip ファイルができます。
```kotlin
./gradlew buildPlugin
```

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/09a2b0e0-62cd-4896-b5df-8c0eca82afad.png)

あとはIDEの設定を開いてインストールするだけです。

Settings -> Plugins -> タブ右端の歯車アイコン -> Install Plugin from Disk... で zip ファイルを選択

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1971624/9d1b5d01-3f4f-484a-b0c7-6a11a1330521.png)

また、JetBrains Marketplace で配布する際は、buildPluginでできるzipファイルを手動でアップロードすることで公開することができます。CI上で配布する手法については、話が肥大化してしまうため、本記事ではここまでの説明にとどめたいと思います。

また、JetBrains Marketplace で配布する際はplugin.xmlのdescriptionの指定が必須なため注意してください。

# まとめ

本記事では、IntelliJ IDEA Pluginの開発に興味はあれど、なかなか参考資料が少なく手を出せていない方に向けて、確実な理解を持ちながら実装していけるように1から実装していく方法を説明しました。

記事の説明用に作成したプロジェクトは GitHub にて公開しておりますので、適宜そちらも参照しつつ進めていただけるとわかりやすいかと思います（コミットを小分けにしているので、コミット単位でみるとわかりやすいはず）。

https://github.com/kitakkun/intellij-plugin-from-scratch

最後にいつも通り宣伝ですが、発展的な事例として back-in-time-plugin の IntelliJ Plugin を紹介して終わりにしたいと思います。なかなかに規模が大きいため、マルチモジュールでプロジェクトを構成しています。

つい最近アルファ版をリリースしてようやく自由に触れる状態になっているので、興味があればぜひ触ってみてください。

（現状IntelliJプラグインはリリースページにzipを添付する運用になっていますが、後ほどCIに組み込んでJetBrains Marketplaceから落とせるようにする予定）

https://github.com/kitakkun/back-in-time-plugin/tree/main/tooling/

長くなりましたが、ここまで読んでいただきありがとうございました！！！
ぜひ思い思いの IntelliJ Plugin を作っていだたければと思います。