TSKaigi Sendaiの下ごしらえとして、ここ2週間ほど、oxcのTypeScript周りのパーサー実装を読んでいました。
読んだ量のわりに人に説明できる形になっていないので、何回かに分けてブログにしていこうと思います。この記事はその0回目で、何を読んだのか・どういう順番で書くのかの見取り図です。
何を読んでいたのか
oxcはRust製のJavaScript/TypeScriptツールチェーンです。リンター(oxlint)やフォーマッター(oxfmt)が有名ですが、その土台に手書きの再帰下降パーサーがあります。
読んでいたのは、そのパーサーの中でもTypeScriptが関わる部分です。ざっくり3つの世界に分かれています。
ts/types.rs— 型の文法。string | numberとかT extends U ? A : Bとか。式とは完全に別の再帰下降パーサーが丸ごと1個入っているts/statement.rs— TS固有の文。enum/interface/type/namespace/declarejs/expression.rsほか — JS側への食い込み。as/satisfies/!/<T>expr/foo<T>()が、式パーサーの中に埋まっている
面白いのは3つ目です。TypeScriptの構文は独立した島になっているわけではなくて、JSの式を読んでいる途中から生えています。そこが曖昧性と戦っている場所です。
Write a JavaScript Parser in Rustをまず読むこと
実はoxcにはWrite a JavaScript Parser in Rustという公式のガイドがあります(元は独立したリポジトリで、今はoxcのサイトに取り込まれています。旧リポジトリには日本語訳も入っています)。
ECMAScript仕様の読み方から始まって、レキサー、パーサー、AST、エラー処理、セマンティック解析まで。しかもRust側の作り込み、ASTをアリーナに載せる話やトークンを小さくする話、文字列インターンの話まで書いてあります。パーサーを書いた人が、書きながら考えていたことをそのまま置いていってくれている感じです。
ちなみに文法の章にはMozillaのjsparagus(LALRパーサージェネレーターでJSをパースしようとしたプロジェクト)の話が引用されていて、その締めがこれです。
What have we learned today?
Do not write a JS parser.
書くなという。JSの文法はそれくらい厄介だ、ということなのですが、oxcはそれを承知のうえで書き切って、さらにその過程をガイドとして公開しているわけです。本当にすごい。
入口は文と式、道具は3つ
実際に読んでみると、身構えていたより道具は少なかったです。TypeScriptの構文がJSのパーサーに食い込むのは文の側と式の側の2系統だし、曖昧性と戦う道具も3つしか出てきませんでした。
入口は文と式 — 文の側は
at_start_of_ts_declarationの1か所。式の側はparse_member_expression_restの<や!、as/satisfiesを読む二項演算のループ、<T>exprの型アサーションと何か所かある道具は3つ — 先読み(lookahead)、投機パース(checkpoint して読んでみて、だめなら rewind)、re-lex(レキサーにトークンを読み直させる)
ちなみに先読みと投機パースは、TypeScriptのために用意された道具ではありません。定義はパーサー共通の cursor.rs にあって、素のJSの曖昧性にも普通に使われています。たとえば (a, b) まで読んだ時点では、括弧でくくった式なのかアロー関数の引数なのかが決まりません。なので => が来るかどうかを先まで見に行って、確かめたら戻ってきます。for (using x of y) と for (using of arr) の見分けも同じやり方です。TypeScriptの部分は、JSのために元からあった道具に乗っかっている、というほうが実態に近いです。
コードの中にこの目印を見つけたら「あ、ここは曖昧なんだな」と思って読む。これだけ知っていればだいぶ追えるんじゃないかと思います。
なので各記事は、公式ガイドを読んでいなくても最後まで読める形にするつもりです。
読んでいて面白かったこと
連載の中身は、だいたいこういう話をする予定です。
TypeScriptで a < b > c; と書くと何になるのか。 答えは (a < b) > c です。ただの比較演算が2回。一方で f<T>(x); は型引数つきの関数呼び出しになります。この2つを見分けるために、パーサーはレキサーに「さっきの < をもう一回読み直して」と頼んでいます。<< を2つの < に割るような処理まであります。
型アサーションの as は、思ったより優先順位が低い。 tscは 1 + 1 as number / 2 を ((1 + 1) as number) / 2 と読みます。つまり as number を空白に置き換えるだけの素朴な型除去にかけると 1 + 1 / 2 になって、答えが 1 から 1.5 に変わってしまう。この問題が今年の6月に修正されたのですが、issueでTSチームの開発リードのRyan Cavanaughがこう言っていました。
We shipped what precedence??
我々はいったいどんな優先順位で出荷してしまったんだ、という意味です。作った当人が自分たちの仕様に驚いている。しかもoxcはts-goのマージから16時間で追従していました。
re-lexを実装している lexer/typescript.rs は、53行中40行がコメント。 「良いコードにコメントは不要」という話をたまに聞きますが、このファイルを見るとそうとも言えないなと思います。ファイルをまたいだ不変条件を守るための契約が、コードでは表現できないので散文で書いてある、という形をしています。
oxcのパーサーは type C<T> = keyof infer U; を素通りします。 tscだとエラーになるのに、です。バグかというとそうではなくて、この検査は誰の担当か、という話でした。oxcではセマンティック解析(oxc_semantic)が担当していて、そこまで通すとちゃんと TS1338 が出ます。判定に型の解決まで要るものは、さらに本家(Go版のtsc)へ外注する。パーサー / セマンティック解析 / 型チェッカーで、仕事がきれいに分かれています。
連載の予定
前半4本が「全体の流れと地図」、後半4本が独立したトリビア集、という構成を考えています。後半はどこから読んでもいいやつです。
| 回 | タイトル | 中身 |
|---|---|---|
| 1 | 型はどう読まれるか | ユニオン型を宣言するだけの型を実際に追う。優先順位の表はどこにもなく、どの関数がどの関数を呼ぶかがそのまま優先順位になっている |
| 2 | TypeScriptはどこに住んでいるか | リポジトリとファイルの地図。TS専用のパーサーというものは存在しないという話 |
| 3 | 型引数つき呼び出しがASTになるまで | f<T>(x); と a < b > c; という字面のそっくりな2行を、先頭から通しで追う。ここで先読み・投機・re-lexが全部出てくる |
| 4 | そのASTは誰に合わせているのか | TypeScript AST / TSESTree / oxc の3者の関係。同じ null 型でも木の形が違う話 |
| 5 | 1 + 1 as number / 2 事件 | as の優先順位と、7.0での意図的なbreaking change |
| 6 | そのエラー、誰が出してるの? | パーサー / セマンティック解析 / 型チェッカーの住み分け |
| 7 | tscの移植の、引き算と足し算 | oxcが捨てたものと、tscには無い近道 |
| 8 | 積み残した型の難所たち | mapped type / tuple type / template literal type / 型述語、そしてアロー関数の曖昧性(カバー文法との対比)を雑多にまとめる回 |
例によって予定は未定なので、順番が入れ替わったり途中で力尽きたりするかもしれません。
書いている途中で気づいたことも多いので、実際に読みながら確認したコードはこのリポジトリの demos/ に置いてあります。ASTのダンプや、パーサーがどの関数をどの順で呼んだかのトレースが入っています。読んだのは rev 1aa5ec11ce 時点のコードなので、この連載に出てくる行数や行番号はそのうちズレます。
まとめ
oxcのTypeScriptパーサーを読んでいました
公式ガイドの「Write a JavaScript Parser in Rust」がよくできているので、興味が出たらまずそこから
この連載は、そこから先で実際に出会ったものを並べていきます
入口は文と式、道具は3つ。これだけ知っていればだいぶ追える
次回から中身に入ります。まずは型の文法から。