🧊

Rustで正規表現パーサーを実装していたら、なぜか文字列リテラルパーサーを実装していた

何を言っているかわからねーと思うが。

ことの発端

  • OXCというRustのプロジェクトで正規表現パーサーを書いた
  • test262のテストもPASSしてるし、問題なく動いてそう

oxc/crates/oxc_regular_expression at main · oxc-project/oxc https://github.com/oxc-project/oxc/tree/main/crates/oxc_regular_expression

Linterにも組み込まれ、それらしく動いてると思ってた矢先に起きた出来事。

どうやら、

  • 正規表現をリテラルではなくコンストラクタ経由で生成していて
  • "\\d"のようにエスケープシーケンスが使われてると
  • その位置が正しく報告されない

というもの。

たとえば、以下の正規表現は構文エラーになる。

/No: \d{2,1}/

{2,1}の量指定子の部分がNGで、数字が昇順になってる必要があるから。

このとき、エラー表示はこんな感じになる。

/No: \d{2,1}/
       ^^^^^

ちゃんと{}の範囲がエラーだよってなってて、これが予期する状態。

だが、コンストラクタを使っていると、

new RegExp("No: \\d{2,1}")
                  ^^^^^

なんか1つズレてる・・・。

なぜか

  • リテラルでの挙動に問題はない
  • リテラルと同等のエラーが検知されていることから、正規表現のパースロジック自体に問題はない
  • 単にエラー表示だけがズレてる
    • その表示に使ってる位置情報がズレてる

突き止めてみると原因は、\\dの部分の位置情報が、エスケープ目的の\をカウントに入れてなかったからだった。

OXCの場合、文字列リテラル中のエスケープシーケンスの処理自体は、本体のParserがやってくれてる。 なのでLinterとしては、単にパースされたASTからStringLiteralを見つけて、その値を正規表現パーサーに投げるだけ。

Parser本体は、

  • ソースコードから文字列リテラルを切り出すところまで先にやってる
  • この時、文字列リテラル中のエスケープシーケンスについても考慮してる
    • さもないと、StringLiteralより後に現れたノードの位置がズレていっちゃうから
  • 後工程のために、エスケープシーケンスが解消された後の文字列を抱える

で、正規表現パーサーを使うときには、このエスケープシーケンスを含まない文字列を利用してた。

なので、そもそもエスケープシーケンスを検知しようがないし、位置情報がズレてる認識もしてなかったのである。

どうする?

エスケープされた文字を含んだ文字列を扱う以上、正規表現パーサーとしても、エスケープシーケンスの存在を考慮するしかない。

ただ残念ながら、Parser本体もStringLiteral自体のstart|endは保持してるけど、それを構成する各文字のstart|endは保存してない。(普通のJavaScriptツーリングに必要ないもんね)

なので、正規表現パーサーとしても、

  • エスケープシーケンスが解消された文字列を受け取るのではなく
  • StringLiteralstart|endを使って、エスケープシーケンスを含んだ生の文字列スライスを受け取る
    • 全体で見ると二度手間になるが、改めてパースする

という結論に至った。

文字列リテラルパーサー

というわけで、書きましたよ!という話でした。

需要もないと思うので、crates.ioには出てないけど、コードはGitHubでは読めるようになってます。

oxc/crates/oxc_regular_expression/src/parser/reader/string_literal_parser at main · oxc-project/oxc https://github.com/oxc-project/oxc/tree/main/crates/oxc_regular_expression/src/parser/reader/string_literal_parser

仕様書はこちら。

12.9.4 String Literals | ECMAScript® 2024 Language Specification https://tc39.es/ecma262/2024/multipage/ecmascript-language-lexical-grammar.html#prod-StringLiteral

正規表現に比べたら単純なので、パーサーを書いてみたい人にもおすすめの教材だった気がする。

おわりに

もともと正規表現パーサーとしても、uvフラグが指定されていない場合、内部的に扱うコードポイントをUnicodeではなくUTF-16にする必要があって、そのときサロゲートペアの位置情報をどう保持するか?っていう悩みがあった。

今回の修正で、そのあたりの処理もまとめてパーサー本体から隔離できたので、結果的にはまあコードが綺麗になって棚ぼたって感じ。

Rustなのでパフォーマンスが良いみたいな話ばかりを最近よく聞くけど、ランタイムがJavaScriptだったならこんな手間は必要なかったのに!って話でした。

おまけ 1

世には<CR><LF>という、いわゆる改行を表すコードポイントが存在してるけど、実はそれ以外にも「CRLFが連続してたら1つ分相当とする」みたいなやつも。

LineTerminatorSequence ::
  <LF>
  <CR> [lookahead ≠ <LF>]
  <LS>
  <PS>
  <CR> <LF>

https://tc39.es/ecma262/2024/multipage/ecmascript-language-lexical-grammar.html#prod-LineTerminatorSequence

なんでお前だけコードポイント2つ使って許されてんの?ってなる。

Fossil: CRLF Is Obsolete And Should Be Abolished https://fossil-scm.org/home/ext/crlf-harmful.md

I think so too!

おまけ 2

お気づきの方もいるかもしれないが、RegExpコンストラクタに渡せるのは、厳密には文字列リテラルだけではない。

関数とかは流石に置いておいて、文字列まわりだとテンプレートリテラルっていう文字列リテラルによく似た構文を持つやつも渡せる。

new RegExp(`ab+c`)

もちろんテンプレートリテラル中に${variable}が現れると困るけど、そうではない完全にstaticなものなら、受け入れるようにすべきか?って少しだけ考えた。

ただ仕様書をみると細かいところが文字列リテラルのそれとは違ってて、中途半端に対応するのもな・・・ってことで諦めることにした。

12.9.6 Template Literal Lexical Components | ECMAScript® 2024 Language Specification https://tc39.es/ecma262/2024/multipage/ecmascript-language-lexical-grammar.html#sec-template-literal-lexical-components

ESLintも対応してないし。