Vimのvimgrepとgrepの違い(内部grepと外部grep)
Vimには全文検索コマンドが2種類あります。Vim標準機能の:vimgrep(内部grep)と、OSのgrepコマンドを呼び出す:grep(外部grep)です。名前が似ていて混同しやすいものの、性質はかなり異なります。そもそもどの範囲を探すのにどれを使うかは検索まとめで並べています。
違うのは速さだけではありません。同じ言葉を同じディレクトリで探しているのに、一方は複数件でもう一方は0件、という食い違いが実際に起きます。この記事ではその食い違いの出どころを手元の件数で説明します。書式やquickfixウインドウの操作そのものはvimgrepで複数ファイルを横断検索する記事が担当なので、そちらへ渡します。
この記事の件数は、Vim 9.1.1752 と Neovim 0.12.4 を macOS 26.4.1 上で -Nu NONE 付きで起動し、一時ディレクトリのファイルへ打った結果です。外部grepとして走ったのは /usr/bin/grep(BSD grep 2.6.0-FreeBSD)で、件数は getqflist() の長さで数えました。
目次(8項目)
| キー | これだけ覚える |
|---|---|
:vim {パターン} {対象} | 内部grep。環境非依存だが低速 |
:grep {パターン} {対象} | 外部grep。高速だが環境依存 |
set grepprg=internal | 外部grepを内部grepの動作に切り替え |
内部grep(vimgrep)
:vimgrep {検索パターン} {検索対象}
:vim {検索パターン} {検索対象}
Vim標準のExコマンドで、照合はVimのプロセスの中だけで完結します。外部プログラムを一切呼ばないので、grepの入っていない環境でも書式が変わりません。'shell' を /no/such/shell という存在しないパスに変えてから :vimgrep /fooQux/gj src/*.js を流しても、結果は1件のままでした。
その代わり、対象のファイルはVimの編集バッファとしていったん読み込まれます。この出し入れが速度の上限を決めていて、2,000ファイルを対象にした所要時間の実測は複数ファイル横断検索の記事にあります。この記事で見ていくのは、読み込むという性質が速度以外に何をもたらすかのほうです。
外部grep(grep)
:grep {検索パターン} {検索対象}
:gr {検索パターン} {検索対象}
OS側のgrepコマンドを呼び出すExコマンドです。何を呼ぶかは 'grepprg' が持ち、Vim 9.1.1752 の既定値は grep -n $* /dev/null でした。$* の位置に、渡した引数がそのまま差し込まれます。
# :grep fooQux src/*.js と打ったときに実際に走るコマンド
grep -n fooQux src/*.js /dev/null
末尾の /dev/null は、対象が1ファイルだけのときでもgrepに出力へファイル名を付けさせるための細工です。引数を記録するだけのスクリプトを 'shell' に据えて確かめると、Vimはこの1行を丸ごとシェルへ渡していました。先ほどの存在しないシェルのままで同じ検索を打てば、当然ながら結果は0件です。
Neovim 0.12.4 の既定値は grep -HIn $* /dev/null で、フラグが2つ多くなっています。この差はバイナリファイルで結果として出ました。needleWord のあとにNULバイトを置いたファイルを :grep needleWord src/bin.dat で探すと、Vimでは Binary file src/bin.dat matches の1行がそのままquickfixに入り、行番号を持たない飛べない項目になります。Neovimは -I がバイナリを読み飛ばすので0件でした。同じファイルを :vimgrep で探せば、1行1桁目を指す飛べる項目が1件見つかります。
比較まとめ
ここまでに出た違いと、以降の節で確かめる違いを並べます。手順はそれぞれの節に書いています。
| 比較軸 | 内部grep(vimgrep) | 外部grep(grep) |
|---|---|---|
| 照合する場所 | Vimのプロセス内 | 'shell' 経由の別プロセス |
| パターンの方言 | Vimの正規表現 | 呼び出すコマンドの方言 |
| 行をまたぐ検索 | \n でまたげる | 行単位 |
| 未保存のバッファ | 対象になる | 対象にならない |
| 文字コード | 'fileencodings' で判定 | バイト列のまま |
| 最初の一致へ移動 | j フラグで抑止 | ! で抑止 |
| 実行速度 | ファイル数に比例して重い | 高速 |
結果の入れ物のほうには差がありません。:vimgrepと:grepはquickfixリストに、:lvimgrepと:lgrepはウィンドウごとのlocationリストに入ります。:lgrep! needleWord src/*.txt のあとに両方を数えると、quickfixは0件のままでlocationリストに1件入りました。
正規表現の方言が違う
いちばん引っかかりやすいのがパターンの書き方です。:vimgrepのパターンはVimの正規表現で、/ で検索するときと同じものが書けます。:grepのパターンは文字列として外部コマンドへ渡るだけで、方言はそのコマンドが決めます。
src/a.js に fooBar と fooQux を1つずつ置いて、同じパターンを両方へ渡した結果が次の表です。
| 渡したパターン | vimgrep | grep |
|---|---|---|
\vfoo(Bar|Qux) | 2件 | 0件 |
foo\zsBar | 1件 | 0件 |
\vlet.*\n.*let | 1件 | 0件 |
-E 'foo(Bar\|Qux)' | 指定できない | 2件 |
1行目の \v はvery magicと呼ばれる指定で、括弧と縦棒を正規表現の記号として扱わせます。BSD grepの既定は基本正規表現なので、\v も括弧も縦棒もただの文字として読まれ、そんな文字列は無いという答えになります。2行目の \zs は一致の開始位置をずらすVim固有の記法で、外部コマンドに対応するものはありません。3行目の \n は、行単位で読むgrepには渡しようがない指定です。
外部grep側で括弧と縦棒を使いたいなら、コマンドのフラグで拡張正規表現に切り替えます。:grep -E 'foo(Bar\|Qux)' src/*.js と書けば2件そろいました。文字クラスで足りる場面なら :grep 'foo[BQ]' src/*.js でも同じ2件です。渡す引数はgrepのコマンドラインそのものなので、フラグは自由に足せます。
縦棒を \| と書いているのは打ち間違いではありません。Exコマンドの行では | がコマンドの区切りとして先に解釈され、引用符で囲んでも:grepの引数はそこで切れます。エスケープせずに :grep -E 'foo(Bar|Qux)' src/*.js と打つと、シェルへ届くのは grep -n -E 'foo(Bar までで、zsh:1: unmatched ' というエラーで終わりました。:vimgrepのパターンは / で囲まれているためこの影響を受けません。
未保存の内容と文字コードで結果が変わる
読み込む側と読み込まない側の差がはっきり出るのがここです。:vimgrepは対象がすでにバッファとして開かれていればその中身を見ます。:grepが呼ぶのはディスクを読む外部コマンドなので、保存していない編集は見えません。
src/a.js を開いて draftToken という単語を含む行を1つ挿入し、保存しないまま両方を打ちました。:vimgrep /draftToken/gj src/*.js は1件、:grep 'draftToken' src/*.js は0件です。ディスク上のファイルは元のままなので、外部grepの0件も誤りではありません。書きかけの変更まで含めて探すなら内部grep、と割り切っておくと迷いません。
文字コードでも同じ形の食い違いが出ます。:vimgrepは読み込む段階で 'fileencodings' の候補を順に試し、内部の文字コードへ変換してから照合します。外部grepは変換をしないので、バイト列が一致するものしか当たりません。
「設定ファイルの検索」と書いたShift_JISのファイルと、「検索の基本」と書いたUTF-8のファイルを1つずつ置き、検索 の2文字で探しました。:vimgrep /検索/gj src/*.txt は、素の-Nu NONEでは1件でした。既定の'fileencodings'がucs-bom,utf-8,default,latin1で日本語のエンコーディングを含まないため、Shift_JIS側がlatin1として読まれて当たりません。set fileencodings=ucs-bom,utf-8,sjis,default,latin1のように候補を足すと2件になり、Shift_JIS側も拾えました。:grep '検索' src/*.txt は1件、当たったのはUTF-8のファイルだけでした。Shift_JIS側を開くと 'fileencoding' が sjis になっており、変換を挟んでいたことが確認できます。
2つある意味が長いあいだ分からず、速いほうだけを使っていました。考えが変わったのは、昔の設定を引っ張り出して整理していたときです。同じ言葉で探しているのに片方は0件、片方は数件という状態に出くわして、そこで初めて別々の仕組みだと理解しました。それ以来、0件で返ってきたらもう片方でも打ち直す癖が付いています。本当に無いのか、探し方が合っていないのかが、その一手で分かれます。
grepprg と grepformat を差し替える
外部grepで呼ぶコマンドは 'grepprg' で決まります。Windowsの既定は findstr.exe が見つかれば findstr /n、無ければ grep -n だと :help にあります。速いツールを入れているなら、ここだけ入れ替えられます。
set grepprg=grep\ -n\ $*\ /dev/null " Unixの既定値
set grepprg=findstr\ /n " Windowsの既定値
set grepprg=rg\ --vimgrep\ --smart-case
set grepformat=%f:%l:%c:%m
下2行がripgrepへ差し替える例です。ripgrepは既定で.gitignoreを読み、除外されたファイルを検索対象から外します。--no-ignoreを付ければ除外を無視して全部を見に行きます。外部プログラムに任せる以上、こうした「何を検索するか」の判断もそのプログラムの流儀に従うことになります。
'grepformat' は、外部コマンドが吐いた行をどう読むかを決めます。既定値は %f:%l:%m,%f:%l%m,%f %l%m で、ファイル名・行番号・本文の3つを取る形です。ripgrepの --vimgrep は桁を1つ足した ファイル:行:桁:本文 を出すので、受け取る側も %f:%l:%c:%m に合わせます。
合っていないと何が起きるかは数えられます。ファイル:行:桁:本文 を1行出すだけのスクリプトを 'grepprg' に据えて既定の 'grepformat' で受けたところ、項目は1件入ったものの桁は0、本文は 5:let fooQux = 2; と桁の数字を巻き込んだ文字列になりました。%f:%l:%c:%m に変えると桁が5、本文が let fooQux = 2; にそろいます。消えるのではなく静かにずれる壊れ方です。
行番号ごと失われる形もあります。'grepprg' から -n を落として grep $* /dev/null にすると、出力は src/a.js:let fooQux = 2; になり、これも1件は入るものの、ファイル名にも行番号にも割り当てられない項目として残りました。差し替えたあとに一覧から移動できなくなったときは、出力の形と 'grepformat' を突き合わせるのが早道です。
逆向きの設定もあります。set grepprg=internal と書くと、:grepが外部コマンドを呼ばずに内部grepとして動きます。
Special value: When 'grepprg' is set to "internal" the :grep command works like :vimgrep, :lgrep like :lvimgrep, :grepadd like :vimgrepadd and :lgrepadd like :lvimgrepadd.
この状態で打つと、:grep! /fooBar/gj src/*.js のようなvimgrep風の書式でも、:grep! fooBar src/*.js のような素の引数でも1件見つかりました。外部grepを疑ったときの切り分けに使えます。
移動の抑止の仕方も2つで違います。:grepは既定で最初の一致へ飛び、! を付けると飛びません。start.txt を開いた状態から :grep 'needleWord' src/*.txt を打つと src/z.txt の3行目へ移動し、:grep! では start.txt の1行目に留まりました。:vimgrep側の ! は別の意味なので、そちらは j フラグで抑えます。フラグの読み方はvimgrepの基本の書式にあります。
まとめ
2つのコマンドは、速い遅いよりも見ている対象が違います。:vimgrepはVimが読み込んで解釈したあとのテキストを、:grepは外部コマンドが読むディスク上のバイト列を探します。パターンの方言、未保存の変更、文字コードで結果が割れるのは、すべてこの一点から出てきます。
使い分けの目安としては、リポジトリ全体を舐める広い検索を:grepに任せて 'grepprg' を速いツールへ向けておき、編集中のバッファが絡む検索と日本語まじりのディレクトリを:vimgrepに回す形が扱いやすくなります。外部コマンドを差し替えたときに 'grepformat' まで合わせておけば、あとで一覧から飛べない原因を探さずに済みます。