きっかけ
正規表現、書くのはなんとかなるけど、読むのがつらいじゃないですか。
/^(?:(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2}))T(?:(?<hour>\d{2}):(?<min>\d{2})(?::(?<sec>\d{2}))?)(?:Z|[+-]\d{2}:\d{2})$/
人の書いたやつを見た瞬間、頭の中で脳内パースが始まって時間が溶けます。regex101 は実行確認には便利ですが「構造が知りたい」ニーズには合わない。regexper.com は美しいけれど少し古くなってきている。
というわけで作りました。
作ったもの
Regex Visualizer — https://sen.ltd/portfolio/regex-viz/
正規表現を入れると、構造を色分けしたネストされたツリーで可視化します。同時にテスト文字列のマッチ範囲もハイライト。
全部 vanilla JS + HTML + CSS、ビルドツールなし、ゼロ依存。本体は約 400 行。テスト 51 ケース込みで 600 行ちょっと。
構成
古典的な 3 層構成にしました。
src/
├── parser.js # 正規表現文字列 → AST
├── renderer.js # AST → HTML
└── matcher.js # 組み込み RegExp で実行、マッチ範囲を返す
パースだけ自前、実行は組み込みの RegExp に任せる、というのが手抜き…いや、賢い手抜きのポイント。実行エンジンまで自作すると膨大になるし、ブラウザの実装より速くも正確にもなりません。必要なのは「構造を知る」ための AST だけ。
パーサー(再帰下降で素直に書く)
JavaScript の正規表現の文法はそれなりに規則的なので、再帰下降で書けます。文法の骨格はこう:
regex = alternation
alternation = sequence ('|' sequence)*
sequence = quantified*
quantified = atom quantifier?
quantifier = '?' | '*' | '+' | '{n[,m]}' ('?')?
atom = literal | charClass | group | anchor | predefined | escape
group = '(' ('?:' | '?<name>')? alternation ')'
charClass = '[' '^'? char* ']'
実装はこんな感じ:
function parseAlternation(state) {
const branches = [parseSequence(state)]
while (peek(state) === '|') {
state.pos++
branches.push(parseSequence(state))
}
if (branches.length === 1) return branches[0]
return { type: 'alternation', branches }
}
function parseSequence(state) {
const children = []
while (state.pos < state.input.length && peek(state) !== '|' && peek(state) !== ')') {
children.push(parseQuantified(state))
}
if (children.length === 1) return children[0]
return { type: 'sequence', children }
}
function parseQuantified(state) {
const atom = parseAtom(state)
const q = parseQuantifier(state)
if (q) return { type: 'quantifier', ...q, child: atom }
return atom
}
ポイントは 「branches が 1 つなら wrap しない」。a|b は alternation になるけど、ab は sequence、a はそのまま literal のままにする。これで AST が最小限になって、renderer 側の実装も減ります。
レンダラー(HTML + CSS で済ませる)
SVG で書こうか迷ったんですが、レイアウト計算を自分でやるのがしんどいので HTML + CSS にしました。各ノードを <div> として出力し、sequence は flex 横並び、alternation は縦並び、group は border で囲む、など CSS に任せる。
case 'sequence': {
const inner = node.children.map(renderNode).join('')
return `<div class="rv-node rv-sequence">
<div class="rv-sequence-row">${inner}</div>
</div>`
}
case 'alternation': {
const parts = []
for (let i = 0; i < node.branches.length; i++) {
if (i > 0) parts.push('<div class="rv-or-label">OR</div>')
parts.push(renderNode(node.branches[i]))
}
return `<div class="rv-node rv-alternation">
<div class="rv-alternation-col">${parts.join('')}</div>
</div>`
}
CSS 側で sequence-row は display: flex; flex-direction: row、alternation-col は column、と書くだけ。ネストしても崩れません。
色分けはノードの種類ごとに border-left の色を変えただけ:
.rv-literal { border-left: 3px solid var(--literal-color); }
.rv-predefined { border-left: 3px solid var(--predefined-color); color: var(--predefined-color); }
.rv-charClass { border-left: 3px solid var(--charclass-color); color: var(--charclass-color); }
.rv-anchor { border-left: 3px solid var(--anchor-color); color: var(--anchor-color); }
モダンなブラウザだと CSS 変数で簡単にテーマ色を持てるので、コード側は色を意識しなくて済みます。
マッチング(組み込み RegExp に任せる)
ツリー描画だけ自前で、マッチングはブラウザ組み込みの RegExp に投げます。
export function matchAll(pattern, flags, testString) {
const effectiveFlags = flags.includes('g') ? flags : flags + 'g'
let re
try {
re = new RegExp(pattern, effectiveFlags)
} catch (e) {
return { ok: false, error: e.message }
}
const results = []
for (const m of testString.matchAll(re)) {
const start = m.index
const text = m[0]
if (text.length === 0) continue // zero-width match: skip
results.push({ start, end: start + text.length, text })
}
return { ok: true, matches: results }
}
matchAll を呼ぶときは g フラグが必要です。ユーザーが指定してなくても強制で追加するのが安全。また zero-width match (^, $, (?=...) など) は結果配列に含まれると無限ループの危険があるのでスキップしています。
読めなかった正規表現が、読めるようになる
冒頭の例を Regex Visualizer に貼ると、?<year>, ?<month>, ?<day> といった名前付きキャプチャグループが色付きでネスト表示されるので、「年月日を取って T で区切って時分秒を取って末尾にタイムゾーン」という構造が一目でわかります。
書いた人の頭の中の絵 が、ブラウザ上で再現される感覚。これが欲しかった。
テスト
node --test で 51 ケース。全部ゼロ依存。
npm test
パーサーのユニットテストが主で、各構文要素について 1 〜 3 ケースずつ書いています。エラーケース(未終了のグループ、文字クラス、trailing backslash)も必ず書きました。
おわりに
SEN 合同会社の ポートフォリオシリーズ 100+ の 3 件目です。1 件目 Cron TZ Viewer、2 件目 JSON to TypeScript と同じ方針で、vanilla JS ゼロ依存の静的ツールです。
- 📦 レポジトリ: https://github.com/sen-ltd/regex-viz
- 🌐 ライブデモ: https://sen.ltd/portfolio/regex-viz/
- 🏢 会社: https://sen.ltd/
バグ報告・改善案・「この正規表現が描画されない」報告、歓迎です。
