IntelliJのプラグインを作る-npmパッケージを動かすぞ編
2024年6月4日
※ この記事を書いた後にIntelliJ Platform Pluginのメジャーバージョンアップがあり、かなり大幅に変わっているので、もしかしたら参考にならないかもしれない。変更点はJetBrains Plugin Developer Conf 2024を見てもらえるとよいと思う。
以下の2記事がこれまでの記録。
今回はプラグインの中でnpmパッケージ、つまりNode.jsを動かすところまでやっていこう。
※ 正直なところプラグインの作り方は情報がまとまっておらず、進めるのが難しかったので、accessibility-linterを8割パクらせてもらっています。
ファイルを置く
とりあえずはNode.jsのアプリケーションを作る時と同様にpackage.json
をプロジェクト内に設置する。フォルダ構造は以下のようにした。
src/
└─ main/
├─ javascript/
│ └─ src/
│ ├─ node_modules/
│ ├─ test/
│ ├─ index.js
│ └─ package.json
├─ kotlin/
└─ resources/
test
は必須ではない。index.js
はCommonJSで書く必要があるので要注意。
サンドボックス環境を作る
プラグインはJavaベースなので、Node.jsはそのまま動かせない。
プラグインのディストリビューションに外部ファイルを含める場合は、Gradle IntelliJ Pluginに含まれるprepareSandboxを使う。
今回はnpmパッケージをまるまる外部ファイルとして含める。理由は2つ。
- プラグインからユーザーのプロジェクト内に存在するnode_modules内のパッケージを参照する方法が単純に分からない
- node_modulesがなくてもプラグインの基本機能は動作するように作りたい
build.gradle.kts
に以下のように記述する。
tasks {
prepareSandbox {
val libraries = "${destinationDir}/myproject/lib/"
copy {
from("${project.projectDir}/src/main/javascript/node_modules")
into("${libraries}/node_modules/")
}
}
}
ちなみにcopy
はGradleのAPIで、from
でコピー元、into
でコピー先を指定する。
これでビルドすると、build/idea-sandbox/plugins/myproject/lib/
配下にnode_modules
がコピーされる。
プラグインからJavaScriptの関数を呼び出す
1日目の記事で書いたとおり、externalAnnotator
は以下のステップで動く。
- ファイルに関するデータを収集
- ツールを実行してハイライトデータを収集
- 最終的にハイライトデータをファイルに適用する
今回は2のステップでJavaScriptの関数を呼び出すことになるが、部分的に説明するのが難しいので1から説明しよう。
ExternalAnnotator
現状のファイル構成は以下のようになっている。
src/
└─ main/
└─ kotlin/
└─ com.github.tkskto.myproject/
├─ annotators/
│ ├─ AnnotatorBase.kt
│ └─ HtmlAnnotator.kt
└─ service/
└─ LinterService.kt
今はHTMLファイルだけを対象としているけど、他の拡張子に対してもアノテーションできるようにするために、AnnotatorBase.kt
を作っておき、他のファイルについても拡張しやすいように作る。
AnnotatorBase.kt
はExternalAnnotatorを継承し、以下のようなインターフェースになっている。
abstract class AnnotatorBase : ExternalAnnotator<CollectedInformation, List<CustomAnnotation>>() {
/**
* ツールの起動に必要な初期情報を収集する
* @param file アノテーションの対象になるファイルの情報
* @param editor ファイルのドキュメントが存在するエディタ
* @param hasErrors 前の解析で検出されたエラーがファイルにあるかどうか
* @return doAnnotateメソッドに渡す情報
*/
override fun collectInformation(file: PsiFile, editor: Editor, hasErrors: Boolean): InitialInfoType {
// 後述するServiceにファイルの内容を送信する
val linterService = file.project.service<LinterService>()
val lintResponse = linterService.runRequest(
file.text,
file.name,
)
return CollectedInformation(
lintResponse,
)
}
/**
* アノテーションに必要なすべての情報を受け取る
* 関数内ではインデックスやPSIへのアクセスを避けるか、チェックとロックを自分で行う必要がある
* @param collectedInfo collectInformationによって収集された情報
* @return annotations applyメソッドに渡す情報
*/
override fun doAnnotate(collectedInformation: InitialInfoType?): AnnotationResultType {
}
/**
* 作成したアノテーション用のデータをファイル上に反映する
* @param file 現在IDEで開いているファイルのPSI Filesオブジェクト
* @param annotationResult doAnnotateメソッドでreturnしたアノテーション情報のリスト
* @param holder IDEにアノテーションを反映するためのオブジェクト
*/
override fun apply(file: PsiFile, annotationResult: AnnotationResultType, holder: AnnotationHolder) {
}
}
ざっくりとした流れは以下のようになる。
collectInformation
メソッドで現在IDEが開いてるファイルの情報を受け取り、その内容をnpmパッケージに渡して、npmパッケージ側で解析をして、結果を受け取る- 受け取った結果を
doAnnotate
メソッドに渡す doAnnotate
メソッドでは、受け取ったデータをアノテーション用のオブジェクトに変換する- 変換した結果を
apply
メソッドに渡す apply
メソッドでは、受け取ったデータもとにIDEに表示するツールチップ用のHTMLなどをつくってIDEに反映する
LinterService
肝心のプラグインとnpmパッケージ間通信を実現するのがJSLanguageServiceBase
とJSLanguageServiceNodeStdProtocolBase
の2つのクラス。しかし、インターネットではJSLanguageServiceBase
に関するドキュメントを見つけられなかった。
ので、既存のプラグインの実装やクラスの実装を見て探っていくしかなさそう。この辺り詳しい人いたら教えてほしい。
LinterService
はAnnotatorBase
のcollectInformation
メソッドで受け取ったファイルの情報をもとに、npmパッケージを実行して、結果を受け取るクラスとして作る。
@Service(Service.Level.PROJECT)
class LinterService(project: Project): JSLanguageServiceBase(project) {
// サービスの初期化的なもの
override fun createLanguageServiceQueue(): JSLanguageServiceQueue {
val protocol = ServiceProtocol(myProject, EmptyConsumer.getInstance<Any>())
return JSLanguageServiceQueueImpl(myProject, protocol, myProcessConnector, myDefaultReporter, JSLanguageServiceDefaultCacheData())
}
override fun needInitToolWindow() = false
/**
* prepareSandboxでコピーしたindex.jsにHTMLテキストを送信する
*/
fun runRequest(input: String, fileName: String): CompletableFuture<JSLanguageServiceAnswer?>? {
// Node.jsに送信する
return sendCommand(SimpleCommand(input, fileName)) { _, answer ->
answer
}
}
class SimpleCommand(val input: String, val fileName: String): JSLanguageServiceSimpleCommand, JSLanguageServiceObject {
override fun getCommand() = "myProject"
override fun toSerializableObject(): JSLanguageServiceObject {
return this
}
}
}
class ServiceProtocol(project: Project, readyConsumer: Consumer<*>): JSLanguageServiceNodeStdProtocolBase(project, readyConsumer) {
override fun createState(): JSLanguageServiceInitialState {
val result = JSLanguageServiceInitialState()
result.pluginName = "myProject"
// prepareSandboxでコピーした先のパスを指定する
val file = JSLanguageServiceUtil.getPluginDirectory(this.javaClass, "lib/index.js")
result.pluginPath = LocalFilePath.create(file.absolutePath)
return result
}
}
正直なところかなりハマる。Node.js側で投げられた例外を受け取る方法とかもよくわからないので、その辺はこれから詰めていきたい。