AI支援の開示: この記事はAIの支援を受けて作成されました。すべてのJavaScriptの例は、付属のNode.js検証スクリプトで実行されました。人間による編集レビューは使用されていません。

正規表現は単独では正しくても、アプリケーション内で失敗することがあります。失敗の原因は、フラグ、Unicode表現、式を消費するAPI、または共有された RegExp オブジェクトの可変状態によるものです。

そのため、「別のパターンを試す」というのは、デバッグ戦略として不適切なことがよくあります。より良いアプローチは、バグを小さなテストマトリックスに変換することです。

このマトリックスには、4つの有用な軸があります:

テストする質問
入力 空白、正規化、非表示のコードポイントを含む、正確に失敗した文字列が存在するか?
パターンとフラグ ugims、または y で何が変わるか?
コンシューマーAPI testexecmatchAll、または replace を呼び出しているか?
期待される結果と状態 どのマッチ、グループ、置換テキスト、および lastIndex の値が得られるべきか?

これにより、「ある場所では正規表現が動作するが、別の場所では動作しない」といった逸話が、以下のような再現可能な記述に変わります:

この正確な入力では、/^.$/ は失敗し、/^.$/u は成功し、入力には2つのUTF-16コード単位で表される1つのUnicodeコードポイントが含まれている。

実行可能なマトリックスから始める

最小限に有用なハーネスは、データとアサーションだけで構成されます:

import assert from "node:assert/strict";

const cases = [
  {
    name: "絵文字にはUnicodeコードポイントモードが必要",
    run: () => /^.$/u.test("💡"),
    expected: true,
  },
  {
    name: "分解された文字には2つのコードポイントが含まれる",
    run: () => /^.$/u.test("e\u0301"),
    expected: false,
  },
  {
    name: "NFC正規化はその文字を合成する",
    run: () => /^.$/u.test("e\u0301".normalize("NFC")),
    expected: true,
  },
];

for (const testCase of cases) {
  assert.deepEqual(testCase.run(), testCase.expected, testCase.name);
}

Enter fullscreen mode Exit fullscreen mode

元の失敗した入力をマトリックスに保持します。後で簡略化した例を追加することはできますが、バグを再現できなくなるような整えられた文字列に証拠を置き換えないでください。

入力に疑いがある場合は、コード単位とコードポイントの両方を検査します:

const input = "💡";

console.log(input.length);            // 2 UTF-16 code units
console.log(Array.from(input).length); // 1 Unicode code point

Enter fullscreen mode Exit fullscreen mode

この2つの長さが、u フラグなしのドットが「1つの可視文字」を意味しない理由を説明します。

Unicodeは u フラグを追加するだけではない

u フラグはパターンの解析を変更し、. などの構成要素を個々のUTF-16サロゲート半分ではなくUnicodeコードポイントに対して動作させます:

console.log(/^.$/.test("💡"));  // false
console.log(/^.$/u.test("💡")); // true

Enter fullscreen mode Exit fullscreen mode

また、Unicodeプロパティエスケープも有効になり、手書きの長い範囲よりも明確になることがよくあります:

const lettersOnly = /^\p{Letter}+$/u;

console.log(lettersOnly.test("東京Cafe")); // true
console.log(lettersOnly.test("東京2"));    // false

Enter fullscreen mode Exit fullscreen mode

しかし、u はテキストを正規化しません。合成済みの文字列 "é" と分解された文字列 "e\u0301" は外見は似ていますが、異なるコードポイントのシーケンスを含んでいます。正規化がアプリケーションの契約の一部であるかどうかを決定し、その決定を明示的にテストしてください:

const oneCodePoint = /^.$/u;

console.log(oneCodePoint.test("é"));                  // true
console.log(oneCodePoint.test("e\u0301"));            // false
console.log(oneCodePoint.test("e\u0301".normalize("NFC"))); // true

Enter fullscreen mode Exit fullscreen mode

仕様で許可されていない限り、識別子、パスワード、署名、またはその他のプロトコルデータに対して暗黙的に正規化を行わないでください。正規化はデータに関する決定であり、普遍的な正規表現の修復ではありません。

完全一致だけでなくキャプチャの形状をテストする

完全一致が成功しても、下流のキャプチャ処理が正しいことを証明するものではありません。オプションのキャプチャは undefined になることがあり、繰り返しグループ内のキャプチャは最後にキャプチャされた値のみを保持します。

const optional = /(?<word>[A-Za-z]+)(?:-(?<suffix>\d+))?/;
const match = optional.exec("alpha");

console.log(match.groups.word);   // "alpha"
console.log(match.groups.suffix); // undefined

const repeated = /(ab)+/.exec("abab");
console.log(repeated[0]); // "abab"
console.log(repeated[1]); // "ab", not an array of both repetitions

Enter fullscreen mode Exit fullscreen mode

意味のあるキャプチャごとにアサーションを追加します。アプリケーションコードが値の存在を期待する場合は、欠如したブランチもテストしてください。

名前付きグループは、アサーションが位置ではなくドメインを記述するため、マトリックスを読みやすくします:

assert.equal(match.groups.word, "alpha");
assert.equal(match.groups.suffix, undefined);

Enter fullscreen mode Exit fullscreen mode

置換は別個の動作である

マッチングと置換は、マトリックス内で別々の行であるべきです。パターンが正しいスパンを見つけたとしても、置換文字列が誤ったグループを参照したり、オプションのグループを誤って処理したりする可能性があります。

名前付きキャプチャは、置換テンプレートに有用な自己文書化機能を提供します:

const person = /(?<first>\p{Letter}+)\s+(?<last>\p{Letter}+)/gu;
const input = "Ada Lovelace; Grace Hopper";
const output = input.replace(person, "$<last>, $<first>");

