電通総研 テックブログ

電通総研が運営する技術ブログ

IntelliJプラグイン開発の始め方~アクション機能編~

こんにちは。電通総研ITの寺尾です。
前回ご紹介したプラグイン開発環境構築に続き、今回から実際に機能実装についてお話していきます。
本記事でご紹介する機能はアクション機能です。

前回はこちら:IntelliJプラグイン開発の始め方~環境構築編~

アクション機能とは

IntelliJにおけるアクションとは、メニューやショートカットで呼び出せる機能全般を指します。

普段IntelliJを使う際に、ショートカットキーによるファイル検索、Gitウィンドウでの操作、Runメニューからのデバッグ起動などをすることはないでしょうか。
実はこれらも、IntelliJ上ではアクションとして実装された機能となっています。

Doma Tools」で実装したアクションを例に、任意のメニューに独自のアクションを追加する方法についてご説明します。

公式ドキュメント カスタムアクションの作成

実装前に知っておくこと

実装言語

公式ドキュメントではJavaコードのサンプルが提示されていますが、「Doma Tools」ではテンプレートをベースにKotlinで実装しています。

KotlinはJavaと非常によく似た実装が可能な言語ですが、以下の特徴を持つKotlinを採用しています。
Kotlinでプラグインを開発する利点

  • null安全性:変数宣言時に、Null許容かを明示的に指定できます
  • 型安全ビルダー:静的に必須パラメータをチェックし、コンパイルが成功する状態でのみ、ビルド処理が呼び出せるようにします
  • コールバック:IntelliJプラットフォームが多用するコールバックを、Kotlinはラムダ式によって容易に実装できます

他にも保守性やパフォーマンスに効果のある機能を持ち、IntelliJプラグイン開発において非常に相性の良い言語となっています。個人的にも、null安全性は開発において特に便利な点だと感じています。

これからプラグイン開発をされる方も、Kotlinで実装してみてください!
Kotlinプロジェクトに関する詳細は、Kotlinをご参照ください。

【補足】
簡単なKotlinコードをウェブ上で実行することもできますので、この機会にKotlinにも触れてもらえればと思います!
Play Kotlin

PsiElement

IntelliJプラグインの実装ではそのほとんどでPsiElementのオブジェクトを扱います。
これはファイルやディレクトリ、ファイル内のメソッド名やパラメータなどあらゆる要素を表す基本的な型になっており、
IntelliJ上のあらゆる要素はこの型によるツリー構造を持っています。

サブタイプも多く実際には複雑なツリー構造を持つ場合もあるため、開発を通して扱い方を覚えていきましょう。
公式ドキュメント

参考:PSIツリー構造の確認方法

デバッグ環境では、ツール > View Psi Structure of Current Fileから、PSIツリー構造を確認できます。
またPsiViewerプラグインを導入することで、デバッグ環境以外でもPSIツリー構造を確認できるようになります。

PsiTree

実装

ゴール

今回例に挙げるアクションは、DAOファイルからSQLファイルにジャンプする機能です。
アクションが有効なカーソル位置の判定や、ファイルジャンプまでの実装方法についても見ていきます。

DemoFileJunpAction

事前準備

アクション機能の実装に必須なものは以下の2つになります。

プラグイン設定でアクションとして登録できるクラスはAnActionのサブクラスとなっており、
アクション機能の登録はplugin.xmlタグを使って登録します。

前回ご紹介したアクション機能の登録例と同じ内容ですが再度掲載します。

  • actions: アクションメニューをグループ化するタグ
  • group: アクショングループの設定。グループ名やどのメニューにアクションを追加するなどを設定
  • action: アクション情報を記載するタグ
    • id: アクション機能の識別ID(任意のID)
    • class: 実装したAnActionのサブクラスの完全修飾クラス名
    • text: アクション表示名
    • description: アクションの説明
    • keyboard-shortcut: デフォルトのショートカットキーを登録するタグ
<actions>
    <group
      id="org.domaframework.doma.intellij.DomaToolGroupActions"
      text="Doma Tools"
      popup="true"
      icon="AllIcons.Nodes.AbstractClass">
      <add-to-group
        group-id="EditorPopupMenu"
        anchor="last"
       />
      <action
        id="org.domaframework.doma.intellij.action.JumpToSQLFromDao"
        class="org.domaframework.doma.intellij.action.dao.JumpToSQLFromDaoAction"
        text="Jump to SQL"
        description="Jump from DAO file to SQL file">
        <keyboard-shortcut keymap="$default" first-keystroke="alt D"/>
      </action>
    </group>
  </actions>

