TypeScriptでVimのファジーファインダーを実装して開発体験が最高になっている話

公開:2020/12/23
更新:2020/12/31
19 min読了の目安(約17700字TECH技術記事 2

この記事はVim Advent Calendar 2020の24日目の記事です。

2020/12/31 追記

VimからTypeScriptを実行するためのランタイムとしてNeovimのRemote Pluginかcoc.nvimのRPCを使うと書いたのですが、Vim scriptでRPCを実装したので外部のRPC実装への依存がなくなりました。
coc.nvimを導入していない状態のVimでも使えるようになったので、興味がある方はぜひ試してみてください。

はじめに

この記事は自分が開発しているTypeScript製Vimプラグイン、fzf-preview.vimについての記事となっています。
以下のような内容について書いています。

  • 自分のエディタ(Vim)との付き合い方
  • ファジーファインダーを用いた開発体験・開発フロー
  • プラグインの機能
  • プラグイン開発のモチベーション
  • プラグインの導入など

色々と書いていたら結構な量になってしまったので、動作を見たい場合はfzf-preview.vimをメイン使った作業フローGitとの強力な連携あたりまで飛ぶといいかもしれません。

私の開発環境とプラグイン開発

自分は基本的にWebエンジニアとして活動しており、最近はTypeScript(React)での開発がメイン、たまにRuby(Rails)を書いたりしています。
記事のタイトル通りエディタはほぼVim(厳密にはNeovim)を使っており、現代のメインストリームであるVSCodeは軽くしか触れられていません。

VSCodeはエコシステムを含めて非常に優れたエディタだと思っていますが、Vimコミュニティも活発なユーザが多く非常に良い環境で、ずっとVimを使い続けています。
そして、現代ではLSPの発展が目覚ましく、VimでもLSPクライアントを使うことでVSCodeに近い開発体験を得ることができるようになってきています。
特にTypeScriptの開発についてはVSCodeに比較的近いことが実現できているかと思っています。

最近は補完やジャンプはもちろん、Code Actionを実行するUIもリッチになっていてびっくりしました。

これはVimの標準機能ではなくcoc.nvimの機能です。

なお、現在の自分のvimrcは3505行(コメント + 空行が約500行)・導入しているプラグイン数は111個になっており、ある程度カスタマイズするのが前提になっているので1から開発環境を整えるにはVSCodeを使うのが無難かもしれません…
カスタマイズしたVimは非常に快適ですがとても深い沼です。

そして、今年のVim活としてTypeScriptでVimプラグインの開発に挑戦しました。その結果かなり理想に近いプラグインを作ることができたためVimでの開発が快適になり、更にVimにロックインされるという状態になっています。
その開発しているプラグインがfzf-preview.vimです。
(プラグイン名が微妙なのは開発開始当時プレビュー機能が対応していなかったfzf.vimの機能に対して拡張したプラグインだったからで、結果的に想定より遙かに大きくなってしまいました…)

なお、Vim scriptをあまり書かずTypeScriptでの開発に偏っていったため、Vimmerとしては中級者程度かなと思っています…

モダン開発環境とファジーファインダー、そしてVim

ファジーファインダーとは

知っている方も多いかとは思いますが、ファジーファインダーについて説明します。

リソース(検索対象)となるリストを受け取り、あいまい検索して候補を選択、その後何らかのアクションを実行するソフトウェアのことをファジーファインダーと呼びます。
正確な名前や正規表現が不要で、高速に対象を絞り込めるのがメリットです。
代表的なCLIではfzfがあります。

エディタ上で実行されるファジーファインダーはプロジェクトのファイルリスト全体から対象を高速に検索したり、grep結果をインタラクティブに絞り込んでジャンプするといった機能を提供していることが多いです。

エディタとファジーファインダー

現代のソフトウェア開発においてコードの実装やリーディングの際に何らかのファジーファインダーを用いるのは半ば常識的になってきています。
分かりやすいものだと、VSCodeでいう Ctrl+p (Macでは Command+p )のような機能です。

近年は大半のエディタにファジーファインダーが実装されており、特に最近のVimを取り巻く環境では何故かファジーファインダープラグインが乱立しています。
(自分は棚に上げて、既存のものを使えばいいのではという気持ちはありますが)ファジーファインダーがエディタの機能として重要視されているのが分かります。

この記事で解説しているyuki-ycino/fzf-preview.vimはオールインワン系のファジーファインダープラグインで、徐々にユーザが増えて2020年12月時点でスターを470程度付けて貰えています。
特に海外ではオールインワンなプラグインが好まれる傾向にあるので、そういった影響もありユーザが増えているのかと思います。

有名なファジーファインダープラグインとの客観的な比較は @yutakatay さんの記事にまとめられています。
自分があまり比較したことのないプラグインについても整理されているので、興味がある方は一読をお勧めします。

fzf-preview.vimによる最高の開発体験

自分の開発環境は大半がfzf-preview.vimに依存しています。自分にとって最高のVimプラグインの1つです。
機能として、以下のような操作を一手で行う事ができます。
プラグインからそれぞれコマンドを提供していて、自分は prefix + {key} で起動するようにmapしています。

  • プロジェクト全体からファイル検索
  • 最近開いた(or 保存した)ファイルの選択
  • インタラクティブなgrepと対象行へのジャンプ
  • エラーが発生しているコードへのジャンプ
  • LSPを使っての定義・参照へのジャンプ
  • Gitとの強力な連携

上記をファジーファインダーで開き、カーソルが合っている要素をプレビュー付きで表示します。
このプレビュー機能が個人的には非常に重要です。

プレビューはカーソル上のファイル内容を表示し、grepなどの行を扱うものであればファイル内の対象行の前後から表示します。
ショートカットでスクロールできるので、該当ファイルの中を一通り見通してから選択をすることなどが可能です。
また、Git操作ではcommit logやdiffなどを表示します。

つまり、開発におけるファイルを開いたり検索する機能を網羅しているプラグインで、共通のUIでそれらを扱うため様々な操作の起点となっています。

QuickFixへのエクスポートやQuickFixをリソースとして扱う機能も実装しており、他のプラグインとの連携も可能です。
後述のリファクタリング作業フローにありますが、vim-qfreplaceとの連携もその1つです。
また、gitの様々な操作を提供しています。

以下がgrepを実行している例で、左側で選択している行の周辺がプレビューとして右のブロックに表示されています。
その次の画像ではcommitのdiffを表示しつつgit logを操作しています。この画面からlogに対して様々な操作が可能です。
grep
grep with preview
git_log
git log with diff preview

他のファジーファインダーと比較しての優位点など

よりリッチな表示のサポート

Vimのファジーファインダープラグインとしては有名なものに以下のようなものがあります。

fzf-preview.vimとそれら(+ VSCode)を比較したのが下の画像です。
fzf-preview.vimはファイルタイプのアイコンやディレクトリ名のcolorize、ファイルのプレビューなどの機能を実装しており、表示をとてもリッチにしているのが伝わるかと思います。
特にファイル一覧から選択する際のプレビューと、grepやLSP連携の際に対象となっている行周辺のプレビュー(及びスクロール)できるのが重要で、自分はこれによって開発効率が大幅に上がっています。

画像は各プラグインにcolorizeやdeviconなどの小規模な設定を入れて比較しています。
高解像度なオリジナルファイルにリンクしているので、フォントの細かい部分などはリンク先で確認してみてください。

fuzzy_finder_list

デフォルトで提供しているリソースが豊富

ファイル検索でいうと、fzf.vimにはない、最近使ったファイルや最近保存したファイルの順に候補を出す機能なども提供しています。(Most Recent Used, Most Recent Written)
また、LSP(coc.nvim)との連携も実装しており、DiagnosticsやReferencesなどを扱うことができます。
他にもVimの機能であるmark, jumplist, tags, changelistなどと、一部の外部プラグインとの連携にも対応しています。
(一部fzf.vimのみで提供されているリソースもあります)

lsp_diagnostics
LSP Diagnostics

lsp_references
LSP References

細かい便利な設定が標準で付いている

fzf.vimと比較して空気を読んだ感じのコマンドや設定が標準で付いています。前述したdeviconとの連携もその1つです。
fzf.vimだとそれなりに設定を書く必要がある部分を省略できる感じです。
これは細かいものが多いので説明が難しいのですが、fzf.vimを使った上でfzf-preview.vimを使うと分かってくるかなと思います。

Gitとの強力な連携

一般的なファジーファインダーの枠から外れる機能なのですが、gina.vim, vim-fugitiveと連携することでgitの操作ができます。
Interactive rebaseはVim内で完結するのが難しいので実装していませんが、他の基本的な操作はほぼ網羅しているかと思います。
具体的にはstatus, add, reset, branch, checkout, merge, rebase, log, stash, reflog, commit, push, fetch, pullあたりの操作についてそれぞれのプレビューを見ながら操作できます。

以下が操作例です。

git, status, add, commit, log

git branch, diff, checkout

苦手な部分

一方、fzf-preview.vimは細かい挙動のカスタマイズなどが比較的苦手です。
詳細な部分まで詰めて設定したい場合は他のファジーファインダーの方が向いているかもしれません。

また、後述していますがTypeScriptを利用しているためTypeScript(トランスパイル後のCommonJS)をVimから実行するためにRPCを使ってNodeと通信する手段が必要で、依存している外部コマンドが比較的多いです。

fzf-preview.vimをメイン使った作業フロー

機能追加

fzf-preview.vimを使って小規模な機能追加(TypeScriptのtypeにプロパティを増やす)を実際に行った簡単な例です。
add_property_flow

リファクタリング

動的言語(Vim script)での関数名を変更した簡単な例です。置換にvim-qfreplaceというプラグインを併用しています。
refactor_flow

開発のモチベーション

開発初期

ここからは少し内部の技術的な話に入っていきます。
fzf-preview.vimはファジーファインダーのインターフェイスを提供するプラグインですが、内部的にはfzfをファジーファインダーとして使っています。
fzfをVim上で使うための公式プラグインのfzf.vimで不便に感じる部分の解消と、様々な機能追加を主に行っています。

ややこしいのですが、ファジーファインダー自体の実装がfzf、Vim用の汎用コマンドを提供しているプラグインがfzf.vimです。
また、(fzf.vimでない)fzfのリポジトリにはVimからfzfを操作するためのユーティリティ関数的なものが置かれています。

fzf.vimは優れたファジーファインダープラグインなのですが、自分がfzf-preview.vimを開発し始めた当時は機能が少なく使い辛い部分も多々ありました。(現在はfzf.vimも機能が拡張され、fzf-preview.vimが提供している機能の一部が重複するなどしています)

また、fzfから提供されているユーティリティのインターフェイスがかなり使いづらく、ファイル名にアイコンを付けるなどの処理を行うだけでも黒魔術めいたコードを設定ファイルに記述する必要がありました。

自分はfzfの強力なプレビュー機能に惹かれ、最初はfzf.vimを使いながら自分が必要としている機能をvimrcに追加していたのですが、記述量が膨らんでいったので便利な機能をプラグインとして切り出したのがfzf-preview.vimの成り立ちです。
今は標準で対応していますが、NeovimのFloating Windowへの対応をしたかったなどの理由もあります。

Vim scriptからTypeScriptへの移行

fzf-preview.vimの実装が肥大化するにつれ、fzfから提供しているインターフェイスを扱うのに限界が出てきました。
半ば無理に拡張していた部分の設計が破綻し、自分のVim scriptへの経験・知識不足もありますが言語機能的にも辛くなってきました。

そこで、設計の見直しとTypeScriptへ移行をし、スクラッチで書き直したのが現在のfzf-preview.vimです。
TypeScriptの強力な言語機能・優れた開発体験・エコシステムの強力さなどによってfzfをより抽象的に扱い、様々な機能を柔軟に提供可能になりました。
TypeScriptでの型定義を伴った開発体験はとてもよく、コード量が増えても破綻せずに開発を続けることができています。
npmの資産を使うことができるのも非常に大きかったです。(なんと状態管理にReduxを使うなどしています!)

ただ、そもそも入出力がUNIX思想に寄っているCLIを無理矢理Vimで高機能に使うのに結構無理があって、TypeScriptで実装したfzf-preview.vimも入出力を抽象的に扱う際に黒魔術っぽいことをしています。

また、同様にTypeScriptで実装されたVimプラグインであるcoc.nvimと連携することも容易になり、LSPに関する機能の実装はcoc.nvimを経由して扱っています。

TypeScriptでのVimプラグイン開発については別途まとめたいと思っています。
情報が非常に少なく、最初にまともに動作させるまでがそれなりに大変でした。

プラグインの実装規模ですが、いつの間にか結構増えていって合計約9000行程度になっています。
行数の多いプラグインが優れているというわけではないですが、結構頑張ったなぁと思っています。

===============================================================================
 Language            Files        Lines         Code     Comments       Blanks
===============================================================================
 Python                  1           57           40            4           13
 TypeScript            187         7847         6795           69          983
 Vim script             30         1007          873            2          132
===============================================================================
 Total                 218         8911         7708           75         1128
===============================================================================

動作に必要なソフトウェア

ここまで色々と書いてきましたが、fzf-preview.vimは動作させるのにはVim以外に必要なものがいくつかあります。
というのもTypeScriptの導入も含め、高機能にするため外部のソフトウェアに色々な部分を押しつける前提で実装してきたからです。
Vimユーザはエディタの性質的にもミニマリストの方が比較的多く、ここで好みが結構分かれるようです。
少し面倒かと思いますが、興味のある方はこの節を読んで導入を試してみてください。

なお、Windowsでの動作はPRで対応中です。

TypeScriptランタイム

まず、Nodeと通信するのでTypeScriptをJS(CommonJS)にトランスパイルしておく必要があります。
その後に(Neo)vimからCommonJSを実行する(NodeとRPCを使って通信する)のには主に2つの既存実装があります以下の3つの方法があります。

  • Vim scriptを使って自分でRPCを実装する
  • NeovimのRemote Plugin機能を使う
  • neoclide/coc.nvimの拡張として実装する(独自でRPCを実装しているためVimでも利用できます)

なので標準のVimのみでは動作させることができません。上記のどちらかを導入してください。
2020/12/31 追記: Vim scriptでのRPCを実装してVimとNodeのみで動作させられるようになりました。

fzf-preview.vimはwebpackでそれぞれの環境用にクロスビルドを行い、どれでも動作するようになっています。
それぞれのインストール方法については以下の通りです。

Vim script RPC

vim-plug
Plug 'junegunn/fzf', { 'do': { -> fzf#install() } }
Plug 'yuki-ycino/fzf-preview.vim', { 'branch': 'release/rpc' }
dein.vim
call dein#add('junegunn/fzf', { 'build': './install --all', 'merged': 0 })
call dein#add('yuki-ycino/fzf-preview.vim', { 'rev': 'release/rpc' })

Remote Plugin

npm install -g neovim
vim-plug
Plug 'junegunn/fzf', { 'do': { -> fzf#install() } }
Plug 'yuki-ycino/fzf-preview.vim', { 'branch': 'release/remote', 'do': ':UpdateRemotePlugins' }
dein.vim
call dein#add('junegunn/fzf', { 'build': './install --all', 'merged': 0 })
call dein#add('yuki-ycino/fzf-preview.vim', { 'rev': 'release/remote' })

coc extensions

:CocInstall coc-fzf-preview

外部コマンドとプラグイン

存在するのが前提になっているコマンド及びVimプラグインがいくつかあるのでインストールする必要があります。
(おそらく設定次第で不要になりますが、これらを導入した方が楽です)

Requirement

Optional

このあたりを導入しておくとかなり快適に使えるかと思います。
他の依存関係についてはREADMEdocに書いてあるのでそちらを参照してみてください。

まとめ

  • 中級VimmerがTypeScriptでVimのファジーファインダープラグインを開発してみた
    • TypeScriptの開発体験はフロントエンドに限らずとてもよい、採用できる環境であれば今後も積極的に検討していきたい
  • ファジーファインダーは開発を効率化する上でとても有用なので是非導入するべき
  • ファジーファインダー with プレビューは更に開発効率が上がる、軽快なプレビュー機能が搭載されているものがおすすめ
  • 必要なものが多いけど、使える環境であれば拙作のfzf-preview.vimは個人的にとてもおすすめ

ファジーファインダーは何故かVimで非常に選択肢が多く、選択が難しいのですが導入する価値は十分にあると思います。
どのプラグインを導入するとしても、この記事に書いた内容が少しでも選定の参考になれば幸いです。
また、他のエディタやIDEを使っている方にも、この記事がファジーファインダーの選定・使い方などの参考になればなと思っています。

Vimとは関係ないですが、IntelliJ系のgrep機能などはファジーファインダーのような動作で非常に強力だと聞きました。
最近はJetBrainsの製品を触っていなかったのですが、自分が実装する機能の参考にするためにも、機会があればまた触ってみたいと思っています。

最後になりますが、自分は今後もfzf-preview.vimの開発を続けていきたいと考えているので、気になった方は試しに触って頂けると幸いです。

おまけ

自分のfzf-preview.vimの設定です。
細かめにカスタマイズしているので冗長になっていますが、基本的にはあまり設定しなくても動作するようになっています。

fzf-preview settings
let g:fzf_preview_git_files_command   = 'git ls-files --exclude-standard | while read line; do if [[ ! -L $line ]] && [[ -f $line ]]; then echo $line; fi; done'
let g:fzf_preview_grep_cmd            = 'rg --line-number --no-heading --color=never --sort=path'
let g:fzf_preview_mru_limit           = 500
let g:fzf_preview_use_dev_icons       = 1
let g:fzf_preview_default_fzf_options = {
\ '--reverse': v:true,
\ '--preview-window': 'wrap',
\ '--exact': v:true,
\ '--no-sort': v:true,
\ }
let $FZF_PREVIEW_PREVIEW_BAT_THEME  = 'gruvbox'

noremap <fzf-p> <Nop>
map     ;       <fzf-p>
noremap ;;      ;
noremap <dev>   <Nop>
map     m       <dev>

nnoremap <silent> <fzf-p>r     :<C-u>CocCommand fzf-preview.FromResources buffer project_mru<CR>
nnoremap <silent> <fzf-p>w     :<C-u>CocCommand fzf-preview.ProjectMrwFiles<CR>
nnoremap <silent> <fzf-p>a     :<C-u>CocCommand fzf-preview.FromResources project_mru git<CR>
nnoremap <silent> <fzf-p>g     :<C-u>CocCommand fzf-preview.GitActions<CR>
nnoremap <silent> <fzf-p>s     :<C-u>CocCommand fzf-preview.GitStatus<CR>
nnoremap <silent> <fzf-p>b     :<C-u>CocCommand fzf-preview.Buffers<CR>
nnoremap <silent> <fzf-p>B     :<C-u>CocCommand fzf-preview.AllBuffers<CR>
nnoremap <silent> <fzf-p><C-o> :<C-u>CocCommand fzf-preview.Jumps<CR>
nnoremap <silent> <fzf-p>/     :<C-u>CocCommand fzf-preview.Lines --resume --add-fzf-arg=--no-sort<CR>
nnoremap <silent> <fzf-p>*     :<C-u>CocCommand fzf-preview.Lines --add-fzf-arg=--no-sort --add-fzf-arg=--query="<C-r>=expand('<cword>')<CR>"<CR>
xnoremap <silent> <fzf-p>*     "sy:CocCommand fzf-preview.Lines --add-fzf-arg=--no-sort --add-fzf-arg=--query="<C-r>=substitute(@s, '\(^\\v\)\\|\\\(<\\|>\)', '', 'g')<CR>"<CR>
nnoremap <silent> <fzf-p>n     :<C-u>CocCommand fzf-preview.Lines --add-fzf-arg=--no-sort --add-fzf-arg=--query="<C-r>=substitute(@/, '\(^\\v\)\\|\\\(<\\|>\)', '', 'g')<CR>"<CR>
nnoremap <silent> <fzf-p>?     :<C-u>CocCommand fzf-preview.BufferLines --resume --add-fzf-arg=--no-sort<CR>
nnoremap          <fzf-p>f     :<C-u>CocCommand fzf-preview.ProjectGrep<Space>
xnoremap          <fzf-p>f     "sy:CocCommand fzf-preview.ProjectGrep<Space>-F<Space>"<C-r>=substitute(substitute(@s, '\n', '', 'g'), '/', '\\/', 'g')<CR>"
nnoremap <silent> <fzf-p>q     :<C-u>CocCommand fzf-preview.QuickFix<CR>
nnoremap <silent> <fzf-p>l     :<C-u>CocCommand fzf-preview.LocationList<CR>
nnoremap <silent> <fzf-p>:     :<C-u>CocCommand fzf-preview.CommandPalette<CR>
nnoremap <silent> <fzf-p>p     :<C-u>CocCommand fzf-preview.Yankround<CR>
nnoremap <silent> <fzf-p>m     :<C-u>CocCommand fzf-preview.Bookmarks --resume<CR>
nnoremap <silent> <fzf-p><C-]> :<C-u>CocCommand fzf-preview.VistaCtags --add-fzf-arg=--query="<C-r>=expand('<cword>')<CR>"<CR>
nnoremap <silent> <fzf-p>o     :<C-u>CocCommand fzf-preview.VistaBufferCtags<CR>

nnoremap <silent> <dev>q  :<C-u>CocCommand fzf-preview.CocCurrentDiagnostics<CR>
nnoremap <silent> <dev>Q  :<C-u>CocCommand fzf-preview.CocDiagnostics<CR>
nnoremap <silent> <dev>rf :<C-u>CocCommand fzf-preview.CocReferences<CR>
nnoremap <silent> <dev>t  :<C-u>CocCommand fzf-preview.CocTypeDefinitions<CR>

AutoCmd User fzf_preview#initialized call s:fzf_preview_settings()

function! s:buffers_delete_from_lines(lines) abort
  for line in a:lines
    let matches = matchlist(line, '\[\(\d\+\)\]')
    if len(matches) >= 1
      execute 'Bdelete! ' . matches[1]
    endif
  endfor
endfunction

function! s:fzf_preview_settings() abort
  let g:fzf_preview_grep_preview_cmd = 'COLORTERM=truecolor ' . g:fzf_preview_grep_preview_cmd
  let g:fzf_preview_command = 'COLORTERM=truecolor ' . g:fzf_preview_command

  let g:fzf_preview_custom_processes['open-file'] = fzf_preview#remote#process#get_default_processes('open-file', 'coc')
  let g:fzf_preview_custom_processes['open-file']['ctrl-s'] = g:fzf_preview_custom_processes['open-file']['ctrl-x']
  call remove(g:fzf_preview_custom_processes['open-file'], 'ctrl-x')

  let g:fzf_preview_custom_processes['open-buffer'] = fzf_preview#remote#process#get_default_processes('open-buffer', 'coc')
  let g:fzf_preview_custom_processes['open-buffer']['ctrl-s'] = g:fzf_preview_custom_processes['open-buffer']['ctrl-x']
  call remove(g:fzf_preview_custom_processes['open-buffer'], 'ctrl-q')
  let g:fzf_preview_custom_processes['open-buffer']['ctrl-x'] = get(function('s:buffers_delete_from_lines'), 'name')

  let g:fzf_preview_custom_processes['open-bufnr'] = fzf_preview#remote#process#get_default_processes('open-bufnr', 'coc')
  let g:fzf_preview_custom_processes['open-bufnr']['ctrl-s'] = g:fzf_preview_custom_processes['open-bufnr']['ctrl-x']
  call remove(g:fzf_preview_custom_processes['open-bufnr'], 'ctrl-q')
  let g:fzf_preview_custom_processes['open-bufnr']['ctrl-x'] = get(function('s:buffers_delete_from_lines'), 'name')

  let g:fzf_preview_custom_processes['git-status'] = fzf_preview#remote#process#get_default_processes('git-status', 'coc')
  let g:fzf_preview_custom_processes['git-status']['ctrl-s'] = g:fzf_preview_custom_processes['git-status']['ctrl-x']
  call remove(g:fzf_preview_custom_processes['git-status'], 'ctrl-x')
endfunction