Vimの正規表現で最短マッチを使う(.\{-})
HTMLのhref属性の値だけを消したくて.*で置換したら、最初のタグから最後のタグまでまるごと巻き込まれてしまいました。Vimの正規表現は既定で貪欲(できるだけ長くマッチする)に動くので、これを避けるには最短マッチの書き方が要ります。置換そのものの打ち方は別にまとめてあるので、ここではパターンの書き方だけを扱います。以下の実行結果と所要時間は、macOS 26.4.1のVim 9.1.1752とNeovim 0.12.4に、設定ファイルを読まない状態(-u NONE)で実際に打たせたものです。
目次(8項目)
| パターン | これだけ覚える |
|---|---|
.* | 貪欲マッチ(できるだけ長く一致させる、デフォルト) |
.\{-} | 最短マッチ(できるだけ短く一致させる) |
.\{-1,} | 1文字以上の最短マッチ(他言語の.+?) |
\_.\{-} | 改行もまたぐ最短マッチ |
最短マッチの書き方
.\{-}
他の言語の正規表現でよく見る.*?に相当します。.*が「できるだけ長く」マッチしようとするのに対し、.\{-}は「できるだけ短く」マッチしようとします。
<a>1</a><a>2</a>
:s/<a>.\{-}<\/a>/X/X<a>2</a>
最短マッチなので、最初の<a>から一番近い</a>までだけが対象になり、2つ目のタグは残ります。同じ文字列に.*を使うと、最初の<a>から最後の</a>までを一気にマッチし、結果はXだけになります。注意したいのは、.\{-}が単独では0文字にマッチすることです。「できるだけ短く」を突き詰めると、どの位置でも空文字列で条件を満たしてしまいます。
<a href="a3"> " 実行前
X<XaX XhXrXeXfX=X"XaX3X"X> " :%s/.\{-}/X/g のあと
すべての文字と文字の間にXが挟まりました。最短マッチは前後を目印で挟んで初めて意味を持つ道具だ、という性質がここに出ています。もうひとつ、短ければ何でもよいわけではないというルールもあります。ヘルプにはこう書かれています。
BUT: A match that starts earlier is preferred over a shorter match: "a\{-}b" matches "aaab" in "xaaab".
手元でxaaabに:s/a\{-}b/<&>/を実行するとx<aaab>になり、Neovimでも同じでした。末尾のbだけのほうが短いのですが、開始位置が早いマッチが優先されます。長さより先に開始位置が決まる、と覚えておくと予測しやすくなります。
回数を指定する最短マッチ
a\{-} " 0回以上の最短マッチ(他言語の .*?)
a\{-1,} " 1回以上の最短マッチ(他言語の .+?)
a\{-2,} " 2回以上の最短マッチ
a\{-,3} " 0〜3回の最短マッチ
a\{-1,3} " 1〜3回の最短マッチ
a\{-3} " ちょうど3回
\{-}は.専用の記法ではなく、回数指定の\{n,m}に-を足した形です。-が「できるだけ少なく」の印だと考えると、下限付きの\{-1,}も範囲付きの\{-1,3}も同じ規則で読めます。aaaaaという5文字に:s/パターン/[&]/を実行し、マッチした範囲を角カッコで囲んだ結果が次の表です。
| パターン | 実行結果 | マッチ |
|---|---|---|
a\{-} | []aaaaa | 0文字 |
a\{-1,} | [a]aaaa | 1文字 |
a\{-2,} | [aa]aaa | 2文字 |
a\{-,3} | []aaaaa | 0文字 |
a\{-1,3} | [a]aaaa | 1文字 |
a\{-3} | [aaa]aa | 3文字 |
a* | [aaaaa] | 5文字 |
結果を決めているのは下限のほうです。上限は「これ以上は伸ばさない」という制限なので、効いているのを確かめるには当たらなくなる例のほうが分かりやすくなります。
:s/x.\{-1,3}b/<&>/ " xaaaab には当たらない(a が4個で上限を超える)
:s/x.\{-1,5}b/<&>/ " <xaaaab> になる
xaaaabに1本目を実行するとE486: Pattern not foundになり、行は残りました。上限を5に広げた2本目は<xaaaab>になります。なお\+のほうに-を足すことはできず、a\+\{-}と打つとVimではE62: Nested \{、NeovimではE871が返ります。1回以上の最短マッチはa\{-1,}と書きます。
他の言語の .*? はVimでは通らない
他の言語では*や+のうしろに?を足して最短にしますが、Vimにこの書き方はありません。厄介なのは、打ったときの反応が一定しないことです。very magicの\vを付けた状態で.*?と打つと、手元のVimとNeovimで別のエラーが返りました。
:s/\vhref\="\zs.*?\ze"//g
E62: Nested ? " Vim 9.1.1752
E871: (NFA regexp) Can't have a multi follow a multi " Neovim 0.12.4
これはエディタの違いではなく、動いている正規表現エンジンの違いでした。:set regexpengine?で確かめると、手元のVimはregexpengine=1(旧エンジン)、Neovimはregexpengine=0(NFAを優先)を返します。Vim側を:set regexpengine=2にするとE871が出て、Neovim側を:set regexpengine=1にするとE62が出ました。ヘルプの既定値は0ですが、macOS付属のVimは--cleanで起動しても1を返したので、自分の環境は打って確かめるほうが確実です。エラーになるならまだ気づけます。困るのは既定のmagicのまま.*?と打った場合で、?がただの文字なので「任意の文字列とクエスチョンマーク」を探しに行きます。返るのは次の1行だけで、書き方を間違えたのか対象が無いのかを区別できません。
:s/href="\zs.*?\ze"//g
E486: Pattern not found: href="\zs.*?\ze"
同じ理由で{も既定のmagicではただの文字です。X.{-}YというパターンはXa{-}Yという文字列に当たりました。最短マッチのつもりで打った.{-}が、エラーも出さずにまったく別のものを探している状態になります。
very magic なら {-} と書ける
パターンの先頭に\vを付けると打ち消しがほぼ不要になり、最短マッチも{-}と書けます。長いパターンではこちらのほうが読めます。
:%s/\v(href\=")(.{-})(")/\1\3/g " グループで挟む書き方
:%s/\vhref\="\zs.{-}\ze"//g " \zs \ze で狭める書き方
:%s/\v\<(.{-})\>//g " タグを丸ごと消す
/\v\<a href\="(.{-})" " 検索でも書き方は同じ
ただし\vは打ち消しを減らす代わりに、記号の多くをメタ文字にします。=は「直前を0回か1回」、<と>は単語の境界です。\vhref="とそのまま書くとfが省略可能という意味になって当たらず、3本目の山カッコを打ち消し忘れると<a href="u1">t1</a>が< =""></>に壊れました。逆に\V(very nomagic)では.がただの文字になり、任意の1文字を\.と書きます。
:%s/\Vhref="\zs\.\{-}\ze"//g " very nomagic。任意の1文字は \. と書く
\{-}のほうは\Vでも形が変わりません。3通りの書き方を同じ行に流したところ、結果はすべて同じでした。\vと\Vそのものの入り口は検索と正規表現の基本をまとめた記事で扱っています。
実例:href属性の値を消す
:%s/\(href="\).\{-}\("\)/\1\2/g
| パターン | 役割 |
|---|---|
\(href="\) | グループ1:href="までを記憶 |
.\{-} | 最短マッチ:属性値の部分(次の"の直前まで) |
\("\) | グループ2:閉じの"を記憶 |
置換後は\1\2でグループ1とグループ2だけを残し、間の属性値を消します。目印が複数種類ある場合も考え方は同じで、\(始まりA\).\{-}\(終わりA\)のようにグループと最短マッチのペアを増やしていくだけです。長くて読みにくいパターンも、この3点セットの繰り返しに分解すると、どこが可変部分なのかが見えてきます。
\zs と \ze で置換の対象を狭める
グループを2つ書いて\1\2で書き戻すのは、消したい部分だけを狙うための遠回りでもあります。\zsと\zeを使うと、マッチはさせたまま置換の対象だけを縮められます。
:%s/href="\zs.\{-}\ze"//g
\zsが「ここから置換の対象」、\zeが「ここまで」の印です。<a href="a1" title="t1"><a href="a2" title="t2">という行にグループ版と\zs版を流すと、どちらも<a href="" title="t1"><a href="" title="t2">になりました。置換後の側が空のままで済むので、グループ番号の打ち間違いが起きません。\zs単体の練習問題は正規表現置換のドリルにあります。
改行をまたぐときは \_. を足す
.は改行に一致しないので、.\{-}も行をまたぎません。属性やコメントが複数行に分かれて書かれたHTMLでは、これが理由で1件も当たらないことがあります。改行を含めた「任意の1文字」は\_.です。次の6行を用意して、3通りのパターンを順に試しました。
<p>本文</p>
<!-- TODO:
あとで消す
-->
<p>続き</p>
<!-- 2つ目 -->
:%s/<!--.\{-}-->//g " 1行に収まったコメントだけが消えた
:%s/<!--\_.\{-}-->//g " 複数行のコメントも消えた
:%s/<!--\_.*-->//g " 貪欲。最初の <!-- から最後の --> まで消えた
1本目で消えたのは<!-- 2つ目 -->だけでした。2本目は両方のコメントが消え、複数行だったコメントは1行の空行になります。3本目は<p>本文</p>の1行しか残りませんでした。\_を付けると探索範囲がファイルの終わりまで一気に広がるので、貪欲な\_.*との組み合わせは避けたほうが安全です。VimでもNeovimでも同じ結果でした。
置換する前に確認する
最短マッチは書いた本人の意図より広く当たることがあるので、実行の前に当たり具合を見ます。件数を数えるnと、1件ずつ止まるcの使い方は置換の練習問題と行の中の文字列を置き換える方法で扱っています。
最短マッチと組み合わせるときに効くのはeフラグです。1件も見つからなかったときにE486を出さないようにするもので、手元でe無しに存在しないパターンを置換するとE486: Pattern not foundの例外で止まり、e付きでは何事もなく通りました。.\{-}は当たるファイルと当たらないファイルが混ざりやすいので、:bufdoや:argdoで複数のファイルに同じ置換をかけるときは、置換の末尾にeを足しておかないと当たらないファイルで止まります。
フラグの一覧と範囲の指定は文字列を一括置換する手順にまとめてあります。
最短マッチと否定文字クラス、速いのはどちらか
引用符の中身を取るだけなら、[^"]*のように「区切り文字以外」を並べる書き方もあります。最短マッチは1文字ずつ後戻りするから遅い、という説明をよく見かけるので、実際に測りました。<li><a href="...">を並べた5万行(5.8MB)と、閉じ引用符が無いために1行も当たらない2万行を用意し、reltime()で3回ずつ計測しています。後者は<li><a href=で始めたあと引用符を閉じないまま480字ほど続く行にしました。行が短いと差そのものが小さくなるので、手元で試すときは1行の長さも合わせてください。
/"[^"]*" " 引用符の中身(最短マッチを使わない書き方)
/<[^>]*> " タグ(同上)
:%s/href=".\{-}"/href=""/ge " 計測に使った置換その1
:%s/href="[^"]*"/href=""/ge " 計測に使った置換その2
| パターン | 全行が当たる5万行 | 1行も当たらない2万行 |
|---|---|---|
.\{-}(Vim) | 0.049秒 | 0.066秒 |
[^"]*(Vim) | 0.050秒 | 0.091秒 |
.\{-}(Neovim) | 0.293秒 | 0.554秒 |
[^"]*(Neovim) | 0.302秒 | 0.584秒 |
3回のうちの最速値です。全行が当たる素材では同じエディタの中で差が数パーセントに収まり、後戻りが最大になるはずの「1行も当たらない」素材では、むしろ.\{-}のほうが3割ほど速く終わりました。速さを理由に[^"]*を選ぶ根拠は、この2つの条件では見つかりません。大きく差が出たのはパターンではなくエンジンのほうです。同じ5万行をNeovimで:set regexpengine=1にすると0.099秒、:set regexpengine=2では0.295秒で、3倍の開きがありました。Vimでも0.049秒と0.161秒で同じ比率です。VimがNeovimより6倍速く見えたのは、既定のエンジンが違うからでした。置換が重いときに見るのは、パターンの書き方より先に:set regexpengine?のほうです。
それでも[^"]*を選ぶ理由はあります。速度ではなく、行き過ぎを防げることです。<a href="u1" title="t1" class="c">に:s/href=".\{-}" class="/href="Z" class="/を実行すると、間のtitle="t1"ごと飲み込んで<a href="Z" class="c">になりました。[^"]*版は引用符をまたげないので当たらず、行はそのまま残ります。引用符の中身をその場で書き換えるだけなら、検索そのものが要らないci" によるブロックの書き換えのほうが手数も少なくなります。
.*は貪欲、.\{-}は最短。回数を絞るなら\{-1,}、改行をまたぐなら\_.\{-}、置換の対象を狭めるなら\zsと\ze。この4つで属性値の書き換えはほぼ足ります。
この記法はしばらく手が覚えてくれず、.\{-\}のように閉じカッコまで打ち消してしまうことが何度もありました。あとで試したところ、これはこれで正しく動きます。ヘルプにも閉じカッコの前に打ち消しを置いてよいと書かれていて、余計に打っていたのに一度も怒られなかった理由がやっと分かりました。
まとめ
貪欲マッチで意図しない範囲まで巻き込んでしまったら、.\{-}による最短マッチを試すと効果的です。単独では0文字にマッチするので、開始と終了の目印で挟んで使うのが前提になります。目印を足しても足りないときにだけ、当たる範囲を広げていくと事故が減ります。他の言語の.*?はVimでは通らず、magicのままだと文法エラーにすらならないまま別のものを探すので、打つ前にgnで件数を数えておくと取り違えがその場で分かります。速度は実測したかぎり最短マッチが不利になる場面が見当たらなかったので、[^"]*との使い分けは速さではなく、区切り文字をまたがせたいかどうかで決めれば十分です。