準備が整いましたら、実装を始めていきましょう!

参考:Plugin DevKitを使う

Plugin DevKitを使うことで、簡単に上記の下準備ができます。

ファイル > 新規 > Plugin DevKit > Action
NewActionPanel

表示制御の実装

AnActionクラスで主に使用するオーバーライドメソッドについて説明します。
紹介するメソッドの役割は、それぞれアクション表示制御、アクション実行スレッドの設定、処理本体です。

まずは表示制御から見ていきます。

update

メニュー表示時やショートカット実行時、まずはこのメソッドが呼び出され、アクションの条件を満たしているかを制御します。

引数で渡されるAnActionEvent型が持つpresentation.isEnabledAndVisibleを、最終的にtrueとすることがアクション実行条件を満たすことになります。

例えば、以下の条件をチェックするロジックを実装します。

  • アクションを呼び出したのがJavaファイル上か
  • カーソル位置がDAOメソッド名の上にあるか
  • SQLファイルへのジャンプが必要なDAOメソッドか

以下は実装例です。ファイル情報やカーソル位置の取得は、他機能の実装でもよく使うロジックになります。

ファイルタイプの判定
アクションの呼び出しがJavaファイル上であるかを判定するため、ファイル情報を取得してファイルタイプをチェックします。

// eはupdateに渡されるAnActionEvent型引数
// アクションを呼び出したファイル情報をPsiFileオブジェクトで取得
val currentFile = e.getData(CommonDataKeys.PSI_FILE) ?: return
val file: PsiFile = currentFile ?: return

// PsiFileからVirtualFile型の要素を取得し、FileTypeManagerによってファイルタイプを取得
val fileType = FileTypeManager.getInstance().getFileTypeByFile(file.virtualFile)
val isDaoFileType = when (fileType.name) {
        "JAVA", "Kotlin", "CLASS" -> true
        else -> false
    }

カーソル位置の取得
DAOメソッドを対象にアクションを実行するため、カーソル位置の要素をチェックします。

 // editorにエディタ画面情報を格納
 val editor = e.getData(CommonDataKeys.EDITOR) ?: return
 // editor.caretModel.offsetで、エディタ画面上のカーソル位置の要素をPsiElement型オブジェクトとして取得
 val element = file.findElementAt(editor.caretModel.offset) ?: return

SQLファイルへのジャンプが必要なDAOメソッドかは、実際にはメソッドに付与されたアノテーションタイプなどを細かくチェックする必要がありますが、
今回は簡単にカーソル位置の要素がDAOメソッドかのチェック方法を例にします。

// 取得したelementが、メソッド要素であることをチェック
val isDaoMethod = element is PsiMethod

最後に、チェックした結果をAnActionEventのプロパティに設定して表示制御のロジックは完成です。

e.presentation.isEnabledAndVisible = isDaoMethod

アクションスレッドの設定

update()をバックグラウンド(BGT)とイベントディスパッチ(EDT)のどちらのスレッドで呼び出すかを指定します。 書き込み処理など編集を加える処理はEDT、その他は基本的にBGTを設定します。
※6/20 getActionUpdateThread()update()に影響するメソッドであることをご指摘いただいたため、明記させていただきました。

詳細は公式ドキュメントをご参照ください。

getActionUpdateThread

今回はファイルジャンプのみを行うためBGTで設定します。

override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT

アクション処理の実装

アクションが呼び出された時の本体処理を実装します。

actionPerformed

必要な情報を再度取得してファイルジャンプ処理を実装します。

// カーソル位置のDAOメソッド要素を取得
 val editor = e.getData(CommonDataKeys.EDITOR) ?: return
 val element = currentFile?.findElementAt(editor.caretModel.offset) ?: return
 // PsiMethodの子要素が取得される可能性もあるため、念のため親のPsiMethodを取得
 val method = PsiTreeUtil.getParentOfType(element, PsiMethod::class.java) ?: return
 val daoFile = method.containingFile.virtualFile

 // 以下はプロジェクトの構成情報を扱うために必要なオブジェクト
 val project = e.project ?: return
 val module = project.getModule(daoFile)

