3行まとめ
- 検索・置換ルールを複数登録し、1回の操作でテキストへ順番に適用するブラウザ完結ツールを作った
- 通常モード(文字列一致)は正規表現を一切使わず
split(search).join(replace)で実装。.や(のような正規表現の特殊文字をエスケープする手間がまるごと消える - ルールは前の結果に次のルールが適用される順次適用方式。
A→Bの次にB→Cを登録すると、元のAは最終的にCになる。1ルールずつ独立に効くわけではない
原稿の表記ゆれ(「Webサイト」と「ウェブサイト」が混在している等)を直したい、CSVやログから複数の不要な記号をまとめて除去したい。1個の検索・置換ならエディタの検索・置換(Ctrl+H)で足りるが、直したい対象が複数ある日、毎回コピペを往復するのは煩わしい。
ぱんだツールズのテキスト複数パターン一括置換は、検索・置換のルールをまとめて登録し、1回のボタン操作でテキストに順番に適用するツール。処理はブラウザ内で完結し、テキストはサーバーに送られない。
この記事では、通常モードがなぜ正規表現を使わずに実装できているか、そしてルールの「順次適用」がどう設計されているかを実装ベースで書く。
通常モードは正規表現を使わない
複数パターンの一括置換と聞くと、内部では正規表現を使っているように思うかもしれない。だが通常モード(文字列一致)の実装は、正規表現を一切経由しない。
export function replaceMultiplePatterns(input: string, rules: ReplaceRule[]): string {
let result = input
for (const rule of rules) {
if (rule.search === '') continue
if (rule.isRegex) {
let regex: RegExp
try {
regex = new RegExp(rule.search, 'gi')
} catch {
throw new Error(`不正な正規表現です: ${rule.search}`)
}
result = result.replace(regex, rule.replace)
} else {
result = result.split(rule.search).join(rule.replace)
}
}
return result
}
通常モードの本体はこの1行だけ。
result = result.split(rule.search).join(rule.replace)
String.prototype.replace に文字列を渡した場合、実は最初の1件しか置換されない(全部置換するには replaceAll か g フラグ付き正規表現が要る)。かといって正規表現に持ち込むと、今度は検索文字列に . * ( ) のような正規表現の特殊文字が含まれていたときに意図と違うマッチをしてしまう。ユーザーが「. という1文字を置換したい」と入力しても、正規表現としては「任意の1文字」を意味してしまう。これを避けるには特殊文字のエスケープ処理が要る。
split().join() はこの問題を構造的に回避する。split(検索文字列) は検索文字列を**リテラル(そのままの文字の並び)**として区切り、join(置換文字列) でその区切り目に置換文字列を挟み直す。正規表現のコンパイルが発生しないので、. も ( も「ただの1文字」として扱われ、エスケープが一切不要になる。「全部置換したい・特殊文字もそのまま扱いたい」という通常モードの要件に対して、正規表現を使わないことがそのまま正解になっている設計だ。
正規表現モードは常に g と i を強制する
もう一方の正規表現モードは、素直に RegExp を組み立てて replace する。
regex = new RegExp(rule.search, 'gi')
フラグはユーザーに選ばせず、常に g(すべて置換)と i(大文字小文字を区別しない)を固定で付与している。g を外に出さないのは、「一括置換ツール」で一部だけ置換されると混乱の元になるため。i を強制しているのは、複数ルールを跨いだ一括処理という用途では大文字小文字の違いまで気にして個別制御したい場面が少なく、緩く一致させたほうが実用的という判断。この2つのフラグを固定にすることで、UIからは「正規表現かどうか」のチェックボックス1つだけで済み、フラグ選択のUIを増やさずに済んでいる。
不正な正規表現(閉じ括弧の対応が崩れているなど)が渡されたときは、その場で throw してエラーメッセージを出す。該当ルールだけをスキップして残りを続行する、という挙動はあえて選んでいない。複数ルールが絡む一括処理で一部だけ静かにスキップされると、「なぜこの箇所が変換されなかったのか」が使う側から見えづらくなる。エラーは早く・目立つ場所で止めたほうが、結果的にデバッグしやすい。
ルールは「前の結果」に次のルールが適用される
設計上いちばん誤解されやすいのが、ルールの適用順序。このツールは複数ルールを独立に適用するのではなく、前のルールの出力を、次のルールの入力として渡す。
let result = input
for (const rule of rules) {
// ...
result = result.split(rule.search).join(rule.replace) // 正規表現モードも同様に result を書き換え
}
return result
result という1つの変数を、ルールごとに上書きし続けているのがそのまま実装になっている。これが何を意味するかというと、1番目のルールで「A→B」、2番目のルールで「B→C」と設定した場合、元の文字列にあった「A」は最終的に「C」まで変換される。「A」は1番目のルールにしか一致しないから素通りする、という直感的な期待は外れる。
この仕様は罠にも武器にもなる。罠としては、意図せず連鎖してしまうケース——たとえば「①→②」「②→③」のように置換後の文字列が次のルールの検索対象と偶然一致すると、玉突きで想定より多く変換されてしまう。逆に武器として使うと、(\d+)-(\d+)-(\d+) を $3/$2/$1 に変換するような正規表現のキャプチャグループと組み合わせて、1ルール目で日付の区切りを統一し、2ルール目でその統一済みの区切りをさらに別の記号に変える、といった段階的な変換パイプラインが作れる。「複数の独立した置換」ではなく「小さな変換を順番に通すパイプライン」と捉えると、この設計の狙いがつかみやすい。
UIのFAQでもこの順序依存を明記していて、updateRule(id, patch) でルールを編集するたびに出力をクリアするのも、古い結果と新しい設定がズレて見えるのを防ぐための細かい配慮になっている。
まとめ
- 通常モード(文字列一致)は正規表現を経由せず
split(search).join(replace)で実装。特殊文字のエスケープが不要になり、「入力した文字列をそのまま探して全部置換する」が素直に書ける - 正規表現モードは
g・iフラグを常に固定して付与し、UIの選択肢を増やさずに「すべて置換・大文字小文字を区別しない」を既定動作にしている。不正な正規表現はその場でエラーにし、黙ってスキップしない - ルールは前のルールの出力に次のルールが適用される順次適用。独立した置換ではなく変換パイプラインとして働くので、キャプチャグループと組み合わせた段階的な変換にも使える
表記ゆれの一括統一や、ログ・CSVからの複数記号の除去にどうぞ。テキストはブラウザの外に出ない。
ぱんだツールズ では他にも正規表現テスター・テキスト差分比較・改行コード変換・テキスト行ソート/重複削除など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・テキストはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。