KotlinによるIntelliJ Platform Plugin開発で、道具箱に磨きをかけよう!

2023/12/05に公開

皆さんこんにちは、株式会社ログラスでVPoEをしているいとひろと申します。

生まれてこの方、IDEはIntelliJ IDEAで育ってきたJava系エンジニアです(IntelliJ IDEA歴18年。途中ブランク有り)。

今回は、このIntelliJ IDEAをはじめとするJetBrains製IDEのプラグイン開発についての記事です。

Kotlinを使ったIntelliJ Platform Plugin開発のモチベーション

Kotlinで開発をしている皆さんは、IntelliJ IDEAあるいはAndroid StudioをIDEとして利用している方が多いのではないでしょうか。

これらJetBrain製IDEのベースとなっているThe IntelliJ Platformは、実はKotlinを用いてPlugin開発をすることができます。なんせIntelliJ IDEAもKotlinもJetBrains社が開発したものですからね。

普段IDEで開発をしていて、あれができたらいいのに、これができたらもっと便利なのにと思ったこと、ありませんか?

本記事では、Kotlinを用いたIntelliJ Platform Plugin開発のとっかかりとなる、Plugin Tmplateの紹介と、テンプレートで作られるデフォルトのプラグイン構造について解説します。

どうやって始めるの?

まずは、IntelliJ Platform Plugin Templateを使ってリポジトリを作成しましょう。「Use this template」のメニューから「Create a new repository」を選択します。

こんな感じになるので、名前をつけてリポジトリを作成します。

作成されたリポジトリは、このようなディレクトリ構造になっています。

リポジトリをクローンしたら、以下のようにビルドを走らせてみましょう。

# ./gradlew buildPlugin

2023年12月3日時点では、途中で以下のような警告が出ますが、ビルド自体はSUCCESSとなります。テンプレートの動作確認自体には影響がないので一旦おいておきましょう。

