So What!?

カテゴリ

記事

連絡先

何か間違っている情報などがあればTwitterにてメッセージください。

ご注意

このBlogはアクセスデータをGoogle Analyticsおよび、Cloudflare Analyticsに送信しています。また研究開発用にSentryにパフォーマンスデータを送信しています。それ以外のデータは収集していません。

IntelliJのプラグインを作る-プラグインの設定を追加する編

2024年11月13日

このタイミングでIntelliJ Platform Pluginのバージョンをv2にアップデートした。主にbuild.gradle.ktsの記載の仕方が変わっており、JetBrains Plugin Developer Conf 2024を見てもらえるとよいと思う。

以下の記事がこれまでの記録。

世界中の開発者が開発してくれているプラグインは、いろいろな設定を提供してくれているケースが多く、自作のプラグインでも設定ファイルを自動で検知するか、自分でパスを指定するかの設定を追加したい。

これはユーザ側が明示的に設定できるという利点もあるが、単にパスが明示的である方がプラグイン全体の設計も楽になるので実装したい。

正直ドキュメント見てもなんのこっちゃという感じなので、JetBrainsのGitHubに上がっているtslintをつまみ食いしながら実際に動かしてみることをおすすめしたい。

ある程度作ってみたあとで、ドキュメント(Settings)に立ち戻ることにしよう。

設定の2つのレベル

IDEの設定は以下の2つのレベルに分かれる。

  • グローバルレベル:IDEのテーマや通知設定などプロジェクトによらず共通の設定
  • プロジェクトレベル:バージョン管理システム、コードスタイルなどプロジェクトによって変わる可能性がある設定

この記事で記録するのはプラグインの設定をプロジェクトレベルで保存する内容となる。

設定の基本となるPersistence Model

IDEの設定で変更した内容はPersistence Modelに従って保存される。Persistence Modelはさらに以下の2種類に分かれている。

  • Persisting State of Components
  • Persisting Sensitive Data

Persisting State of Components

IDEの再起動時にコンポーネントやサービスの状態を永続化するためのAPIを提供する。

サービスを永続化させるには以下のステップが必要になる。

  • PersistentStateComponentインターフェースを実装したサービスクラスを実装する
  • 状態クラスを定義する
  • @Stateアノテーションを使って状態を保存する場所を指定する

サービスクラス

自作プラグインの設定はESLintの設定をある程度踏襲しようと思っており、JSのリンターの設定を保存するためのJSLinterConfigurationという抽象クラスがあるのでこれを使う。

ちなみにJSLinterConfigurationクラスはPersistentStateComponentクラスを実装している。

実装だけ省いて書くとこんな感じになるはず。。

@Service(Service.Level.PROJECT)
@State(name = "MyPluginConfiguration", storages = [Storage("jsLinters/MyPlugin.xml")])
class MyPluginConfiguration(
    @NotNull project: Project,
) : JSLinterConfiguration<MyPluginSettingState>(project) {
	override fun savePrivateSettings(state: MyPluginSettingState) {}

	override fun loadPrivateSettings(state: MyPluginSettingState): MyPluginSettingState {}

	override fun toXml(state: MyPluginSettingState): Element {}

	override fun getInspectionClass(): Class<out JSLinterInspection> {}

	override fun fromXml(element: Element): MyPluginSettingState {}
}

状態クラス

JSLinterConfigurationで扱う状態クラスはJSNpmLinterStateを継承する必要がある。

class MyPluginSettingState(
	nodePackageRef: NodePackageRef,
	customConfigFileUsed: Boolean,
	customConfigFilePath: String? = null,
) : JSNpmLinterState<MyPluginSettingState> {
	override fun getNodePackageRef(): NodePackageRef {}

	override fun withLinterPackage(nodePackageRef: NodePackageRef): MyPluginSettingState {}

	override fun equals(other: Any?): Boolean {}

	override fun hashCode(): Int {}

	override fun toString(): String {}
}

詳細はわからんけどXMLにシリアライズして保存してるっぽい。各メソッドが何をするかはメソッド名でなんとなく察する。

ちなみに永続化できるのは以下のデータのみ。

  • numbers (int, Integerなどを含む)
  • booleans
  • strings
  • collections
  • maps
  • enums

※ 他のタイプを永続化したい場合はConverterを使うといいらしい。ここでは触れない。

アノテーション

サービスクラスに以下のようにアノテーションを書く