// ジャンプ先のファイル情報を取得し、ファイルジャンプ

ファイルジャンプのロジック

ここからは少し複雑なロジックになるため、ファイル情報を取得するために必要なコアな実装例のみを記載します。

ファイルパスを取得

Domaのルール上、DAOメソッドに関連するSQLファイルは、リソースディレクトリ内の以下に配置されていることが前提となります。

SQLファイルの配置先】
{リソースディレクトリ}/META-INF/{DAOパッケージパス}/{DAO名}/{DAOメソッド名}.sql

まずは基となるDAOメソッドのファイルパスを取得します。
カーソル位置から取得した要素オブジェクトから、以下のようにファイル情報を取得できます。

// DAOメソッド名
val methodName = element.name 
// プロジェクトのルートパス。「{プロジェクトディレクトリ}/src/main」のようなパスを取得
val contentRoot = this.psiProject.getContentRoot(daoFile)?.path 

// contentRootが取得できていれば、収集した情報でSQLファイル用の相対パスを生成
val sqlFilePath =
    contentRoot.let {
        // DAOファイルの絶対パス
        val daoFilePath = daoFile.path
        // DAOファイルのパスを文字列変換し、SQLファイル用の相対パスを生成
        // 例)「src/main/java/example/dao/ExampleDao.java」を変換
        daoFilePath
        .replace("java","resources/META-INF")
        .replace(".java","/")
        .plus("$methodName.sql")
        // src/main/resources/META-INF/example/dao/ExampleDao/exampleMethodName.sql
    }

SQLファイルを取得

リソースディレクトリ内から相対パスでファイルを取得する場合、ResourceFileUtilを使用します。
以下の処理では、DAOファイルと同じモジュール内のリソースディレクトリから、相対パスに一致するSQLファイルをVirtualFileオブジェクトとして返します。

第3引数は検索スコープを設定します。getModuleScopeには、テストリソースディレクトリとしてマークされたディレクトリを検索対象とするかのフラグを設定します。この例では対象外とするためfalseで設定します。

sqlFilePathMETA-INF/から始まる相対パスです。

val targetSql = ResourceFileUtil.findResourceFileInScope(
        sqlFilePath.replace("//", "/"),
        project,
        module.getModuleScope(false),
    )

SQLファイルへジャンプ

最後はファイルジャンプの実装です。
対象ファイルにジャンプする処理自体は非常にシンプルで、FileEditorManagerを使用して以下のように実装します。

 FileEditorManager.getInstance(project).openFile(targetSql, true)

これでDAOメソッドからSQLファイルをエディタ画面上で開くことができるようになりました!

動かしてみる

デバッグ起動して、想定通りにファイルジャンプアクションが動作するか試してみましょう。

前回ご紹介したデバッグ起動するで、IntelliJデバッグ画面を起動してください。

画面が起動したら、以下手順で動作確認してみましょう。

  1. 任意のDomaプロジェクトを作成し、DAOとSQLファイルを実装
  2. DAOメソッド名上の右クリックでポップアップメニューを表示
  3. 登録したジャンプアクションを選択
  4. SQLファイルにジャンプ

もしアクションが表示されない場合や、アクションを実行してもファイルジャンプできない場合
開発画面側でブレークポイントを設定し、DAOやSQLファイル情報が正しく取得できているか等を確認してください。

参考: 「Doma Tools」の実装コード

Doma Tools」の同様のジャンプアクションは以下コードで実装しています。
JumpToSQLFromDaoAction

さいごに

今回はIntelliJプラグインのアクション機能実装に関するお話でした。

アクション機能は「Doma Tools」に実装した中では個人的に比較的簡単な機能となっており、最初に実装するにはちょうど良い難易度でした。
この記事ではファイルジャンプ機能を例に挙げましたが、SQLファイルの生成など様々なアクションも実装できますので、「Doma Tools」や他のプラグインも参考にして好みの機能を追加してみてください!

今後も他機能の実装に関する記事を投稿していきますので、ぜひご覧ください。

レビューも投稿していただけますと大変励みになります🙇‍♀️
Doma Tools マーケットプレイスページ

プラグインOSSとしてDomaコミュニティへ寄贈されています。
不具合修正や機能要望、ディスカッションにもぜひご参加ください。

採用ページ

執筆:@terao.haruka、レビュー:@nakamura.toshihiro
Shodoで執筆されました