Vimで複数ファイルを横断検索する(vimgrep + cwindow)
プロジェクト内の複数ファイルから特定のキーワードを探したいとき、いちいちファイルを開いてから検索する必要はありません。:vimgrepにワイルドカードでファイルを指定すれば、開いてすらいないファイルもまとめて検索できます。
この記事のコマンドと数値は、Vim 9.1.1752 と Neovim 0.12.4 を -Nu NONE 付きで起動し、一時ディレクトリの4つのJavaScriptファイルに対して実際に打った結果です。oldName という文字列を src/a.js に3箇所(うち2箇所は同じ行)、src/lib/b.js と node_modules/pkg/c.js に1箇所ずつ置き、src/d.js には置いていません。件数は getqflist() の長さで数えています。
目次(8項目)
基本の書式
:vimgrep /{検索パターン}/gj {検索対象ファイル} | cwindow
:vim /{検索パターン}/gj {検索対象ファイル} | cw
検索対象ファイルにはワイルドカードが使えます。例えば次のように指定できます。
:vim /numpy/gj *.py | cw
:vim /autocmd/gj **/*.vim | cw
:vim /structure/gj ~/src/*.c | cw
**を使うと、カレントディレクトリ配下を再帰的に検索できます。1つ目の例はカレントディレクトリのPythonファイルからnumpyという文字列を、2つ目はカレントディレクトリ配下すべてのvimファイルからautocmdという文字列を検索します。このワイルドカードを展開するのはシェルではなくVim自身で、'shell' を存在しないパスに変えてから同じ検索を流しても件数は5件のまま変わりませんでした。シェル側で globstar を有効にしていなくても ** が使えるのはこのためです。
パターンの後ろに付いている g と j は別々の意味を持ちます。g は1行に複数の一致があれば全部拾う指定で、これを外して j だけで検索すると、2箇所ある行が1件に丸められて4件になりました。j は一覧を作るだけでジャンプしないという指定です。外すと最初の一致へ勝手に移動し、見るつもりのなかった node_modules/pkg/c.js が開きました。元の場所へ戻る手間が増えるので、gj を既定の形にしておくと扱いやすくなります。
対象にはワイルドカード以外も書けます。% は編集中のファイルだけ、## は引数リストに入っているファイル全部を指します。:args src/a.js src/lib/b.js と入れてから ## を渡すと4件、src/a.js を開いて % を渡すと3件でした。検索範囲を先に固定しておきたいときは、引数リストと:argdoの使い方で作った引数リストをそのまま流用できます。
区切りの / は他の記号に替えられます。パターンにパスのようなスラッシュが入るときは :vimgrep #path/to#gj **/*.js のように # で囲むと、バックスラッシュでのエスケープが要りません。この形で path/to を含む2行が並びました。
**を使うとサブディレクトリまで再帰的に検索できます。開いていないファイルも対象にできる点が:bufdoとの違いです。
:vimgrep /TODO/gj **/*.js のあと :copen 5 を実行した画面開いているバッファだけを検索したい場合
このコマンドはファイルパスを指定して検索するため、まだ開いていないファイルも対象にできるのが特徴です。逆に、すでに開いている複数バッファだけを対象にしたい場合はbufdo + vimgrepを使う記事を参照してください。使い分けの目安はこうなります。
| 目的 | コマンド |
|---|---|
| 開いている全バッファを検索 | :silent! bufdo vimgrepadd ... % のあと :cwindow |
| ファイル・ディレクトリを指定して検索 | :vimgrep ... {file} | cw |
バッファ側で vimgrepadd になっているのは、:vimgrep が実行のたびにリストを作り直すからです。3件・0件・1件のバッファを開いて :silent! bufdo vimgrep /oldName/g % を流すと、最後に一致したバッファの1件しか残りませんでした。追記していく :vimgrepadd に替えると4件そろいます。
ファイルを指定する側にも、開いているバッファのほうが優先されるという性質があります。src/d.js を開いて保存せずに書き換えてから検索すると、ディスク上にはまだ無い文字列で1件ヒットしました。書きかけの内容もそのまま検索対象になるので、保存前に探し直すこともできます。
結果一覧を開いて行き来する
一覧を出すコマンドは2つあり、:cwindow は一致があるときだけ開きます。0件のときに打ってもウィンドウは増えず、すでに開いていた一覧のほうは閉じました。:copen は0件でも開きます。結果を必ず目視したいときは :copen、検索のたびに自動で出したいときは :cwindow、という使い分けになります。
autocmd QuickFixCmdPost *grep* cwindow
設定ファイルに書いておけば、grep系コマンドの実行後に毎回自動でQuickfixウインドウが開くようになります。*grep* がどこまで拾うかは、発火したコマンド名を記録するautocmdを仕掛けて数えられます。手元で試した範囲では :vimgrep・:lvimgrep・:grep のいずれでも発火しました。名前に grep を含むコマンドが対象なので、:vimgrepadd や :lgrep も当たります。ただし開くのは :cwindow なので、ロケーションリストに入る :lvimgrep のあとでも出てくるのはquickfixの一覧です。前の検索結果が残っている状態で試すと、5件入ったロケーションリストではなく古い1件の一覧が開きました。こちらも自動で出したいなら、:lvimgrep 用に :lwindow のautocmdを別に書きます。
開いた一覧は 'buftype' が quickfix の専用バッファです。高さは既定で10行あり、'winfixheight' が立っているので他のウィンドウを分割しても10行のまま残ります。一致が1件でも10行で開くため、狭くしたいときは :copen 5 のように高さを渡します。
:cnext / :cprev " 次 / 前の一致へ
:cfirst / :clast " 最初 / 最後へ
:cc 3 " 3番目の一致へ
:colder / :cnewer " 1つ前 / 後の検索結果に切り替える
番号が分かっているときは :cc に数字を渡すと一足飛びに移動できます。5件の結果で :cc 3 を打つと src/a.js の2行目へ飛びました。末尾で :cnext を打っても先頭へは回り込まず、E553: No more items で止まります。
:colder は見落とされがちですが便利です。別の検索をしたあとでも前の結果に戻れるので、探し直す必要がありません。保持されるのは10個までで、同じ検索を12回繰り返してからリストの本数を数えると10で頭打ちになりました。
一覧の中で / の検索は普通に使えますが、行の削除は既定では通りません。dd を押すと E21: Cannot make changes, 'modifiable' is off になります。:set modifiable してから消せば見た目の行は減るものの、getqflist() の件数は5件のままでした。結果そのものを絞りたいときは、VimにもNeovimにも同梱されている :packadd cfilter の :Cfilter が使えます。:Cfilter /a.js/ を実行すると5件から3件に減りました。
大量ファイルを検索するときの注意
vimgrepは各ファイルを実際にVimの編集バッファとして読み込みながら検索するため、ファイル数が多いと外部のgrepコマンドより低速になります。40行のJavaScriptファイルを2,000個(合計7.8MB)置いたディレクトリで計ると、:vimgrep /needle/gj **/*.js は1.64秒から1.66秒かかりました。同じ範囲をシェルの grep -rn で探した場合は0.09秒から0.12秒です。両者の違いはvimgrepとgrepの違いの記事で詳しく比較しています。
遅さの正体は、パターンの照合そのものよりファイルの出し入れにあります。2,000ファイルの検索が終わった直後にバッファの数を数えると41個あり、そのうち読み込み済みは1個だけでした。一致した40ファイルは入れ物だけが残って中身は捨てられており、一致しなかった残りも一度は読み込まれています。この固定費がファイル数ぶん積み上がるので、数十から数百ファイルなら待たされる感覚はほとんど無い一方、リポジトリ全体を無条件に舐める使い方には向きません。
設定ファイル一式をディレクトリごと検索したいとき、**/*.vimのような再帰ワイルドカードを覚えてから、ファイラーで1つずつ開いて探す作業がほぼなくなりました。数百ファイル規模のプロジェクトでは体感で少し待つこともありますが、日常的な設定ファイルの検索であれば十分な速度で使えています。
検索結果からまとめて置換する
quickfixに入ったファイルすべてに同じ処理を流せます。:cfdo は結果に含まれる各ファイルで1回ずつコマンドを実行するので、置換と保存をつなげると一括置換になります。3ファイルに散った5件の結果で回数を数えると、:cdo は5回、:cfdo は3回実行されました。
:vimgrep /oldName/gj **/*.js
:cfdo %s/oldName/newName/ge | update
e フラグは一致が無くてもエラーにしないという指定で、これが無いと途中で止まります。:update は変更があったファイルだけを保存します。裏を返すと、この2行は確認を挟まずにディスクへ書き込むコマンドです。一時ディレクトリで流したときは3ファイルが実際に書き換わり、一致の無かった src/d.js だけが元のまま残りました。検索から除外していない node_modules/pkg/c.js も含まれます。実行前に :copen で対象を確かめ、試すときは本番のリポジトリではなく捨ててよいディレクトリで行ってください。
ここにVimとNeovimの差が出ます。Vim 9.1.1752の既定は 'hidden' が無効で、| update を付けずに :cfdo %s/oldName/newName/ge だけを流すと、1つ目のファイルを置換した直後に E37: No write since last change で巡回が止まりました。Neovim 0.12.4は 'hidden' が既定で有効なので、同じコマンドが3ファイルとも最後まで走ります。Vim側で :cfdo を使うなら、:set hidden を先に入れておくか、上のように :update で保存を挟む形にします。
似たコマンドに :cdo があり、こちらはファイルではなく一致した1件ごとに実行します。行単位の処理をしたいときはこちらですが、%s のようにファイル全体を対象にする置換と組み合わせると、同じファイルに対して同じ置換が一致の数だけ走ることになります。ファイル全体の置換は :cfdo、一致した行を1つずつ加工したいときは :cdo、と分けておくと無駄がありません。
検索から除外するファイルを決める
**/* で再帰的に検索すると、node_modules やビルド生成物まで舐めてしまいます。'wildignore' に書いたパターンは :vimgrep の対象からも外れます。手元の例では、設定前に5件だった結果が */node_modules/* を足すと4件になり、node_modules 配下の1件だけが消えました。
set wildignore+=*/node_modules/*,*/.git/*,*/dist/*,*.min.js
効くのはVimがワイルドカードを展開する段階なので、除外はかなり強く働きます。除外したファイルは名指ししても検索できず、:vimgrep /oldName/gj node_modules/pkg/c.js と書いたときは E479: No match になりました。生成物の中をどうしても見たいときは、その場だけ :set wildignore= に戻してから実行します。
これを設定しておくと、ファイル名の補完からも同じものが消えます。プロジェクトごとに違うなら、ローカルの設定ファイルを読み込む仕組みを入れておくと使い回せます。
まとめ
開いていないファイルまで含めて横断検索したいときは:vimgrep /パターン/gj ファイル指定のあとに:cwindowです。**の再帰ワイルドカードと組み合わせれば、プロジェクト全体からのキーワード検索も1コマンドで完結します。一覧をそのまま :cfdo につないで一括置換まで進むときだけは、設定次第で途中で止まり、止まらなければディスクまで書き換わることを忘れないでください。