console.log(output);
// Lovelace, Ada; Hopper, Grace

Enter fullscreen mode Exit fullscreen mode

置換に条件が必要な場合は、関数を使用し、正規表現だけでなく最終的な文字列をアサートします:

const version = /v(?<major>\d+)(?:\.(?<minor>\d+))?/g;

const output = "v2 v3.7".replace(version, (...args) => {
  const groups = args.at(-1);
  return groups.minor === undefined
    ? `${groups.major}.0`
    : `${groups.major}.${groups.minor}`;
});

console.log(output); // 2.0 3.7

Enter fullscreen mode Exit fullscreen mode

コールバックの引数リストは扱いにくいため、本番コードに登場する場合は名前付き関数の背後にその動作を分離してください。

グローバル正規表現を可変オブジェクトとして扱う

g および y フラグは RegExp をステートフルにします。test および exec の呼び出しは lastIndex を読み書きします。

const shared = /\d+/g;
const input = "a1 b2";

console.log(shared.test(input), shared.lastIndex); // true, 2
console.log(shared.test(input), shared.lastIndex); // true, 5
console.log(shared.test(input), shared.lastIndex); // false, 0

Enter fullscreen mode Exit fullscreen mode

これにより、モジュールレベルの正規表現がリクエストやバリデーション呼び出し間で再利用されると、テスト結果が交互になることがよくあります:

const hasNumber = /\d/g;

hasNumber.test("item 7"); // true
hasNumber.test("item 7"); // false: the same object starts from its old lastIndex

Enter fullscreen mode Exit fullscreen mode

信頼できる3つの修正方法は次のとおりです:

  1. g を削除して、操作をyes/noテストにする。
  2. 独立したスキャンごとに新しい RegExp を構築する。
  3. lastIndex を意図的にリセットし、状態遷移をアサートする。

すべてのマッチを収集するには、matchAll を使用するのが通常は理解しやすいです。これは複製された正規表現の状態を使用して反復し、元のオブジェクトの lastIndex を変更しません:

const digits = /\d+/g;
const values = [..."a1 b22".matchAll(digits)].map((match) => match[0]);

console.log(values);           // ["1", "22"]
console.log(digits.lastIndex); // 0

Enter fullscreen mode Exit fullscreen mode

もう1つテストする価値がある状態境界は、長さゼロのマッチです。手動の exec ループは、式が入力を消費せずに成功すると停止する可能性があります。空のマッチの後に自分で lastIndex を進めるか、そのケースのために反復動作がすでに定義されているAPIを使用します:

const positions = [..."ab".matchAll(/(?=\w)/g)].map((match) => match.index);
console.log(positions); // [0, 1]

Enter fullscreen mode Exit fullscreen mode

APIを意図的に比較する

JavaScriptは、異なる質問に答える複数の正規表現コンシューマーを公開しています:

API 有用な結果 状態に関する懸念
re.test(input) ブール値の存在チェック g または ylastIndex を変更する
re.exec(input) キャプチャとインデックスを含む1つのマッチ g または ylastIndex を変更する
input.matchAll(re) すべての詳細なマッチの反復可能オブジェクト g が必要。元の正規表現を変更しない
input.replace(re, value) 変換された文字列 置換のセマンティクスには独自のアサーションが必要

2つの環境が一致しない場合は、まず同じ入力、フラグ、APIを使用していることを確認してください。ある場所で test を使用し、別の場所で matchAll を使用することは、制御された実験ではありません。

マトリックス構築中のブラウザローカルのスクラッチパッドとして、RegexRegex JavaScriptガイドとライブチェッカー は、マッチ、キャプチャグループ、置換プレビュー、エラーをまとめて表示します。これは任意です。この記事のすべての例は、Node.jsだけで再現できます。

実用的なデバッグシーケンス

以下は、正規表現のバグに対して私が使用するワークフローです:

  1. 正確な入力をキャプチャする。 エスケープ、改行、正規化、および周囲の文字を保持する。
  2. 正確な構築を記録する。 リテラルと new RegExp(string) には異なるエスケープレイヤーがあります。
  3. コンシューマーAPIを固定する。 本番コードが testexecmatchAll、または replace を呼び出しているかどうかを記録する。
  4. 最小限に関連するフラグマトリックスを構築する。 一度に1つのフラグを変更し、その行が存在する理由を述べる。
  5. 完全一致とキャプチャをアサートする。 オプションおよび繰り返しグループのブランチを含める。
  6. 置換出力を別個にアサートする。 正しいマッチでも誤ったテキストを生成する可能性があります。
  7. g または y に対して lastIndex をアサートする。 同じオブジェクトを複数回実行する。
  8. Unicodeの敵対例を追加する。 ドメインで許可される場合は、星座文字、結合マーク、非ASCII文字を含める。
  9. 再現後に最小化する。 元のケースを永続的な回帰テストとして保持する。
  10. サポートされているランタイムで実行する。 エンジンの機能とUnicodeデータは、デプロイされたブラウザとNodeのバージョン間で異なる可能性があります。

目標は、すべての理論的な文字列をテストすることではありません。各仮定を可視化することです。入力表現、フラグ、APIセマンティクス、キャプチャ、置換出力、および可変状態が別々の行になると、ほとんどの「不可解な」正規表現の失敗は、通常の失敗したアサーションになります。

マトリックスを実行可能に保つ

アプリケーションが依存するケースを node:assert/strict を使用した小さな regex-matrix.mjs ファイルに配置し、最初の期待値の失敗時にプロセスを非ゼロで終了させるようにします。次のように実行します:

node regex-matrix.mjs

Enter fullscreen mode Exit fullscreen mode

期待される結果をコードと一緒に保存して、後の正規表現の編集が実行可能な期待値を変更せずに静かに動作を変更できないようにします。