「grep で確かに存在するはずの定義が見つからない」「import している側はヒットするのに、定義している行だけ出てこない」「1ファイルだけエディタの全文検索に出てこない」——正規表現やパスの指定を何度見直しても、この形は直りません。
原因がファイルの中身ではなく、そのファイルがテキストと認識されていないことにある場合があります。grep と ripgrep はバイナリと判定したファイルを黙って飛ばすため、症状は「検索対象に入っていない」という形でしか現れません。しかも引き金は、生の NUL バイト(0x00)が1個混ざるだけで足ります。
この記事では、原因を確定させる3ステップ(file → 混入位置 → 前後の内容)、挙動を変えずに直す方法、そしてリポジトリ全体を点検するコマンドまでを扱います。
file が対象を data と返すなら、grep と ripgrep はそのファイルを読まずに飛ばしている 引き金は生の NUL バイト(
0x00)が1個混ざること。1バイトで足りる wc -l は行数を返し sed でも中身が読めるので、ファイルが壊れているようには見えない 混入位置は
python3 で制御文字を列挙すれば一意に出る 直し方は文字列リテラルをエスケープ表記へ置き換え、
file の判定が戻ることを確認する 目次を開く
症状: 片側だけがヒットしない
実際に踏んだ形はこうでした。定数 FIELD_SPECS を定義しているファイルが1つあり、それを import している画面側のコードは grep で普通にヒットするのに、定義している行だけがどれだけ検索しても出てきません。
grep -rn "FIELD_SPECS" src/ # import 側は出るが、定義側の1ファイルだけ出ない
wc -l src/lib/fieldSpecs.ts # → 256 行。ファイルは読めている
sed -n '1,20p' src/lib/fieldSpecs.ts # → 中身も普通に表示される 行数も数えられ、sed でも読める。壊れている感じが全くないのが厄介なところです。エディタの全文検索でも出てこないことがあります(内部で ripgrep を使っている場合)。
原因: 生の NUL バイトが1個混ざるとテキスト扱いされなくなる
grep も ripgrep も、対象を読む前に「これはバイナリか」を判定します。判定に引っかかったファイルは中身が検索されません。NUL バイトはその代表的な引き金で、1バイト混ざれば十分です。ファイルサイズや行数は関係ありません。
厄介なのは表示です。バイナリと判定された場合は Binary file … matches のような行が出るのが基本ですが、環境や版によっては何も出ないことがあります。つまり「本当に0件」と「読まれていない」が区別できません。
今回のケースの原因は、区切り文字を書くときに '\x00' というエスケープ表記ではなく、生の NUL バイトをそのままソースに埋め込んでいたことでした。
3ステップで確定させる
1. file を叩く
いちばん速いのはこれです。テキストとして認識されていれば言語名と Unicode text などが返り、そうでなければ data だけが返ります。
file src/lib/fieldSpecs.ts
# NG: src/lib/fieldSpecs.ts: data
# OK: src/lib/fieldSpecs.ts: JavaScript source, Unicode text, UTF-8 text 2. 混入位置を列挙する
file が data を返したら、制御文字の位置とバイト値を出します。タブ・改行・復帰を除いた制御文字だけを拾う形にすると、混入箇所が一意に出ます。
python3 -c "b=open('src/lib/fieldSpecs.ts','rb').read(); print([(i,hex(c)) for i,c in enumerate(b) if c<9 or 13<c<32])"
# → [(4821, '0x0')] 3. その位置の前後を見る
python3 -c "b=open('src/lib/fieldSpecs.ts','rb').read(); print(b[4780:4870])" 前後を見れば、文字列リテラルの内側なのか、貼り付けで紛れ込んだ場所なのかが分かります。ここまでで原因は確定です。
直し方: 挙動を変えずにエスケープ表記へ
文字列リテラルの中にあった場合は、生のバイトを2文字のエスケープ表記に置き換えます。JavaScript・TypeScript・Python いずれも実行時の値は変わらないため、挙動を保ったまま直せます。
python3 - <<'EOF'
from pathlib import Path
p = Path("src/lib/fieldSpecs.ts")
b = p.read_bytes()
print("NUL の個数:", b.count(b"\x00"))
p.write_bytes(b.replace(b"\x00", rb"\x00"))
EOF 直した直後に、判定が戻ったことと grep でヒットすることを両方確認します。
file src/lib/fieldSpecs.ts # data ではなくなっているか
grep -rn "FIELD_SPECS" src/ | head # 定義側が出るか 文字列リテラルの外(コードの構造の中)に紛れていた場合は、置き換えではなく削除してから、その言語の構文チェックを1回通してください。
混入を早く見つける点検コマンド
一度踏んだら、次は自動で気づけるようにします。追跡しているソースだけを見て、テキストと認識されないものを挙げます。
git ls-files '*.ts' '*.tsx' '*.js' '*.py' | while read -r f; do
file "$f" | grep -q 'text' || echo "NOT TEXT: $f"
done 出力が空なら問題なし。pre-commit や CI にそのまま置けます。コミット前に落とせば、検索できないファイルがリポジトリに入りません。画像やフォントを巻き込まないよう、対象の拡張子は明示するのがこつです。
それでも中身を検索したい時
原因を直すのが本筋ですが、生成物やログのように直せないファイルを検索したいこともあります。その場合は「バイナリでもテキストとして扱う」オプションを使います。
grep -a "FIELD_SPECS" path/to/file # バイナリを text として処理する
rg --text "FIELD_SPECS" path/to/file # ripgrep なら --text(短縮形は -a) オプション名と既定の挙動は版によって変わることがあるので、手元の grep --help と rg --help で確認してください。
まとめ
- 「import 側はヒットするのに定義側が出ない」ときは、正規表現やパスより先に
fileを叩く -
dataと返るなら、grep と ripgrep はそのファイルを読まずに飛ばしている - 引き金は生の NUL バイト1個。
wc -lもsedも普通に動くので、壊れているようには見えない - 直し方は文字列リテラルをエスケープ表記へ置き換えること。実行時の挙動は変わらない
-
git ls-filesとfileを組み合わせた点検を1本置けば、次は数秒で切り分けられる
よくある質問(FAQ)
Q. Binary file … matches が出れば気づけるのでは?
出れば気づけますが、出ないことがあります。そもそもヒットしなければその行は出ませんし、環境によっては表示自体が省かれます。「0件」と「読まれていない」を画面から区別できないので、file で確かめる手順を先に置くのが確実です。
Q. なぜ1バイトで判定が変わるのですか?
バイナリ判定は「先頭の一定バイトを読んで、テキストとして無効なバイトが含まれるか」で行われます。NUL は通常のテキストに現れないため、1個でも判定が切り替わります。ファイル全体の何割が壊れているか、という見方はされません。
Q. NUL 以外にも引き金になるバイトはありますか?
制御文字全般が候補です。この記事で使った python3 のワンライナーはタブ・改行・復帰を除いた制御文字を列挙するので、NUL 以外の混入もそのまま見つかります。判定の細かい基準は実装と版によって変わるため、まず file が返す文字列を確認してください。
Q. Git の差分では気づけませんか?
差分としては出ますが、原因には見えません。行の中身が同じに見えるのに「なぜかこの行が変更扱いになる」形で現れます。git diff の見た目が説明できないときも、内容を疑う前に file を1回叩くほうが早いです。
