1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

正規表現を構造ツリーで可視化するツールを 400 行で作った

1
Posted at

きっかけ

正規表現、書くのはなんとかなるけど、読むのがつらいじゃないですか。

/^(?:(?<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 Visualizerhttps://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 ゼロ依存の静的ツールです。

バグ報告・改善案・「この正規表現が描画されない」報告、歓迎です。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?