Golang で Vim/neovim 双方で動くセレクタ系のプラグインを作ったときの話

# はじめに
この記事では、Vim/neovim 両方で動く Golang 製のプラグインを書いた話をします。
プラグインを書くに当たって色々考えてきた内容をざっくりと紹介していきます。

# 発端
最近、LSP クライアント、スニペットプラグイン、補完プラグインなどを自作して使っています。楽しいからいいのですが、自分は何をやっているんだろう？と自問自答の日々です。時間が吹き飛んでいるのだけは確実です。

ところで、当時やってみようと思ったのがセレクタ系プラグインでした。（fzf や denite のようなもの）
セレクタ系プラグインを作るにあたって、重要になりそうなのはパフォーマンスかな？と思いました。

- Vim script で 1 万件のファイルをフィルタしたりって現実的...？
- Golang 触ってみたい
- Golang でシングルバイナリなプラグイン書けばインストールも手間いらずでいいのでは？！

そういった考えもあり、Golang でプラグインを書いてみることにしました。


# 成果物

https://github.com/hrsh7th/vim-candle

※ 既存のプラグインのほうが高機能で安定していると思うので利用はおすすめしません。
※ とりあえず動きはします。


# 実装するときに考えていたこと

### Golang のプロセスと連携する方法

neovim であれば、msgpack-rpc で別プロセスとやり取りする手段がリモートプラグインという名前でネイティブで提供されています。しかし、Vim 側をサポートするとなると自分でその辺も作っていく必要があります。

元々、LSP クライアントも趣味で作っていたので JSON-RPC の処理は用意できていました。（[vim-vital-vs](https://github.com/hrsh7th/vim-vital-vs/blob/master/autoload/vital/__vital__/VS/RPC/JSON.vim)）

ということで、Golang のプロセスと stdin/stdout を介して JSON-RPC でおしゃべりすることとしました。この方法は、Vim で動作する LSP クライアントのほとんどが採用している方法です。

Golang で JSON-RPC を取り扱うのには [sourcegraph/jsonrpc2](https://github.com/sourcegraph/jsonrpc2) を採用しています。（ [mattn/efm-langserver](https://github.com/mattn/efm-langserver) を参考にさせてもらいました！）

実際は純粋な JSON-RPC ではなく、Content-Length などのヘッダパートを含んだ（LSP で定義された）プロトコルですが、上記ライブラリはそれにも対応しています。


### どうやって拡張性を担保するか？

各種セレクタ系のプラグイン（fzf や denite）は、ユーザ側で拡張可能な作りになっています。

- fzf
    - fzf 起動時にコマンドを渡すことで拡張できます。
    - grep 結果がほしければ `rg -i ...` を渡せるし、ファイル一覧が欲しければ `fd ...` を渡せます。

- denite
    - denite は python で記述したスクリプトを動的に読み込んでくれます。
    - 例えば、下記のような拡張が denite の外部ソースとして公開されています。
        - https://github.com/delphinus/vim-denite-memo
        - https://github.com/neoclide/denite-git
        - https://github.com/hsawaji/denite-ctags

ということで、せっかく自作するんだから拡張性をもたせよう。ということに自分の中でなりました。（どうせ、自分で使う範囲でしか拡張しないってわかっているのに。。。）

色々思案しましたが、なんらかのスクリプト処理系を導入しようと考えました。

- https://github.com/d5/tengo
    - Golang で書かれたインタプリタ（VM）
    - 高速に動作するらしい。独自の文法を持っている。（Golang に寄せてはいる模様）
    - 最初はこれにしようかなーと考えていました。
- https://github.com/yuin/gopher-lua
    - Golang で書かれた Lua のインタプリタ
    - Lua はこういう埋め込みの言語には向いてるとよく聞くし、候補に入れていました。
- https://github.com/traefik/yaegi
    - Golang で書かれた Golang のインタプリタ
    - Pure Go で書かれたスクリプトなら動的に読み込み可能！
    - さらに、登録すればスクリプト側からバイナリ側の関数も実行可能！
    - 元々 Golang 勉強したいって話ではじめたし、これでいってみよう！となりました。

現在、yaegi でソースを書いていますが、かなり普通に動いています。

https://github.com/hrsh7th/vim-candle/blob/master/autoload/candle/source/grep/source.go

Vim script 側から Golang のスクリプトファイルを指定してリクエストを送ることで拡張ソースを実行できる作りになっています。


### かんたんにインストールできるようにしたい

せっかく Golang で書いているのだから、pre-built なバイナリも配布したいところです。
おそらく gopher の方々的には一般的な構成かと思いますが、下記のような流れでインストールを自動化しています。

1. GitHub Actions で [goreleaser](https://github.com/goreleaser/goreleaser-action) をキックしてバイナリ生成
2. 生成したバイナリを GitHub Releases に登録
3. それを Vim plugin から curl で取得

工夫した点としては、下記のような点です。

1. Vim script を編集しただけでビルドされるのは無駄なので、同梱している [package.json のバージョンによってビルド](https://github.com/MontyD/package-json-updated-action)をする
2. Vim script 側からも package.json のバージョンを参照して、バイナリの更新が必要なことを検知できるようにする

これらの設定は https://github.com/hrsh7th/vim-candle/blob/master/.github/workflows/release.yml に記載されています。


# 実装してみての感想

Golang は書くのがすごい楽で、パフォーマンスも出るのでとてもよかったです。
特に gopls がよくできているのはデカかった。自動補完、オートインポート、診断情報など、動作も安定しているし LSP サーバとしては一番よくできてるんじゃないかなと感じます。

また、当初想定していた実験は諸々うまくいったのかなと思っています。

1. Golang でフィルタするので高速に動作する
2. シングルバイナリで自動ダウンロード機能もあるので、インストールもかんたん
3. yaegi を使って外から拡張することができる

ただし、あくまでもやりたいことはできた。というだけであって、プラグインとして完成度が高いか？と言われるとそういうわけではありません。そもそも機能が少ないですし。
公開して、issue に対応し、ドキュメントを整備し、破壊的変更が入る際のマイグレーションパスを考える。。。としっかりメンテしている先人たちには頭が上がりません。


# まとめ

なにか実装してる時に面白い話あったかな？と思い出しながら書いていましたが、記事にしてみるとあんまりパットしません。。。実際はめっちゃ苦労しながらやっていたので結構な時間がかかっています。

出来上がりとしては基本的な機能しかないですし、設計面でミスった点も多いですが、とりあえずまともに動いているし、実装していて楽しかったのでよかったです！