"api" === name が false になる。ログに出力すると両方とも api に見える。長さを取ると片方だけ 4 である。
こうなったときの犯人はだいたい不可視文字 である。ゼロ幅スペース(U+200B)、BOM(U+FEFF)、全角スペース(U+3000)、ノーブレークスペース(U+00A0)あたりが、Web ページからのコピー、翻訳サービスの出力、エディタの折り返し補助などに紛れて入る。
やっかいなのは、画面に何も表示されない ので目視で見つからないことと、エラーメッセージが原因を指さない ことである。JSON パースエラーは「予期しないトークン」としか言わないし、比較の不一致にいたっては例外すら出ない。
この記事では、不可視文字を位置つきで洗い出して種別ごとに掃除する 手順をまとめる。ブラウザで済ませたいときの不可視文字チェッカー と、CI やスクリプトに組み込むための Node / perl のコードを両方置く。
何を「不可視文字」とみなすか 一括で消せばよい、とはならない。用途によって残すべきものが変わる からだ。分類しないと、必要な文字まで巻き添えになる。
上のツールが実際に持っている分類表がそのまま出発点になる。
1 2 3 4 5 6 7 8 9 10 11 12 13 var TARGETS = [ { code : 0x3000 , kind : 'ideographic-space' , fix : ' ' }, { code : 0x00A0 , kind : 'nbsp' , fix : ' ' }, { code : 0x200B , kind : 'zero-width' , fix : '' }, { code : 0x200C , kind : 'zero-width' , fix : '' }, { code : 0x200D , kind : 'zero-width' , fix : '' }, { code : 0x2060 , kind : 'zero-width' , fix : '' }, { code : 0xFEFF , kind : 'bom' , fix : '' }, { code : 0x00AD , kind : 'soft-hyphen' , fix : '' }, { code : 0x2028 , kind : 'line-separator' , fix : '\n' }, { code : 0x2029 , kind : 'line-separator' , fix : '\n' }, { code : 0x0009 , kind : 'tab' , fix : ' ' } ];
範囲で拾うものも別にある。
1 2 3 4 5 6 7 if (cp >= 0x2000 && cp <= 0x200A ) return { code : cp, kind : 'thin-space' , fix : ' ' };if (cp >= 0xE0100 && cp <= 0xE01EF ) return { code : cp, kind : 'variation-selector' , fix : '' };if (cp >= 0xFE00 && cp <= 0xFE0F ) return { code : cp, kind : 'variation-selector' , fix : '' };if (cp < 0x20 && cp !== 0x0A && cp !== 0x0D && cp !== 0x09 ) return { code : cp, kind : 'control' , fix : '' };if (cp === 0x7F ) return { code : cp, kind : 'control' , fix : '' };if (cp >= 0x202A && cp <= 0x202E ) return { code : cp, kind : 'bidi' , fix : '' };if (cp >= 0x2066 && cp <= 0x2069 ) return { code : cp, kind : 'bidi' , fix : '' };
この表の作りで、注意して読むべき判断が3つある。
改行(U+000A / U+000D)は検出対象に入っていない。 行の区切りとして必要だからで、これを消す実装にすると1行に潰れる。一方で行区切り(U+2028 / U+2029)は対象に入っている。多くの環境で改行として扱われないためで、置き換え先は削除ではなく \n になっている。
異体字セレクタ(U+FE00〜U+FE0F / U+E0100〜U+E01EF)は既定で消さない設計になっている。 直前の漢字の字形を切り替える文字なので、人名や地名を扱う列で消すと表記が変わる。「見えないから消す」でひとまとめにしてはいけない代表例である。
BOM(U+FEFF)は消してよいとは限らない。 UTF-8 のファイル先頭にある BOM は、Excel が文字コードを判定する手がかりとして使われることがある。一方 JSON やシェルスクリプトの先頭にあると解析に失敗する。渡す相手で決まる ので、ツール側も「消す/残す」を選ばせる形にしている。
位置は「コードポイント単位」で数える 見つけたあと、それが何行目の何桁目かを出す必要がある。ここで数え方を間違えると、報告した位置とエディタの表示がずれる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 function scan (text ) { var s = String (text == null ? '' : text); var found = [], line = 1 , col = 1 , idx = 0 ; for (var i = 0 ; i < s.length; ){ var cp = s.codePointAt(i); var w = cp > 0xFFFF ? 2 : 1 ; idx += 1 ; var t = targetOf(cp); if (t) found.push({ cp : cp, kind : t.kind, index : idx, line : line, col : col }); if (cp === 0x0A ){ line += 1 ; col = 1 ; } else { col += 1 ; } i += w; } return found; }
s.length は UTF-16 のコード単位 の数である。JavaScript の文字列は UTF-16 なので、絵文字や一部の漢字(サロゲートペアで表される文字)は 2 と数えられる。s[i] で1文字ずつ回すと、そこで位置がずれる。
上の実装は codePointAt で読み、cp > 0xFFFF なら 2 進める。進む量は UTF-16 の単位、数える量はコードポイントの単位 、と分けているのが要点である。for...of は同じことを言語側でやってくれるので、後述の Node 版ではそちらを使う。
実際に試す 1. その場で1件だけ見たいとき 貼り付けるだけなら不可視文字チェッカー が速い。見つかった文字が U+200B のようなバッジになって、周りの文脈と一緒に位置が見える。掃除する種別はチェックボックスで選べて、既定では見えないまま害になりやすいもの (ゼロ幅文字・BOM・ソフトハイフン・制御文字・書字方向の制御)だけが入っている。全角スペースと異体字セレクタは既定では外れている。処理はブラウザ内で完結するので、貼り付けた内容が送信されることはない。
2. ファイルを走査する(perl) macOS の /usr/bin/grep は -P(PCRE)に対応していない。
1 2 $ /usr/bin/grep -P '\x{200b}' config.json grep: invalid option -- P
Homebrew で GNU grep や ugrep を入れていれば通るが、環境に依存させたくない。**perl はどの環境にもほぼ入っていて、-CSD で入出力を UTF-8 として扱える**ので、こちらのほうが確実である。
1 2 3 perl -CSD -ne 'while (/([\x{200B}-\x{200D}\x{2060}\x{FEFF}\x{00AD}\x{00A0}\x{3000}\x{2028}\x{2029}\x{202A}-\x{202E}\x{2066}-\x{2069}])/g) { printf "%s:%d:%d: U+%04X\n", $ARGV, $., pos($_), ord($1); }' config.json
BOM とゼロ幅スペースと全角スペースが混ざった config.json に対する出力はこうなる。
1 2 3 4 config.json:1:1: U+FEFF config.json:2:13: U+200B config.json:3:1: U+3000 config.json:3:2: U+3000
$ARGV がファイル名、$. が行番号、pos($_) がマッチ直後の位置なので、1文字マッチなら桁がそのまま出る。file:line:col: の形なので、エディタやエラーパーサにそのまま食わせられる。複数ファイルなら引数を並べればよい。
1 perl -CSD -ne '...' $(git ls-files '*.json' '*.yml' '*.md' )
3. スクリプトに組み込む(Node.js) CI で落としたい、あるいは掃除まで自動でやりたいなら Node のほうが扱いやすい。Node.js 20 以降 があれば依存は要らない。上のツールと同じ分類表を最小構成で写したものを inv.js として置く。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 "use strict" ;const TARGETS = new Map ([ [0x3000 , ["ideographic-space" , " " ]], [0x00a0 , ["nbsp" , " " ]], [0x200b , ["zero-width" , "" ]], [0x200c , ["zero-width" , "" ]], [0x200d , ["zero-width" , "" ]], [0x2060 , ["zero-width" , "" ]], [0xfeff , ["bom" , "" ]], [0x00ad , ["soft-hyphen" , "" ]], [0x2028 , ["line-separator" , "\n" ]], [0x2029 , ["line-separator" , "\n" ]], [0x0009 , ["tab" , " " ]], ]);function targetOf (cp ) { if (TARGETS.has(cp)) return TARGETS.get(cp); if (cp >= 0x2000 && cp <= 0x200a ) return ["thin-space" , " " ]; if (cp >= 0xfe00 && cp <= 0xfe0f ) return ["variation-selector" , "" ]; if (cp >= 0xe0100 && cp <= 0xe01ef ) return ["variation-selector" , "" ]; if (cp < 0x20 && cp !== 0x0a && cp !== 0x0d && cp !== 0x09 ) return ["control" , "" ]; if (cp === 0x7f ) return ["control" , "" ]; if (cp >= 0x202a && cp <= 0x202e ) return ["bidi" , "" ]; if (cp >= 0x2066 && cp <= 0x2069 ) return ["bidi" , "" ]; return null ; }function scan (text ) { const found = []; let line = 1 , col = 1 ; for (const ch of String (text)) { const cp = ch.codePointAt(0 ); const t = targetOf(cp); if (t) found.push({ cp, kind : t[0 ], line, col }); if (cp === 0x0a ) { line++; col = 1 ; } else { col++; } } return found; }function clean (text, kinds ) { const allow = new Set (kinds); let out = "" ; for (const ch of String (text)) { const t = targetOf(ch.codePointAt(0 )); out += t && allow.has(t[0 ]) ? t[1 ] : ch; } return out; }const hex = (cp ) => "U+" + cp.toString(16 ).toUpperCase().padStart(4 , "0" );const pasted = "\uFEFF{\n \"name\": \"a\u200Bpi\",\n\u3000\u3000\"port\": 8080\n}" ;console .log("見つかった不可視文字:" );for (const f of scan(pasted)) console .log(` ${f.line} 行 ${f.col} 桁 ${hex(f.cp)} ${f.kind} ` );try { JSON .parse(pasted); } catch (e) { console .log("JSON.parse:" , e.message); }const fixed = clean(pasted, ["bom" , "zero-width" , "ideographic-space" , "control" ]);console .log("掃除後 parse:" , JSON .parse(fixed));console .log("掃除前後の長さ:" , pasted.length, "→" , fixed.length);console .log('"api" と一致するか:' , JSON .parse(fixed).name === "api" );
実行する。
1 2 3 4 5 6 7 8 9 10 見つかった不可視文字: 1行 1桁 U+FEFF bom 2行 13桁 U+200B zero-width 3行 1桁 U+3000 ideographic-space 3行 2桁 U+3000 ideographic-space JSON.parse: Unexpected token '', "{ "name"... is not valid JSON 掃除後 parse: { name: 'api', port: 8080 } 掃除前後の長さ: 37 → 35 "api" と一致するか: true
この出力の読みどころを3つ挙げる。
JSON.parse のエラーメッセージが役に立たない。 「Unexpected token ‘‘」の引用符の中には BOM が入っているのだが、表示すると何も見えない。エラー本文を見て原因が分かる形になっていない ので、パースが落ちたら文字列を先に走査するほうが早い。
長さが 37 → 35 で、減ったのは 2 文字だけである。 BOM とゼロ幅スペースが消えて 2 文字。全角スペース 2 つは半角スペースに置き換わっている ので長さが変わらない。「消す」と「置き換える」を種別ごとに分けているのはこのためで、字下げまで消すと今度は整形が崩れる。
bidi と variation-selector を掃除対象に入れていない。 上の例では ["bom", "zero-width", "ideographic-space", "control"] だけを渡している。書字方向の制御は本当に必要な文書がありうるし、異体字セレクタは前述のとおり字形の指定である。掃除する種別は明示して渡す 設計にしておくと、あとで「なぜ消えたのか」を追える。
4. CI で落とす scan() の結果が空でなければ終了コードを 1 にすればよい。上の inv.js から TARGETS / targetOf / scan / hex をそのまま持ってきて、末尾だけ次に差し替える(inv-ci.js として保存する)。
1 2 3 4 5 6 7 8 9 10 11 const files = process.argv.slice(2 );let bad = 0 ;for (const f of files) { for (const g of scan(require ("fs" ).readFileSync(f, "utf8" ))) { if (g.kind === "variation-selector" ) continue ; console .log(`${f} :${g.line} :${g.col} : ${hex(g.cp)} ${g.kind} ` ); bad++; } } process.exit(bad ? 1 : 0 );
1 node inv-ci.js $(git ls-files '*.json' '*.md' )
問題があればファイル名つきで並び、終了コードが 1 になる。
1 2 3 4 config.json:1:1: U+FEFF bom config.json:2:13: U+200B zero-width config.json:3:1: U+3000 ideographic-space config.json:3:2: U+3000 ideographic-space
ここで「全部の種別を落とす」設定にしないほうがよい。 タブや全角スペースは意図して入っている文書がある。落とす対象は、まずゼロ幅文字・BOM・制御文字 の3つから始めて、誤検知が出ないことを確かめてから広げるのが安全である。
まとめ
「画面上は同じなのに比較が通らない」「JSON パースが落ちる」の犯人は、ゼロ幅スペース・BOM・全角スペースであることが多い
一括で消してはいけない。 改行は必要、行区切りは \n に置換、異体字セレクタは字形の指定、BOM は渡す相手で判断が変わる
位置はコードポイント単位 で数える。s.length や s[i] は UTF-16 のコード単位なのでサロゲートペアでずれる。for...of を使う
コマンドラインでは grep -P を当てにしない(macOS の /usr/bin/grep は非対応)。**perl -CSD のほうが確実**で、file:line:col: 形式で出せる
CI に入れるなら、まずゼロ幅文字・BOM・制御文字 の3種別だけから始める
1件だけ確かめたいなら不可視文字チェッカー に貼るのが速い。処理はブラウザ内で完結する
本記事の分類表とロジックは、ハシトシステムの不可視文字チェッカー の実装に基づいている。出力例は Node.js 20.17.0 と macOS 標準の perl で実際に実行した結果である。