grep の正規表現の文字クラス中のハイフンをバックスラッシュでエスケープしようとしてはいけない
タイトルオンリー。grep
(GNU Grep, BSD Grep) に限らず「POSIX 正規表現」を使う場所であれば当てはまる。
問題設定
「数字・アルファベット・アンダーバー・ハイフン」のいずれかだけで構成されている行(例: 00-hello_world
)を grep
で抜き出したい、という場合を考える。
まちがい
以下はパッと見良さそうに見えるが誤っている。
$ echo '00-hello_world' | grep -E '^[0-9A-Za-z_\-]+$'
00-hello_world
この正規表現はバックスラッシュを含む文字列にもマッチしてしまう。
$ echo 'x\y' | grep -E '^[0-9A-Za-z_\-]+$'
x\y
後段にバックスラッシュを含む文字列が渡ると困る処理があるととても面白いことになるかもしれない(婉曲表現)。
ちなみにアンダーバーとハイフンの配置が逆だと \
に加えて ]
, ^
にもマッチする(「\
から _
までの文字」と解釈されるので)。この場合 -
にはマッチしないのでミスに気付けるかもしれない。
$ echo '\]^' | grep -E '^[0-9A-Za-z\-_]+$'
\]^
$ echo '-' | grep -E '^[0-9A-Za-z\-_]+$'
せいかい
grep
で利用できる POSIX 正規表現において、文字クラス中のハイフンをハイフンそのものとして扱うには文字クラスの最初か最後に置く必要がある。
文字クラス中のバックスラッシュはバックスラッシュそのものとして扱われる(後に続く文字をエスケープなんてしない)。
$ echo '00-hello_world' | grep -E '^[0-9A-Za-z_-]+$'
00-hello_world
$ echo '00-hello_world' | grep -E '^[-0-9A-Za-z_]+$'
00-hello_world
Stack Overflow にこれについての質問と回答がある。
実は 正規表現 - Wikipedia の「構文」節 にもきちんとこのことは書かれている。
正規表現中の「
-
」は括弧内の最初か最後にあるときのみ、リテラルとして扱われる。例えば正規表現「[abc-]
」や正規表現「[-abc]
」は一文字「a
」、「b
」 、「c
」 、「-
」にマッチする。 一文字「]
」自身にマッチさせる最も手っ取り早い方法は、囲んでいる括弧内で、括弧が最初になるようにすることである。 例えば正規表現「[][ab]
」は一文字「]
」、「[
」、「a
」、「b
」にマッチする。
せいかい (2)
現代において我々が慣れ親しんでいる Perl 互換正規表現 (PCRE) およびその派生ではバックスラッシュで文字クラス中のハイフンをエスケープできる。
pcre
エンジンが有効化された GNU Grep を使っているのであれば -P
(--perl-regexp
) で Perl 互換正規表現が使える。
$ echo '00-hello_world' | grep -P '^[0-9A-Za-z\-_]+$'
00-hello_world
$ echo 'x\y' | grep -P '^[0-9A-Za-z\-_]+$'
The Silver Searcher (ag
) や ripgrep (rg
) を使った場合も同様の挙動となる。
$ echo '00-hello_world' | ag '^[0-9A-Za-z\-_]+$'
00-hello_world
$ echo 'x\y' | ag '^[0-9A-Za-z\-_]+$'
$ echo '00-hello_world' | rg '^[0-9A-Za-z\-_]+$'
00-hello_world
$ echo 'x\y' | rg '^[0-9A-Za-z\-_]+$'
余談だが ripgrep の正規表現(すなわち Rust の regex
)は厳密には PCRE ではなく、高速化(最悪時間計算量の削減)のため後方参照・後読み・先読み機能が省かれている。
-P
(--pcre2
) オプションを付けると pcre
エンジンが使われる。
$ echo '00-hello_world' | rg -P '^[0-9A-Za-z\-_]+$'
00-hello_world
$ echo 'x\y' | rg -P '^[0-9A-Za-z\-_]+$'
ロケールについて
なお、このような問題設定において grep -E
を使う場合は LC_ALL=C
をつけた方が動作が高速な上予期せぬ事故を防げる。
$ echo '00-hello_world' | LC_ALL=C grep -E '^[0-9A-Za-z_-]+$'
00-hello_world
例えば A-Za-z0-9
は [:alnum:]
と書けそうな気がしてしまうが、ja_JP.UTF-8
などのロケールではダイアクリティカルマークの付いたアルファベット(例: é
, ö
)が来たときに全てが終わってしまう。
$ echo 'é' | LC_ALL=ja_JP.UTF-8 grep -E '^[[:alnum:]_-]+$'
é
$ echo 'é' | LC_ALL=C grep -E '^[[:alnum:]_-]+$'
ちなみに grep -P
, ag
, rg
ではロケールが ja_JP.UTF-8
でも é
にはヒットしないが、rg -P
ではヒットしてしまう(C
ロケールにしてもヒットする)。これを回避するには --no-unicode
を付ける必要がある。
$ echo 'é' | LC_ALL=ja_JP.UTF-8 grep -P '^[[:alnum:]_-]+$'
$ echo 'é' | LC_ALL=ja_JP.UTF-8 ag '^[[:alnum:]_-]+$'
$ echo 'é' | LC_ALL=ja_JP.UTF-8 rg '^[[:alnum:]_-]+$'
$ echo 'é' | LC_ALL=ja_JP.UTF-8 rg -P '^[[:alnum:]_-]+$'
é
$ echo 'é' | LC_ALL=ja_JP.UTF-8 rg -P --no-unicode '^[[:alnum:]_-]+$'
おわりに
この手のミス、かつての自分が絶対どっかでやらかしているんだけど、どこでやらかしたかもう全くもって思い出せない……。