@State(name = "MyPluginConfiguration", storages = [Storage("jsLinters/MyPlugin.xml")])
  • name: 状態の名前。XMLのルートタグ名になる。必須。
  • Storage: 格納場所の指定。プロジェクトレベルのサービスの時は任意。
  • reloadable: XMLファイルが外部から変更された時、状態が変更されたときにリロードが必要かどうか(falseにするとプロジェクトを完全にリロードする必要がある)

おまけ:SettingSyncによる設定の同期

IntelliJにはSettingSyncという機能が標準で備わっており、異なるマシンでもIDEの設定を同期することができる。

さきに書いた設定をSettingSyncの対象にするには記述するアノテーションにいくつか条件がある。

  • RoamingType@Storageに対して定義されていて、値がDISABLEDになっていないこと
  • SettingsCategoryが定義されていて、値がOTHERになっていないこと
  • 同じXMLファイルに格納されるデータが複数ある場合、RoamingTypeがすべて一致していること

Persisting Sensitive Data

パスワードやサーバURLなどの機密性の高いユーザーデータ(Persisting Sensitive Data)を安全に保存する場合はThe Credentials Store APIを使う。

How to Useに簡単な使い方が書いてあったのでここでは触れない。

Configurableクラスをplugin.xmlに記述する

カスタム設定の実装は、設定用のエクステンションポイントを使ってplugin.xmlに記述することで、その存在をIDEに伝えることができる。

<extensions defaultExtensionNs="com.intellij">
  <projectConfigurable
      parentId="tools"
      instance="com.example.MyPluginConfigurable"
      id="com.example.MyPluginConfigurable"
      displayName="My Project Settings"
      nonDefaultProject="true"/>
</extensions>

parentIdはIDEの数ある設定カテゴリのどこに設定を表示するかを指定することになる。IDEに標準で用意されているカテゴリのIDは決まっているのでそこから選ぶことになる。

設定グループの親子関係を作成するにはCustom Settings Groupsを読むとよい。

プログラムで親子関係定義することもできるけど、実行時だとパフォーマンスが悪いので、設定に限らずだけどplugin.xmlで定義しておけるものは定義しておくのがベストプラクティスっぽい。

projectConfigurableエントリポイントに指定する実装はConfigurableクラスConfigurableProviderクラスのどちらかになる。

Configurableクラスはinstanceに指定し、ConfigurableProviderクラスはproviderに指定する。

ConfigurableはJavaDoc読むことをおすすめすると書いてある。

とりあえず箇条書きのメモ。

  • IDEの設定ダイアログで実装した設定項目がクリックしたタイミングで、Configurableクラスのコンスタラクタが呼び出される
  • IDEの設定ダイアログを閉じたタイミングでインスタンスは終了する
    • 終了するタイミングでConfigurable.disposeUIResources()が呼び出される
  • プログラムから設定ダイアログを開きたい場合はShowSettingsUtilを使うと便利

今回のプラグインではESLintの設定を踏襲するためにJSLinterConfigurableというクラスを継承して作る。

class MyPluginConfigurable(
	project: Project,
) : JSLinterConfigurable<MyPluginSettingState>(project, MyPluginConfiguration::class.java, true) {
	override fun createView(): JSLinterView<MyPluginSettingState> {
		return MyPluginSettingsView(myProject, displayName, MyPluginSettingsComponent(project, isFullModeDialog, false))
	}

	override fun getId(): String {
		return "com.example.MyPluginConfigurable"
	}

	override fun getDisplayName(): String {
		return "My Project Settings"
	}

	private class MyPluginSettingsView(
		project: Project,
		displayName: String,
		panel: MyPluginSettingsComponent,
	) : NewLinterView<MyPluginSettingState>(
        project,
        displayName,
        panel.getPanel(),
    ) {
		private val myPanel: MyPluginSettingsComponent = panel

		override fun getStateWithConfiguredAutomatically(): MyPluginSettingState {
			return MyPluginSettingState.DEFAULT.withLinterPackage(AutodetectLinterPackage.INSTANCE)
		}

		override fun handleEnabledStatusChanged(enabled: Boolean) {
			myPanel.handleEnableStatusChanged(enabled)
		}

		override fun setState(state: MyPluginSettingState) {
			myPanel.setState(state)
		}

		override fun getState(): MyPluginSettingState {
			return myPanel.getState()
		}
	}
}

