Vimのvimgrepとgrepの違い(内部grepと外部grep)
Vimには全文検索コマンドが2種類あります。Vim標準機能の:vimgrep(内部grep)と、OSのgrepコマンドを呼び出す:grep(外部grep)です。名前が似ていて混同しやすいものの、性質はかなり異なります。そもそもどの範囲を探すのにどれを使うかは文字列検索の記事の最後で並べています。
違うのは速さだけではありません。同じ言葉を同じディレクトリで探しているのに、一方は複数件でもう一方は0件、という食い違いが実際に起きます。この記事ではその食い違いの出どころを手元の件数で説明します。後半では、どちらで探しても結果が入るquickfixの一覧を行き来する方法と、そこから複数ファイルの一括置換へつなぐ手順を扱います。
この記事の件数は、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() の長さで数えました。一覧の操作と一括置換の節は、2026-11-05に Vim 9.1.1752 と Neovim 0.12.6 で、oldName を src/a.js に3箇所(うち2箇所は同じ行)、src/lib/b.js と node_modules/pkg/c.js に1箇所ずつ置いた一時ディレクトリへ打ち直した結果です。
目次(11項目)
| キー | これだけ覚える |
|---|---|
: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自身なので、シェル側で globstar を有効にしていなくてもサブディレクトリまで再帰的に探せます。
:vimgrep /oldName/gj **/*.js
:vimgrep /oldName/gj % " 編集中のファイルだけ
:vimgrep /oldName/gj ## " 引数リストのファイル全部
パターンの後ろの g と j は別々の意味を持ちます。g は1行に複数の一致があれば全部拾う指定で、外すと2箇所ある行が1件に丸められ、5件が4件になりました。j は一覧を作るだけでジャンプしない指定です。外すと最初の一致へ勝手に移動し、見るつもりのなかった node_modules/pkg/c.js が開きます。gj を既定の形にしておくと扱いやすくなります。対象の ## は引数リストに入っているファイルを指すので、範囲を先に固定しておきたいときに使えます。
その代わり、対象のファイルはVimの編集バッファとしていったん読み込まれます。40行のJavaScriptファイルを2,000個(合計7.8MB)置いたディレクトリで計ると、:vimgrep /needle/gj **/*.js は1.5秒から1.6秒かかり、同じ範囲をシェルの grep -rn で探すと0.09秒から0.11秒でした。遅さの正体はパターンの照合よりファイルの出し入れにあり、検索が終わった直後にバッファを数えると41個、そのうち読み込み済みは1個だけでした。一致しなかったファイルも一度は読み込まれて捨てられています。数十から数百ファイルなら待たされる感覚はほとんどありませんが、リポジトリ全体を無条件に舐める使い方には向きません。ここから先は、読み込むという性質が速度以外に何をもたらすかを見ていきます。
外部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 フラグで抑えます。フラグの読み方は内部grepの節に書きました。
結果一覧を開いて行き来する
どちらで探しても、結果は同じquickfixの一覧に入ります。一覧を出すコマンドは2つあり、:cwindow は一致があるときだけ開きます。0件のときに打ってもウィンドウは増えず、すでに開いていた一覧のほうは閉じました。:copen は0件でも開きます。結果を必ず目で見たいときは :copen、検索のたびに自動で出したいときは :cwindow と使い分けます。
autocmd QuickFixCmdPost *grep* cwindow
設定ファイルに書いておくと、grep系のコマンドのあとに毎回一覧が開きます。発火したコマンド名を記録して数えると、:vimgrep・:lvimgrep・:grep・:vimgrepadd・:lgrep のいずれでも発火しました。ただし開くのは :cwindow なので、ロケーションリストに入る :lvimgrep のあとでもquickfixの一覧が出ます。そちらも自動で出したいなら、: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 になります。結果そのものを絞りたいときは、VimにもNeovimにも同梱されている :packadd cfilter の :Cfilter を使います。:Cfilter /a.js/ を実行すると5件から3件に減りました。
oldName の例とは別の、TODO を3か所に置いたディレクトリで :vimgrep /TODO/gj **/*.js のあと :copen 5 を実行した画面検索結果からまとめて置換する
quickfixに入ったファイルすべてに同じ処理を流せます。:cfdo は結果に含まれる各ファイルで1回ずつコマンドを実行するので、置換と保存をつなげると一括置換になります。3ファイルに散った5件の結果で回数を数えると、:cdo は5回、:cfdo は3回実行されました。
:vimgrep /oldName/gj **/*.js
:cfdo %s/oldName/newName/ge | update
e フラグは一致が無くてもエラーにしないという指定で、これが無いと途中で止まります。:update は変更があったファイルだけを保存します。裏を返すと、この2行は確認を挟まずにディスクへ書き込むコマンドです。一時ディレクトリで流したときは3ファイルが実際に書き換わり、検索から外していなかった 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.6は 'hidden' が既定で有効なので、同じコマンドが3ファイルとも最後まで走り、未保存のバッファが3つ残ります。Vim側で :cfdo を使うなら、:set hidden を先に入れておくか、上のように :update で保存を挟みます。
一致した1件ごとに実行する :cdo は、行単位の加工に向いています。%s のようにファイル全体を対象にする置換と組み合わせると、同じファイルに同じ置換が一致の数だけ走るので、ファイル全体の置換は :cfdo に任せます。
検索から除外するファイルを決める
**/* で再帰的に検索すると、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= に戻してから実行すると5件に戻ります。外部grepの :grep にはこの設定は効かないので、除外は呼び出すコマンドのフラグで指定します。
まとめ
2つのコマンドは、速い遅いよりも見ている対象が違います。:vimgrepはVimが読み込んで解釈したあとのテキストを、:grepは外部コマンドが読むディスク上のバイト列を探します。パターンの方言、未保存の変更、文字コードで結果が割れるのは、すべてこの一点から出てきます。
使い分けの目安としては、リポジトリ全体を舐める広い検索を:grepに任せて 'grepprg' を速いツールへ向けておき、編集中のバッファが絡む検索と日本語まじりのディレクトリを:vimgrepに回す形が扱いやすくなります。外部コマンドを差し替えたときに 'grepformat' まで合わせておけば、あとで一覧から飛べない原因を探さずに済みます。一覧が出たあとは :cc と :colder で行き来し、複数ファイルの置換は :cfdo に :update を挟んでつなげます。