> Task :buildSearchableOptions
CompileCommand: exclude com/intellij/openapi/vfs/impl/FilePartNodeRoot.trieDescend bool exclude = true
2023-12-03 21:17:07,759 [   2155]   WARN - #c.i.u.n.s.ConfirmingTrustManager - Received an empty list of custom trusted root certificates from the system. Check log above for possible errors, enable debug logging in category 'org.jetbrains.nativecerts' for more information
java.lang.Error: no ComponentUI class for: com.intellij.util.ui.tree.PerFileConfigurableBase$PerFileConfigurableComboBoxAction$1[,0,0,0x0,invalid,alignmentX=0.0,alignmentY=0.0,border=,flags=0,maximumSize=,minimumSize=,preferredSize=,defaultIcon=,disabledIcon=,disabledSelectedIcon=,margin=null,paintBorder=true,paintFocus=true,pressedIcon=,rolloverEnabled=false,rolloverIcon=,rolloverSelectedIcon=,selectedIcon=,text=,defaultCapable=true]
        at java.desktop/javax.swing.UIDefaults.getUIError(UIDefaults.java:763)
        at java.desktop/javax.swing.MultiUIDefaults.getUIError(MultiUIDefaults.java:144)
        at java.desktop/javax.swing.UIDefaults.getUI(UIDefaults.java:793)
        at java.desktop/javax.swing.UIManager.getUI(UIManager.java:1078)
        at java.desktop/javax.swing.JButton.updateUI(JButton.java:144)
        at com.intellij.openapi.actionSystem.ex.ComboBoxAction$ComboBoxButton.updateUI(ComboBoxAction.java:372)
        at java.desktop/javax.swing.AbstractButton.init(AbstractButton.java:2141)
        at java.desktop/javax.swing.JButton.<init>(JButton.java:134)
        at java.desktop/javax.swing.JButton.<init>(JButton.java:88)
        at com.intellij.openapi.actionSystem.ex.ComboBoxAction$ComboBoxButton.<init>(ComboBoxAction.java:152)
        at com.intellij.util.ui.tree.PerFileConfigurableBase$PerFileConfigurableComboBoxAction$1.<init>(PerFileConfigurableBase.java:882)
        at com.intellij.util.ui.tree.PerFileConfigurableBase$PerFileConfigurableComboBoxAction.createComboBoxButton(PerFileConfigurableBase.java:882)
        at com.intellij.openapi.actionSystem.ex.ComboBoxAction.createCustomComponent(ComboBoxAction.java:86)
        at com.intellij.util.ui.tree.PerFileConfigurableBase.createActionPanel(PerFileConfigurableBase.java:614)
        at com.intellij.util.ui.tree.PerFileConfigurableBase.createActionPanel(PerFileConfigurableBase.java:606)
        at com.intellij.util.ui.tree.PerFileConfigurableBase.createActionPanel(PerFileConfigurableBase.java:600)
        at com.intellij.util.ui.tree.PerFileConfigurableBase.createDefaultMappingComponent(PerFileConfigurableBase.java:222)
        at com.intellij.util.ui.tree.PerFileConfigurableBase.createComponent(PerFileConfigurableBase.java:181)
        at com.intellij.openapi.vfs.encoding.FileEncodingConfigurable.createComponent(FileEncodingConfigurable.java:159)
        at com.intellij.openapi.options.ex.ConfigurableWrapper.createComponent(ConfigurableWrapper.java:169)
        at com.intellij.ide.ui.search.SearchUtil.processConfigurables(SearchUtil.java:75)
        at com.intellij.ide.ui.search.TraverseUIStarter.lambda$startup$0(TraverseUIStarter.java:120)
        at java.desktop/java.awt.event.InvocationEvent.dispatch(InvocationEvent.java:308)
        at java.desktop/java.awt.EventQueue.dispatchEventImpl(EventQueue.java:792)
        at java.desktop/java.awt.EventQueue$3.run(EventQueue.java:739)
        at java.desktop/java.awt.EventQueue$3.run(EventQueue.java:733)
        at java.base/java.security.AccessController.doPrivileged(AccessController.java:399)
        at java.base/java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:86)
        at java.desktop/java.awt.EventQueue.dispatchEvent(EventQueue.java:761)
        at com.intellij.ide.IdeEventQueue.defaultDispatchEvent(IdeEventQueue.java:909)
        at com.intellij.ide.IdeEventQueue._dispatchEvent(IdeEventQueue.java:756)
        at com.intellij.ide.IdeEventQueue.lambda$dispatchEvent$5(IdeEventQueue.java:437)
        at com.intellij.openapi.progress.impl.CoreProgressManager.computePrioritized(CoreProgressManager.java:787)
        at com.intellij.ide.IdeEventQueue.lambda$dispatchEvent$6(IdeEventQueue.java:436)
        at com.intellij.openapi.application.TransactionGuardImpl.performActivity(TransactionGuardImpl.java:105)
        at com.intellij.ide.IdeEventQueue.performActivity(IdeEventQueue.java:615)
        at com.intellij.ide.IdeEventQueue.lambda$dispatchEvent$7(IdeEventQueue.java:434)
        at com.intellij.openapi.application.impl.ApplicationImpl.runIntendedWriteActionOnCurrentThread(ApplicationImpl.java:838)
        at com.intellij.ide.IdeEventQueue.dispatchEvent(IdeEventQueue.java:480)
        at java.desktop/java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:207)
        at java.desktop/java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:128)
        at java.desktop/java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:117)
        at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:113)
        at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:105)
        at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:92)

...中略...

BUILD SUCCESSFUL in 9m 10s
14 actionable tasks: 14 executed
Configuration cache entry stored.

次に、IntelliJ上のGradleでビルドできるようにしましょう。まず Build, Execution, Development > Build Tools > Gradle 内で、Javaのバージョン17系のSDKをセットします。

