🧊

JSで書かれたECMAScript RegExpパーサーの比較

OXCで正規表現パーサーを実装してるときに全部一通り読んでみて、みんな違ってみんな良いってなったので。

候補はこちらの3つ。

かのAST Explorerでも、RegExp部門ではこの3つがリストにある。

AST explorer https://astexplorer.net/

前提

ECMAScript本体だと、ESTreeというデファクトがあるけど、残念ながらRegExpにはない。

ESTreeでのRegExpの扱いはこんな感じで、ただの文字列でしかない。

interface RegExpLiteral <: Literal {
  regex: {
    pattern: string;
    flags: string;
  };
}

https://github.com/estree/estree/blob/master/es5.md#regexpliteral

そういうわけで、この正規表現パターンの中身を、ASTで扱いたいモチベーションがあった人々が、それぞれパーサーを書いたのであろう。

というわけで、npmへの公開が早かった順にご紹介。

regjsparser

jviereck/regjsparser: Parsing the JavaScript’s RegExp in JavaScript. https://github.com/jviereck/regjsparser/tree/gh-pages

先鋒はregjsparserで、0.0.1が公開されたのはなんと2013年!にも関わらず、今もメンテが続いている様子。

  • ES2024のvフラグ
  • Stage 3のModifiers

といった最新の仕様にも対応してる。

特徴は、なんといっても1ファイルでコードが完結してるところ。

https://github.com/jviereck/regjsparser/blob/38b304d6c38d79d80a0f2417c93a4db1a18e0ab1/parser.js

トータル1654行しかないし、コメントとユーティリティ的なコードを除けば、ほんと1000行ちょっとしかない。サイズも他の実装の1/4くらいしかない。

アーキテクチャも素直で、ほとんどがparseXxx(): Xxxという感じのシグネチャになっており、とても読みやすい。

ただ効率重視な実装になってる場所もあって、意味的には異なる複数のパターンを、1つの関数でまとめて処理してたりすることもある。 (たとえば https://github.com/jviereck/regjsparser/blob/38b304d6c38d79d80a0f2417c93a4db1a18e0ab1/parser.js#L929

基本はUTF-16のコードユニットで処理しつつも、uフラグ時は、サロゲートペアを2つくっつけて単一文字として扱ってるところが印象的だった。

https://github.com/jviereck/regjsparser/blob/38b304d6c38d79d80a0f2417c93a4db1a18e0ab1/parser.js#L313

循環参照もないシンプルな浅めのASTを出力するので、簡単にJSON化したりできるのも扱いやすいところか。

コスパのいい実装だとは思いつつ、不満も書いておくと・・・。

TypeScriptではなく、一緒に置いてある.d.tsファイルが微妙にズレてたり足りてない定義があったりするところは気になる。

あとは、バリデーション系の実装も結構抜けてる印象で、たとえば以下がエラーにならない。

  • /\p{Invalid}/v: 不正なユニコードプロパティ
  • /\k<invalid>/u: 存在しない名前付きキャプチャグループ名
  • /(?<dup>)(?<dup>)/: グループ名の重複

パース処理自体にもJSのRegExpを多用してるので、実行パフォーマンスもあまり良くないとは思う。

コードが少ない分、機能も少なめといった感じか。

regexp-tree

DmitrySoshnikov/regexp-tree: Regular expressions processor in JavaScript https://github.com/DmitrySoshnikov/regexp-tree

続いては2017年が初出であるregexp-treeを。

このパーサーの特徴は、なんといってもSpecテキストであるBNF(風のテキスト)を元に、CLIでコードを生成してるところ。

https://github.com/DmitrySoshnikov/regexp-tree/blob/c0e5e4b0c3618f1923a8aa0b29abd045be0ec757/src/parser/regexp.bnf

実際にパーサーを書いてて、Specから生成できんもんかね〜とは思ってたけど、やってる実例があったのは驚いた。(なおコードは読み解くことはできそうにない)

DmitrySoshnikov/syntax: Syntactic analysis toolkit, language-agnostic parser generator. https://github.com/DmitrySoshnikov/syntax

しかし手動でコードを書いてる部分もやはりあるようで、そう全部うまくはいかんのやなーという感想。

不満としては、

  • vフラグがエラーになる
  • dフラグもなぜかエラーになる(パーサーとしての出力には影響ないけど)
  • uフラグがあっても、サロゲートペアは展開されたまま

というあたりか。

特にvフラグがエラーになるのは痛い。かと思えば、まだStage 1のxフラグは実装されてたりして、さらに謎が深まる。

出力されるASTもverboseというか、画一的と言うか、なんというか特徴的な感じ。

そんな感じではあるものの、パーサーの他にもコンポーネントがいろいろあって全方位的なおかげか、この3つの中では一番スターが多い。

regexpp

eslint-community/regexpp: The regular expression parser for ECMAScript. https://github.com/eslint-community/regexpp

最後は、元はmysticatea氏の個人プロジェクトだったが、最近はESLintコミュニティで管理されてる@eslint-community/regexppを。 最後発であり、npmに出たのは2022年のことらしい。(個人プロジェクト時は2018年から)

ESLintのコアルールでも使われてるだけあってか、もっとも重厚な仕上がりだと感じた。コードも全部TSで書かれてて、行数もこの中では一番多い。

コードの流れは、Specのパターン通りになっており、素直で読みやすい。

けど、アーキテクチャとしてはJavaScriptらしく(?)ダイナミックな処理が多用されており、コードを追うのは大変だった。 (たとえば https://github.com/eslint-community/regexpp/blob/608145afd010502ef9b0e1c0002a46889a11cb0f/src/parser.ts#L586 ) (他にもparseXxx(): booleanな関数を呼ぶと、this._xxxが更新されるとかそういう)

ただ自分が知る限り、ES2025までのSpecを全部実装していて、流石の貫禄!って感じ。

uフラグとそうでないときで、Readerクラスの実装を使い分けてる。

https://github.com/eslint-community/regexpp/blob/608145afd010502ef9b0e1c0002a46889a11cb0f/src/reader.ts

出力されるASTも割と重厚な印象なのと、パーサーと言いつつbackreferenceとCapturingグループの紐づけまでやってくれるので、循環参照が発生してて愚直にJSON.stringify()できないところは気になった。(あくまでJSで書かれたツールで利用するなら問題ではないけど)

Annex Bで定義されてる内容を、あえて許可しないというstrictモードを使い分けられるようになってるのも特徴かも。ただ別にコードが軽くなるわけではないし、いつ使いたくなるのかは知らんけど。

まとめ

(もともとのSpecがあるだけJSDocよりはマシではあるけど、)正規表現をASTで扱いたい人がそんなにいないからか、機能やらASTのデザインやらいろんな線引が難しい分野やなと思った。

そんな3者3様をサクッと眺められるツールも用意したので、正規表現パーサーを実装するときのお供にどうぞ。

JS RegExp AST Viewer https://leaysgur.github.io/js-regexp-ast-viewer/

ちなみに、OXCでの正規表現パーサーの実装は、regjsparserregexppを足して2で割った感じをイメージして実装してる。