実際に設定ダイアログに何を表示するかは、上記のコードで言うとMyPluginSettingsViewクラスに書かれている。

MyPluginSettingsViewではKotlinでUIを宣言するためのKotlin UI DSLを使って書いていくことになる。

Kotlin UI DSLについては自分でも少しまとめるつもりだが、別の方の記事でIntelliJ Platform Pluginの開発にて、Kotlin UI DSL Version 2 や Swing を使って、ToolWindow上にコンポーネントを表示してみたという記事がコード、キャプチャ両方セットでまとめてくれていたのでわかりやすいとおもう。

正直ここまで文章を書いている自分以外には、うまく伝わらないことだらけな気がするが、、とりあえずいつか自分で見返す時が来た時のために、IDEとカスタム設定のタッチポイントの記録ということで…。

おまけのおまけ

1つだけハマりポイントがあったのでメモ。作成した設定を変更して「apply」しようとすると、

Can’t find tools for “MyPlugin” in the profile “Project Default”

というエラーがでて変更した設定が反映されなかった。

静的コード解析に関する設定を追加する際はどうやらCode Inspectionsに関する設定も追加する必要があるらしい。

コード・インスペクション

コード・インスペクションは静的コード解析用に設計されたツール群で、編集中のファイルを走査して問題のある構文を検査したり、クイックフィックス機能を提供することができる。

WebStormにおけるインスペクション機能について知りたい場合はCode inspectionsを見るとよいと思う。

各インスペクションツールの設定は「プロファイル」としてグループ化されていて、ツールごとに検査するファイルの範囲や重大度の変更ができるようになっている。

今回作成しているプラグインも静的コード解析ツールなので、同様にプロファイルを提供する必要があるが、デフォルトのプロファイルである「Project Default」にその設定がないことでエラーになっていたようだ。

ちなみに、言語ごとに提供されているインスペクション機能はNew Inspections in This Release | Inspectopedia Documentationを見るとよい。

インスペクション機能の実装

インスペクション機能を提供するには、以下の2種類のエクステンションポイントのどちらかを使う。

  • localInspection:一度に1つのファイルを動作するインスペクションに使用する
  • globalInspection:複数のファイルにまたがって動作するインスペクションに使用する

今回は場合は1つのファイルに対して動作するのでlocalInspectionを使う。

指定できる各属性はcomparing_string_references_inspectionというサンプルツールのコードを見るのがわかりやすいと思う。(というか他にドキュメントっぽいのが見当たらなかった)

<extensions defaultExtensionNs="com.intellij">
    <localInspection language="JAVA"
         bundle="messages.InspectionBundle"
         key="inspection.comparing.string.references.display.name"
         groupPath="Java"
         groupBundle="messages.InspectionsBundle"
         groupKey="group.names.probable.bugs"
         enabledByDefault="true"
         level="WARNING"
         implementationClass="org.intellij.sdk.codeInspection.ComparingStringReferencesInspection"/>
</extensions>

implementationClassに指定するクラスはどの言語を扱うかによって参照するスーパークラスが変わってくるみたいだが、今回はJSLinterInspectionというクラスを継承したクラスを指定した。

実装を除いたコードは以下のような感じ。

class MyCustomInspection : JSLinterInspection() {
    override fun chooseSeverity(
        fromError: HighlightSeverity,
        inspectionSeverity: HighlightSeverity,
    ): HighlightSeverity {}
    
    override fun getOptionsPane(): OptPane {}
    
    override fun ensureServiceStopped(project: Project) {}
    
    override fun getBatchSuppressActions(element: PsiElement?): Array<SuppressQuickFix> {}
    
    override fun getGroupDisplayName(): String {}
    
    override fun getSettingsPath(): List<String> {}
}

各インスペクション機能には、機能を説明するためのHTMLファイルをセットで用意する必要がある。

resourcesフォルダ内にinspectionDescriptionsフォルダを作ってその中にHTMLファイル作成するが、その時のファイル名はJSLinterInspectionクラスを継承したクラスから「Inspection」をとったものになる。

先ほどのサンプルコードの場合は「MyCustom.html」を作って、その中に説明文を書く。

HTMLのファイル名を変更したい場合はgetShortNameメソッドをオーバーライドして、ファイル名を指定することもできる。ただし、その場合はplugin.xmlにもshortNameを指定することが推奨されている。plugin.xmlに書くことで、クラスを参照するよりも前にIDEがHTMLファイルを検知でき、パフォーマンスがよい。