上記をセットすると、IntelliJ上でのGradleから Tasks > intellij > buildPlugin を走らせると、ターミナルと同様にSUCCESSになるはずです。

さて、このテンプレートはクローンしたらそのままプラグインとして動作するようにできているので、動作確認をしてみましょう。

Pluginの動作確認をしてみよう

テンプレートから作成されたプロジェクトを立ち上げると、IntelliJ上でGradleのRun Configurationが読み込まれ、以下のように「Run Plugin」をIDEから走らせることができます。( IntelliJ上にGradle Pluginがインストールされている必要があります)

「Run Plugin」を走らせてみると、以下のようなJetbrains Community Editionの同意画面が出てくるので、チェックボックスにチェックを入れて「Continue」を押しましょう。

すると、新たなIntelliJインスタンスが立ち上がり、以下のようなWelcome画面が表示されます。これは何が起きているかというと、Gradleで「Run Plugin」を走らせたことによって、PluginがインストールされたIntelliJ IDEAのコミュニティエディションに相当するIDEが立ち上がったということです。

では、ここからPluginの挙動を確認していきましょう。

インストールされたPluginの概要の確認

こちらの「Plugins」の項目を開き、

さらに「Installed」のタブを開いてみましょう

Pluginがインストールされているのがわかります。テンプレートから作成されたプラグインの詳細やCHANGELOGもここに表示されるようになります。

IDE上でPluginの挙動を確認

では、元の画面に戻って、通常のIDEとして立ち上げ、Pluginの挙動を確認してみましょう。

「New Project」でも「Open」でも「Get from VCS」でも良いので、お好きな方法で任意のプロジェクトを立ち上げてみましょう。

プロジェクトを開くと、IntelliJのツールウィンドウメニューに「MyToolWindow」という名前のツールウィンドウが現れます。最初に開く際はIntelliJがインデックスするのに時間がかかる場合があります。

MyToolWindowが開くと、ランダムに数字を表示してくれる「Shuffle」ボタンが現れます。押すたびにランダムな数字が表示される簡単なプラグインです。

Pluginの構造を理解する

さて、サンプルとして実装されているMyToolWindowですが、どのような仕組みで動いているのでしょうか。Pluginの構造を理解してみましょう。

Plugin設定ファイル: plugin.xml

プラグインの設定ファイルである plugin.xml ファイルは src/main/resources/META-INF 以下に存在しています。このファイルではプラグインの一般情報と、依存関係、拡張ポイント、リスナー等が管理・設定されます。

IntelliJ Platform Plugin Templateでは、以下のような設定がデフォルトで適用されています(見やすいように改行を入れています)。

ここでは、extensions として toolWindow が設定され、MyToolWindow というidで MyToolWindowFactory が呼び出されます。

また、 applicationListeners として 、ApplicationActivationListener というtopicに対して MyApplicationActivationListener クラスが登録されています。

このサンプルに必要なコードとしては、必要最低限レベルの以下のコードが含まれています。

.
├── listeners
│   └── MyApplicationActivationListener.kt  Application activation listener — detects when IDE frame is activated
├── services
│   └── MyProjectService.kt                 Project level service
├── toolWindow
│   └── MyToolWindowFactory.kt              Tool window factory — creates tool window content
└── MyBundle.kt                             Bundle class providing access to the resources messages

リスナークラス: MyApplicationActivationListener.kt

IDEのフレームがアクティベートされた際に applicationActivated(ideFrame: IdeFrame) メソッドが呼び出されます。

ここでは単純にログだけが出力されています。

package com.github.itohiro73.intellijpluginkotlinhelloworld.listeners

import com.intellij.openapi.application.ApplicationActivationListener
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.wm.IdeFrame

internal class MyApplicationActivationListener : ApplicationActivationListener {

    override fun applicationActivated(ideFrame: IdeFrame) {
        thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.")
    }
}

