プラグイン無しでファイル名からファイルを探す(:find)
fzfのようなファジーファインダーを入れるほどでもありませんが、ファイル名の一部だけを覚えていて、それを手がかりに開きたいことがあります。Vim標準の:findコマンドは、設定さえしておけばこの用途に十分応えます。探すのがファイルの名前ではなく中身なら道具が変わるので、検索まとめのほうから入ってください。
目次(8項目)
| キー | これだけ覚える |
|---|---|
set path+=$PWD/** | 下準備(サブディレクトリも検索対象に) |
:find {ファイル名} | 名前の一部からファイルを探して開く |
:2find {ファイル名} | 同名ファイルの2番目の候補を開く |
| Tab | 入力中に候補を補完 |
下準備:pathオプションの設定
set path+=$PWD/**
:findはpathオプションに並んだディレクトリを順に見て、最初に見つかったファイルを開きます。既定値は処理系で違い、設定を読まない状態で起動して:set path?を打つと、Vim 9.1.1752はpath=.,/usr/include,,を、Neovim 0.12.4はpath=.,,を返しました。差はCのヘッダ置き場が入っているかどうかだけで、残る2つの要素は共通です。
カンマ区切りの各要素は、それぞれ次の意味を持ちます。
.:いま開いているファイルが置かれているディレクトリ/usr/include:Cのヘッダ置き場(Vimのみ)- 空の要素:カレントディレクトリ、つまり
:pwdが返す場所
最大のハマりどころが3つ目です。.,,のカンマとカンマの間には何も書かれておらず、この空っぽの要素がカレントディレクトリを表します。末尾のカンマが余っているように見えるので、整形するつもりで消すと探索範囲が静かに変わります。
.とカレントディレクトリを同じものだと思っていると、ここで結果が食い違います。次の構成を作り、src/lib/util.jsを開いた状態から:find helper.jsを実行して確かめました。
.
├── helper.js
└── src
└── lib
├── helper.js
└── util.js
| pathの値 | 開かれたファイル |
|---|---|
. | src/lib/helper.js |
,, | helper.js |
同じコマンドが別のファイルを開きました。.は編集中のファイルに付いて回るので、深い階層へ移った瞬間に探索の起点も動きます。空の要素のほうは、ウィンドウを何に切り替えても:pwdの場所を指したままです。隣のファイルを相対パスで参照し合うプロジェクトでは前者が、ルートから名前だけで引きたいときは後者が効きます。
**は、その下のディレクトリを再帰的にたどる指定です。数字を付けると深さを制限でき、**1はカレントディレクトリの1つ下まで、**2は2つ下までを見ます。上の構成で試すと、path=**1では直下のhelper.jsしか見つからず、2階層下のsrc/lib/util.jsは候補に入りませんでした。**2にするとsrc/lib/の2本も候補に入ります。
冒頭に書いた$PWD/**と、単なる**にも差があります。$PWDはsetした時点で絶対パスに展開されるため、あとから:cdしても動きません。ルートで候補が2件だった状態から:cd srcすると、相対の**ではsrc配下の1件に減り、$PWD/**では元のルートを指したまま2件が残りました。作業中にディレクトリを移動する習慣があるなら、この差がそのまま検索範囲の差になります。
:set path? " 現在のpath設定を確認
ファイルを探して開く
:find {ファイル名}
ファイル名(の一部)を入力すると、path配下から一致するファイルを探して開きます。同じ名前が複数あるときは先頭の1件が開き、2番目以降を出したいときは:2find util.jsのように数字を前に置きます。src/lib/util.jsとtest/lib/util.jsを置いた手元の環境では、:find util.jsが前者ではなくtest/lib/util.jsを開き、:2find util.jsでsrc/lib/util.jsに移れました。実装とテストで同名のファイルを使う構成なら、この数字だけで対になるファイルを行き来できます。
見つからないときのメッセージは2種類あり、区別が付くと切り分けが速くなります。1件も無ければE345: Can't find file "nosuch.js" in path、候補はあるのに指定した番号まで届かなければE347: No more file "util.js" found in pathです。Vim 9.1.1752とNeovim 0.12.4で文言まで同じでした。E347が出たときに名前の綴りを疑っても意味はなく、単に候補の数が足りていません。
代わりに:editを打たないことも大切です。:editはpathをまったく見ず、カレントディレクトリからの相対パスとして素直に解釈します。pathに**を足した状態で:edit util.jsを実行したところ、ルートにutil.jsが無いにもかかわらずエラーは出ず、その名前の空バッファが開きました(filereadable(expand("%"))が0を返します)。目的のファイルを編集しているつもりで保存すると、空のファイルが1つ増えます。
Tab補完が返すのは最短の一意なパス
入力の途中でTabを押すと候補が補完されます。ここで返るのは単なるファイル名ではなく、そのファイルを一意に決められる最短の後ろ側のパスです。名前がぶつかっていなければファイル名だけで確定し、ぶつかっていれば区別が付くところまでディレクトリが伸びます。
:find ma
:find main.js
先ほどの構成にsrc/main.jsとsrc/api/client.jsを足し、Tabを押したときと同じ一覧をgetcompletion()で取り出しました。
| 打った文字 | 返ってきた候補 |
|---|---|
ma | main.js |
cli | client.js |
help | ./helper.js と lib/helper.js |
util | src/lib/util.js と test/lib/util.js |
main.jsは実際にはsrc/main.jsにありますが、同名が他に無いので名前だけで済んでいます。helper.jsは2か所にあるため、カレントディレクトリ直下のほうが./helper.js、もう一方がlib/helper.jsになりました。lib/まででは区別が付かないutil.jsだけがsrc/とtest/まで伸びています。候補が急に長くなったときは、ディレクトリ構成のどこが被っているかが見えていると考えると読みやすくなります。
並び順には落とし穴があります。補完の一覧はsrc/lib/util.js、test/lib/util.jsの順でしたが、:findと:2findが開くのは逆の順です。補完はソートされ、:findはディレクトリをたどった順に返すので一致しません。Vim 9.1.1752とNeovim 0.12.4のどちらでも同じ食い違い方をしました。一覧で2番目に見えたファイルを:2findで開こうとすると、別のファイルが来ます。
見え方はwildmenuとwildoptionsで変わります。wildmenuはどちらの処理系でも既定で有効、wildmodeの既定も同じくfullですが、wildoptionsはVim 9.1.1752が空、Neovim 0.12.4がpum,tagfileでした。そのためNeovimではコマンドラインの上にポップアップメニューが出て、Vimでは候補が1行に並びます。同じ設定を書いても見た目が揃わないのはこの差によります。
もう1つ、**に深さの数字を付けると補完だけが黙る現象があります。path=.,,**3にすると:find util.jsは問題なく開けるのに、:find utilまで打ってTabを押しても何も起こりません。path=.,,**に戻すと候補が出ます。tmuxの中で対話起動したVim 9.1.1752とNeovim 0.12.4の両方で再現しました。深さ制限は再帰の暴走を止める手段として有効ですが、引き換えに補完を失います。
分割・タブで開きたい場合
:sfind {ファイル名} " 水平分割で開く
:vert sfind {ファイル名} " 垂直分割で開く
:tabfind {ファイル名} " 新規タブで開く
ここで注意したいのは、候補番号の指定がこの3つには引き継がれないことです。:sfindの前に置いた数字は:splitと同じくウィンドウの高さになり、:tabfindの数字は:tabnewと同じくタブの挿入位置になります。実際に:5sfind util.jsを打つと、高さ5行のウィンドウが開き、中身は5番目ではなく1番目の候補でした。2番目の候補を分割で見たいときは、先に画面分割してから:2findを打つ形になります。
カーソル下のファイル名を開くgfも同じpathを使い、分割で開くCtrl-w fも同様です。ここで効いてくるのがsuffixesaddで、既定値はVimもNeovimも空のため拡張子を書かない参照は解決できません。require('util')のutilにカーソルを置いてgfを押すとE447: Can't find file "util" in pathになり、set suffixesadd=.jsを足した途端にtest/lib/util.jsが開きました。同じ「見つからない」でも番号が:findと違うので、どちらの経路でつまずいたかが分かります。押した先の使い分けはカーソル下のパスをそのまま開く(gf)にまとめてあります。
打鍵を減らすなら、末尾を<Space>にして<CR>を付けないマッピングが向いています。コマンドラインが開いたまま止まるので、続けてファイル名とTabを打てます。
" 末尾にスペースを残し、コマンドラインを開いたまま入力を待たせる
let mapleader = "\<Space>"
nnoremap <Leader>f :find<Space>
nnoremap <Leader>v :vertical sfind<Space>
nnoremap <Leader>t :tabfind<Space>
Vim 9.1.1752で試すと、Spacefのあとmaと打ってTabでmain.jsまで伸び、Enterでsrc/main.jsが開きました。リーダーキーの決め方はmapleaderの設定を参照してください。
fzfとの違い
あいまい検索やインクリメンタルな絞り込みという点では、fzfのような専用プラグインのほうが高機能です。ただし、そこまでの検索精度を毎回必要としないなら、プラグイン無しで完結する:findで足りる場面も多くあります。ファイルの中身で絞り込みたいなら、vimgrepによる横断検索のように別の道具に渡したほうが速く済みます。
標準のまま、候補の集め方だけを差し替える手もあります。findfuncオプションに関数を指定すると、:findはpathを歩くのをやめ、その関数が返したリストだけを候補にします。手元ではexists('+findfunc')がVim 9.1.1752でもNeovim 0.12.4でも1を返しました。
" gitが把握しているファイルだけを :find の候補にする
function! GitFind(cmdarg, cmdcomplete) abort
let l:files = systemlist('git ls-files')
if v:shell_error | return [] | endif
return filter(l:files, 'v:val =~ a:cmdarg')
endfunction
set findfunc=GitFind
第2引数は補完のために呼ばれたかどうかを示す真偽値で、関数は:find1回につき1度しか呼ばれません。node_modulesの下にもutil.jsを置いたリポジトリで試すと、path=.,,**のままでは候補がdist/util.jsとlib/util.jsの2件でしたが、この関数を入れると追跡対象のutil.js1件だけになりました。除外の条件をpath側に書き足さなくても、.gitignoreの内容がそのまま反映されます。差し替えても:findは普通のExコマンドのままなので、Tabの補完もコマンドラインウインドウでの履歴の編集もそのまま効きます。
pathの空の要素がカレントディレクトリ、先頭の.が開いているファイルのディレクトリです。この2つを取り違えると、同じ:findが別のファイルを開きます。
最初はfzf系のプラグインを入れないと不便だろうと思い込んでいましたが、pathを設定した:findだけでも普段の用途にはほぼ困りませんでした。つまずいたのはwildignoreのほうで、補完を軽くするつもりでnode_modulesを除外したところ、依存ライブラリの中身を見たいときだけ開けなくなり、補完の設定が原因だとは思い至らないまま半日ほど探し回りました。
大きなプロジェクトでは重くなることがある
遅くなるのは探索そのものではなく、候補の列挙です。node_modulesを模したツリーを作って測りました。2,000個のパッケージそれぞれに4階層下のディレクトリを掘り、合計で約20,000ファイルという構成です。
| 操作 | Vim 9.1.1752 | Neovim 0.12.4 |
|---|---|---|
| 1件を開く | 0.05秒 | 0.02秒 |
| 候補20,000件の補完 | 10.0秒 | 10.3秒 |
:findは最初の1件が見つかった時点で打ち切るので一瞬で返りますが、Tabは全部を数え上げたうえに一意になる最短のパスまで組み立てるため、10秒ほど固まります。押してから待たされるので「:findが重い」と感じますが、時間を食っているのは補完のほうです。
ここでwildignoreを使いたくなりますが、副作用があります。set wildignore=*/node_modules/*を入れると補完は1.4秒台まで縮む代わりに、:find m00.jsそのものがE345で失敗するようになりました。このオプションは補完の絞り込みだけでなく:findの探索結果からも該当ファイルを落とすので、除外したディレクトリのファイルは名前を正確に打っても開けません。Vim 9.1.1752とNeovim 0.12.4のどちらでも同じでした。
素直なのは入口を絞るほうです。set path=.,,src/**のように自分が書くコードのある場所だけを再帰対象にすると、同じツリーのまま補完が0.00秒まで下がりました。依存ライブラリを開く頻度は普段そう高くないので、必要になったときだけ足すほうが実用的です。それも面倒なら、findfuncでgitの管理下に寄せる手が残ります。
まとめ
:findはpathの書き方さえ押さえれば、プラグイン無しでも十分実用になります。押さえどころは、空の要素がカレントディレクトリを指すこと、先頭の.が開いているファイル側を指すこと、そしてTabが返すのは一意になる最短のパスであることの3つです。パスが分かっている場所を直接開きたいならgfのほうが向いていますし、直前まで触っていたファイルへ戻るだけなら:oldfilesで最近使ったファイルを開くほうが速く済みます。