IntelliJでソースコード検索プラグインを作る

IntelliJでプラグインを作るにはどうするのか知りたくて、簡単なプラグインを作ってみました。
記録のために残しておきます。

## 作るもの
ソースコードの中から条件に一致する行だけを抽出し一覧表示し、選択するとその行を表示するプラグインです。Emacsのhelm-swoopみたいな感じです。
![plugin.gif](https://qiita-image-store.s3.amazonaws.com/0/27709/a80ac9f1-6624-95bb-2a45-88a4a9a1e9ea.gif)

## 環境
- macOS High Sierra (10.13.6)
- IntelliJ IDEA 2018.2.3 CommunityEdition

## IntelliJのプラグイン概要
IntelliJはプラグインによる拡張を前提としているため、追加の機能拡張が容易になっています。
メニューに機能を追加することや、任意の情報を表示するウインドウの追加はもちろん、新しい言語に対応するプラグインも作成することができます。
それらはJavaまたはKotlinで記述し、UIはSwingで作成します。
そしてJarファイルに固め、IntelliJのプラグイン設定画面からJarファイルを選択することでプラグインを読み込むことができます。
もちろんIntelliJの公式サイトで公開することでIntelliJのプラグイン設定画面からボタン一つでインストールすることもできるようになります。

## プロジェクト作成
IntelliJのメニュー [File]-[New]-[Project...]を選択します。
New Projectウインドウが開いたら、IntelliJ Platfom Pluginを選択します。
<img width="1094" alt="intellij1.png" src="https://qiita-image-store.s3.amazonaws.com/0/27709/4cf8b5e5-15f7-4e97-6bc9-baf92ab972cd.png">

Project SDKが設定されていない場合は、設定を追加します。IntelliJのアプリ自体がPluginのSDKとなっているので、IntelliJをインストールしているフォルダを指定します。詳細は公式ページが参考になります。
[IntelliJ プラットフォーム・プラグイン SDKの設定](https://pleiades.io/help/idea/configuring-intellij-platform-plugin-sdk.html)

Project SDKが設定できればNextを選択します。
Project名を入力する画面が表示されるので、適切な名前を入力してFinishを選択します。
<img width="1094" alt="intellij2.png" src="https://qiita-image-store.s3.amazonaws.com/0/27709/85535706-98ff-4c48-c348-fa8b0ac9333b.png">

以上で初期設定は完了です。

最初に、検索結果を表示するWindowを実装します。IntelliJではSwingでUIを実装します。(Swing書くの何年ぶりだろう・・・)
Swingの説明は省きますが、次のようにResultWindowクラスを作成します。検索結果を表すResultItemも同時に定義しておきます。

```Kotlin
data class ResultItem(
        val file: VirtualFile,
        val lineNumber: Int,
        val offset: Int,
        val text: String)

class ResultWindow: JPanel() {
    var input: JTextField
    var allResultItem: List<ResultItem> = mutableListOf()
    lateinit var resultList: JBList<ResultItem>
    var resultDataMode: DefaultListModel<ResultItem>
    var toolWindow: ToolWindow? = null
    var currentProject: Project? = null
    var selectionIndex = -1

    init {
        // Window作成
        layout = GridBagLayout()
        input = JTextField(400)

        // 検索エリアの上下キー入力で、検索結果を移動
        input.addKeyListener(object: KeyListener {
            override fun keyTyped(e: KeyEvent?) {}
            override fun keyPressed(e: KeyEvent?) {
                when (e?.keyCode) {
                    KeyEvent.VK_UP -> {
                        if (selectionIndex > 0) {
                            selectionIndex--
                        }
                    }
                    KeyEvent.VK_DOWN -> {
                        if (resultList != null && selectionIndex < resultList.itemsCount) {
                            selectionIndex++
                        }
                        if (selectionIndex > resultList.itemsCount) {
                            selectionIndex = resultList.itemsCount - 1
                        }
                    }
                    else -> {}
                }
                resultList.selectedIndex = selectionIndex
                resultList.ensureIndexIsVisible(selectionIndex)
            }
            override fun keyReleased(e: KeyEvent?) = search(input.text)
        })
        add(JLabel("Search : "), createLayoutParam(0,0,1,1,0.0,0.0,GridBagConstraints.NONE))
        add(input, createLayoutParam(1,0,1,1,1.0,0.0,GridBagConstraints.HORIZONTAL))
        resultDataMode = DefaultListModel()
        resultList = JBList(resultDataMode).apply {
            addListSelectionListener(MyListSelectionListener())
            cellRenderer = MyCellRenderer()
        }
        add(JScrollPane().apply {
            viewport.view = resultList}, createLayoutParam(0,1,2,1,0.0,1.0,GridBagConstraints.BOTH))
    }

    private fun createLayoutParam(x: Int, y: Int, sx: Int, sy:Int, wx: Double, wy: Double, fill: Int): GridBagConstraints {
        return GridBagConstraints(x,y,sx,sy,wx,wy,GridBagConstraints.CENTER,fill, Insets(0,0,0,0),0,0)
    }

    /**
     * 検索対象の設定
     */
    fun setData(list: List<ResultItem>) {
        allResultItem = list
        list.forEach {
            resultDataMode.addElement(it)
        }
    }

    /**
     * 検索実行
     */
    fun search(str: String) {
        val lowerCase = str.toLowerCase()
        resultDataMode.clear()
        allResultItem.forEach {
            if (it.text.toLowerCase().indexOf(lowerCase) != -1) {
                resultDataMode.addElement(it)
            }
        }
    }

    /**
     * 検索テキストボックス設定
     */
    fun setKeyword(title: String) {
        input.text = title
        input.requestFocusInWindow()
    }

    /**
     * 検索結果選択時にエディタ側を移動させるリスナ
     */
    inner class MyListSelectionListener: ListSelectionListener {
        override fun valueChanged(e: ListSelectionEvent?) {
            if (e == null) return
            val item = resultList.selectedValue ?: return
            currentProject?.apply {
                getCurrentEditor(this)?.apply {
                    caretModel.moveToOffset(item.offset)
                    scrollingModel.scrollToCaret(ScrollType.CENTER)
                }
            }
        }
        private fun getCurrentEditor(project: Project): Editor? =
                FileEditorManager.getInstance(project).selectedTextEditor
    }

    /**
     * JBListのCellRenderer
     */
    class MyCellRenderer: DefaultListCellRenderer() {
        override fun getListCellRendererComponent(list: JList<*>?, value: Any?, index: Int, isSelected: Boolean, cellHasFocus: Boolean): Component {
            val label = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) as JLabel
            val item = value as ResultItem
            label.text = "${item.lineNumber} : ${item.text}"
            return label
        }
    }
}
```

ほとんどSwingのコードなのでIntelliJ要素はないですが、関連する部分としては検索結果を選択したときに、対応する行へジャンプする部分です。
FileEditorManagerからEditorのインスタンスを取得し、対応する行を表示しています。
これらのAPIのドキュメントはなさそうなので、IntelliJのソースコードを読むか、ソースコードが公開されているプラグインのソースコードを読むしかなさそうです。
あとはデバッガで、各インスタンスの中に目当ての情報が無いかたどっていくと良いかもしれません。

```Kotlin
val item = resultList.selectedValue ?: return
currentProject?.apply {
    getCurrentEditor(this)?.apply {
        caretModel.moveToOffset(item.offset) // 対応する行に移動
        scrollingModel.scrollToCaret(ScrollType.CENTER) // エディタをスクロール
    }
}

private fun getCurrentEditor(project: Project) : Editor? =
                FileEditorManager.getInstance(project).selectedTextEditor
```



では次にこのResultWindowをIntelliJの中から使えるようにしましょう。
このResultWindowは単なるSwingのWindowですので、ToolWindowとして呼び出せるようにします。
そのためにToolWindowFactoryを作成します。
ToolWindowFactoryを継承し、createToolWindowContentメソッドをオーバーライドします。
ResultWindowインスタンスを生成し、addしているだけです。

```Kotlin
class SwoopPlugin : ToolWindowFactory {
    override fun createToolWindowContent(project: Project, parentToolWindow: ToolWindow) {
        ResultWindow().apply {
            toolWindow = parentToolWindow
            currentProject = project
            parentToolWindow.component.add(this)
        }
    }
}
```

そして、このSwoopPluginをプラグインとして使えるよう設定しましょう。
resources/META-INF/plugin.xmlを開き、先ほどのWindowをToolWindowとして追加します。

```xml
  <extensions defaultExtensionNs="com.intellij">
    <toolWindow id="sourcesearch" anchor="bottom" factoryClass="SwoopPlugin"/>
  </extensions>
```

この部分については公式ドキュメント[Tool Windows](https://www.jetbrains.org/intellij/sdk/docs/user_interface_components/tool_windows.html)が詳しいです。

さぁ、まだ完成はしていませんが、一度実行してResultWindowが表示されるか見てみましょう。
RunでPluginを実行します。すると開発中のプラグインを有効にした状態で、もう一つのIntelliJが起動します。
左下にWindowが表示されていますね！
<img width="958" alt="intellij4.png" src="https://qiita-image-store.s3.amazonaws.com/0/27709/095c3919-92c3-9dae-0ee2-53af1d33766a.png">

では次にActionを追加し、検索機能を完成させましょう。
IntelliJのメインメニューにActionを追加します。Actionとは、メニューやショートカットから起動することができる機能です。
srcディレクトリを右クリックし、コンテキストメニューを表示します。
[New]-[Plugin DevKit]-[Action]を選択します。
するとNew Actionダイアログが表示されるので、各項目を設定します。

Plugin DevKitがメニューにない場合はDevKitのプラグインが有効になっていないと思われるので、プラグイン設定画面からDevKitをenableにしてください。詳細は[プラグインの管理](https://pleiades.io/help/idea/managing-plugins.html)を参照します。
<img width="743" alt="intellij3.png" src="https://qiita-image-store.s3.amazonaws.com/0/27709/7c3a60ba-d683-8032-8317-6c652d190b5c.png">


- Action ID  
  IntelliJの中でActionを識別するユニークなIDです。
  どんなフォーマットで指定するかは標準のAction IDがまとまっているページがあるので参考にしてください。[Action ID一覧](http://keithlea.com/idea-actions/)
- Class Name  
  Actionクラスのクラス名を入力します。今回は手抜きでDefault packageに置いています。
- Name  
  メニューに表示される名前です。
- Description  
  アクションの説明になります。
- Add to Group  
  作成するActionをどのAction Groupに追加するかを設定します。Action Groupとは文字通りActionをまとめたものです。MainMenuはIntelliJのメニューにActionを追加します。Actionsではさらにその中のどこに追加するかを設定します。たとえばFileのAction GroupにActionを追加すると、FileのメニューにActionを追加することができます。
  今回はNavigateに追加するので、GoToMenuを設定します。
- Keyboard Shortcuts  
  キーボードショートカットも設定することができますので必要があれば設定しておきます。

OKボタンを選択すると、プラグインの設定ファイルであるplugin.xmlにActionが追加され、ソースファイルとして入力したSwoopActionクラスが生成されます。

```java
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;

public class SwoopAction extends AnAction {
    @Override
    public void actionPerformed(AnActionEvent e) {
        // TODO: insert action logic here
    }
}
```

ActionはAnActionクラスを継承します。そしてメニューから選択されるか、ショートカットキーが入力されるとactionPerformedメソッドが呼びだされます。
今回はKotlinでプラグインを記述したいので、JavaからKotlinに変換しておきます。
SwoopActionクラスを右クリックし、[Convert Java File to Kotlin File]を選択します。
無事にKotlinソースコードに変換されました。

```kotlin
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent

class SwoopAction : AnAction() {
    override fun actionPerformed(e: AnActionEvent) {
        // TODO: insert action logic here
    }
}
```

ではこのActionに対して実装を進めていきましょう。必要な処理は次の通りです。

1. 選択中のエディタ情報を取得する。
2. エディタから、ソースコードを取得する。
3. ResultWindowをToolWindowManagerから取得する。
4. ResultWindowに対してソースコードを渡す。

ということで実装すると次のようになります。ポイントは、AnActionEventのgetDataで各種情報を取得できるというところです。何が取れるかはデバッガで中身を見るのが早いかと思います。
そして、ToolWindowManagerからToolWindowのContentManagerを取得します。その際に指定するIDはplugin.xmlのtoolWindowのIDを指定します。
取得したContentManagerからResultWindowを取得します。


```kotlin
class SwoopAction : AnAction() {
    override fun actionPerformed(e: AnActionEvent) {
        // 各種インスタンス取得
        e?.getData(DataKeys.EDITOR) ?: return
        val editor = e.getData(DataKeys.EDITOR) as EditorImpl
        val currentProject = e.project ?: return
        val contentManager = ToolWindowManager.getInstance(currentProject)
                .getToolWindow("sourcesearch").contentManager
        val componentCount = contentManager.component.componentCount

        // 検索結果Window取得
        var resultWindow : ResultWindow? = null
        for (i in 0 until componentCount) {
            val component = contentManager.component.getComponent(i)
            if (component is ResultWindow) {
                resultWindow = component
                resultWindow.currentProject = currentProject
            }
        }

        // 検索実行
        editor?.document?.let {
            val split = it.text.split("\n")
            val list = mutableListOf<ResultItem>()
            var index = 1
            var offset = 1
            split.forEach {
                list.add(ResultItem(editor.virtualFile, index++, offset, it))
                offset += it.length
            }
            resultWindow?.apply {
                setData(list)
                setKeyword("")
            }
        }
    }
}
```

では実行し、確認しましょう。Runで起動するIntelliJのメニューに次のようにActionが追加されていれば成功です！
<img width="400" src="https://qiita-image-store.s3.amazonaws.com/0/27709/877b7099-4f8c-c4b8-2191-d511e59ba278.jpeg">

が、MacのHigh Sierraでは、Runから起動したIntelliJのインスタンスではこのActionを選択しても期待通りにactionPerformedメソッドが呼ばれません。そのため、その他のメニューもすべて動作しないはずです。

どうやらバグのようですので、次のVM引数をつけることでMacネイティブのメニューを使わなくして回避します。
``` -Dapple.laf.useScreenMenuBar=false ```

<img width="981" alt="intellij5.png" src="https://qiita-image-store.s3.amazonaws.com/0/27709/ae620893-4ea3-dd71-045a-a0e4dde30117.png">

では動かしてみましょう!次のように検索できれば成功です。
![plugin.gif](https://qiita-image-store.s3.amazonaws.com/0/27709/a80ac9f1-6624-95bb-2a45-88a4a9a1e9ea.gif)


## まとめ
- IntelliJのプラグインは簡単に作ることができます。
- ドキュメントは英語ですが公式ドキュメント[IntelliJ Platform SDK](https://www.jetbrains.org/intellij/sdk/docs/welcome.html)がおすすめ。
- またはソースコードが公開されているプラグインを参考にします。。
- あとはデバッガで中身を見ればなんとかなります。
- Macではプラグインのデバッグでメニューが効かないという罠があるのでVM引数を設定して回避します。
- Kotlinおすすめ。