ファクトリクラス: ToolWindowFactory.kt

toolWindowに登録されるWindowのメインコンテンツを提供するファクトリクラスです。ここではボタンが押されるたびにランダムに数字を表示する機能が提供されています。

package com.github.itohiro73.intellijpluginkotlinhelloworld.toolWindow

import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBPanel
import com.intellij.ui.content.ContentFactory
import com.github.itohiro73.intellijpluginkotlinhelloworld.MyBundle
import com.github.itohiro73.intellijpluginkotlinhelloworld.services.MyProjectService
import javax.swing.JButton


class MyToolWindowFactory : ToolWindowFactory {

    init {
        thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.")
    }

    override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
        val myToolWindow = MyToolWindow(toolWindow)
        val content = ContentFactory.getInstance().createContent(myToolWindow.getContent(), null, false)
        toolWindow.contentManager.addContent(content)
    }

    override fun shouldBeAvailable(project: Project) = true

    class MyToolWindow(toolWindow: ToolWindow) {

        private val service = toolWindow.project.service<MyProjectService>()

        fun getContent() = JBPanel<JBPanel<*>>().apply {
            val label = JBLabel(MyBundle.message("randomLabel", "?"))

            add(label)
            add(JButton(MyBundle.message("shuffle")).apply {
                addActionListener {
                    label.text = MyBundle.message("randomLabel", service.getRandomNumber())
                }
            })
        }
    }
}

プロジェクトレベルサービス: MyProjectService.kt

プロジェクトレベルでアクセスできるサービスとして、 MyProjectService を実装しています。ここではランダムな数字を生成できるシンプルなサービス実装となっています。このサービスは MyToolWindowFactory から活用されています。

package com.github.itohiro73.intellijpluginkotlinhelloworld.services

import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import com.github.itohiro73.intellijpluginkotlinhelloworld.MyBundle

@Service(Service.Level.PROJECT)
class MyProjectService(project: Project) {

    init {
        thisLogger().info(MyBundle.message("projectService", project.name))
        thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.")
    }

    fun getRandomNumber() = (1..100).random()
}

バンドルクラス: MyBundle.kt

リソースのMessageにアクセスするためのバンドルクラスです。MyToolWindowFactory のファクトリクラス内では、ランダムに生成した数字をラベルに表示するための randomLabel というキーのメッセージに対してアクセスしています。また、 MyProjectService 内では projectService キーに対してメッセージアクセスしています。

package com.github.itohiro73.intellijpluginkotlinhelloworld

import com.intellij.DynamicBundle
import org.jetbrains.annotations.NonNls
import org.jetbrains.annotations.PropertyKey

@NonNls
private const val BUNDLE = "messages.MyBundle"

object MyBundle : DynamicBundle(BUNDLE) {

    @JvmStatic
    fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any) =
        getMessage(key, *params)

    @Suppress("unused")
    @JvmStatic
    fun messagePointer(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any) =
        getLazyMessage(key, *params)
}

MyBundle.properties

projectService=Project service: {0}
randomLabel=The random number is: {0}
shuffle=Shuffle

テンプレートで実装されている内容としては以上がメインで、ここを起点にドキュメントやコードを参照しながらいろいろいじってみると、ご自身の手に馴染むプラグインのアイデアが浮かんでくるかもしれません。

終わりに

以上のように、 IntelliJ Platform Plugin Template を活用すると、非常にシンプルなツールウィンドウの実装からHello World的なプラグイン開発をスタートすることができます。ぜひ皆さんもお手元でまずは遊んでみていただければと思います。

これを機に、みなさんが普段使っている道具箱(IDE)をKotlinを用いて拡張し、開発者体験を向上してみてください!

リソース:
GitHub repository: IntelliJ Platform Plugin Template
IntelliJ Platform Plugin SDK Home

株式会社ログラス テックブログ

Discussion