🌀

swc に Stage 4 のプロポーザル class static blocks を実装した

2021/11/01に公開

Rust で書かれた swc という JavaScript のトランスパイラがあります。最近は Next.js 12 からデフォルトで Babel の代わりに使われることになったので日本でも話題にあがることが多くなったように思います。

https://github.com/swc-project/swc

ちょっと前から、暇なときに、Rust への入門のために swc に貢献しています。この間、現在 Stage 4 の static blocks という提案を swc に実装したので、そのときの話を書きます。

ただ数ヶ月前の話なので、正直詳細まで覚えていません。なのでこの記事は雑なメモだと思ってください。とは言っても、実際に swc をいじろうとするときのとっかかりにはなるんじゃないかと思います。

Issue を立てる

まず Issue を立てます。

https://github.com/swc-project/swc/issues/2125

そして「自分がやります」と表明しておきます。

AST を更新する

static blocks は、複雑な構文ではありませんが、構文の変更を伴うので、それ用に AST の定義を更新する必要があります。

estree での static blocks の AST の定義を参考に定義しました。

swc での AST 定義は、当然ですが struct と enum でいい感じになっていて、こんな感じのやつを新しく定義してやります。

pub struct StaticBlock {
    pub span: Span,
    pub body: Vec<Stmt>,
}

Span というのはそのノードの位置情報を表すやつです。Vec<Stmt>Stmt は任意のステートメントのことです。

これは僕が適当に決めちゃったのですが、後から変更されたようです。

Visitor を更新する

新しいノードを追加したら、Visitor を更新する必要があるようです。

書いたので結構前なのでもう忘れましたが、「このノードはこういう風にたどってね〜」というのを指示しておく必要があったのでしょう。

パーサーを書く

static blocks をパースするためにパーサーに手を加えていきます。swc のパーサーは手書きの再帰下降です。

自分は Babel の static blocks の実装にも関わったていたので、そのときの経験から、JS と TS で static blocks のパースの勝手が違うことを知っていました(TS の場合 modifier(アクセス修飾子とかreadonlyとか)が static blocks とおなじ場所に現れるので、そのあたりの考慮が必要)。

ただ、JS のみに対応する場合実装がシンプルで済むので、一旦 JS のみに対応するパーサーを書きました。そしてテストを書いて、そのテストをもとにして TS 用のパーサーも実装していくことにしました。

JS 用パーサー

前述の通り、static blocks のパースの面倒な点は、その登場する位置にあります。TS のクラスのいろんな修飾子とおなじ場所に現れるのが面倒なだけです。static blocks 自体のパース処理は簡単にかけます。

こんな感じです。

fn parse_static_block(&mut self) -> PResult<ClassMember> {
    let start = cur_pos!(self);
    expect!(self, '{');

    let stmts = self.parse_block_body(false, false, Some(&tok!('}')))?;

    let span = span!(self, start);
    Ok(StaticBlock { span, body: stmts }.into())
}

この関数が呼ばれる時点で、すでに static の部分は読み終わっている前提ですね。cur_pos!tok!expect!などマクロが散見されますが、なんとなく雰囲気はわかると思います。ブロックの始まりの{を読んで、中身のステートメントを読んでいます(ブロックの終わりの}parse_block_bodyの中で読んでるんだと思う)。で、Result(っぽいやつ)に包んで StaticBlock ノードを返します。

呼び出し元はこんな感じになっています。

if self.input.syntax().static_blocks() && is_static && is!(self, '{') {
    return self.parse_static_block();
}

この処理は parse_class_member_with_is_static という関数の中で呼ばれています。簡単に言えば「static かもしれないクラスメンバーをパースする関数」です。で、self.input.syntax().static_blocks() というのはそもそも static blocks をパースするべきかオプションで指定できるので、それを取得するやつです。それを踏まえると、ここの条件は「static blocks をパースするべきで、現時点で static メンバーっぽいとわかっていて、更に { が続く場合」ということになります。そういう場合に、前述の parse_static_block を呼び出すよーってことです。

TS 用パーサー

swc の TypeScript パーサーは、Babel と違って JavaScript パーサーとおなじファイルに地続きに書かれています。そういう観点では Babel のパーサーの方が読みやすいと思います。

全然詳細を忘れてしまったのですが、modifier が複数ある場合に適切なエラーを吐かせるのが難しかったような記憶があります。たとえばこういうやつ。

class Foo {
  readonly static {
    console.log("FOO");
  }
}

これはもちろん構文エラーですが、親切な構文エラーを吐かせる必要がありました。なので、雑にパースするのではだめで、readonly をちゃんと読み、 static blocks をちゃんとパースし、その上で「そこに readonly が存在するのはおかしい」っていうエラーを吐く必要があります。

具体的には、上の readonly static {...} のコードを swc のパーサーに食わせると、こういうエラーを吐くようになっています。

error: Modifiers cannot appear here
 --> input.ts:2:5
  |
2 |     readonly static {
  |     ^^^^^^^^

結構親切でしょう。これを実現するのがちょっと面倒だった、ということです。

Babel でも同様の実装をしたことがあるので、そのときの PR も貼っておきます。この PR は本家(microsoft/TypeScript)の実装を参考に実装しました。

https://github.com/babel/babel/pull/13680

ちなみに、後でこのあたりの処理を修正したので、現在の実装とは微妙に異なっています。

https://github.com/swc-project/swc/pull/2200

トランスフォーマーを書く

Deno も swc のパーサーを使っているようで、Deno チームの人が static blocks のパーサーの実装を待っている雰囲気があったので、パーサーまで実装して一旦マージしてもらいました。

なのでトランスフォーマーの実装は別の PR です。僕の怠惰で、パーサーの PR がマージされてから数か月してからこの PR を作成しました。

https://github.com/swc-project/swc/pull/2474

これはやっていること自体は全然難しくありません。Babel の該当プラグインをそのまま移植する感じです。

処理としては、

class Foo {
  static {
    console.log("Foo");
  }
}

というコードを

class Foo {
  static #_ = (() => {
    console.log("Foo");
  })();
}

に変換するだけです。これで static blocks とおなじタイミングとスコープで実行されるコードを static features と private fields で再現できます。

つまり、「StaticBlock のノードを見つけたら、それを static な private property(名前は_)で置き換えて、そのプロパティの初期値を StaticBlock の中身とおなじ内容の即時実行関数にする」という感じです。

やることとしてはかなり簡単だったんですが、swc のトランスフォーマーの書き方なんてどこにもドキュメントはないので、既存の実装を一生懸命読んで適当に実装するしかありません。このあたりは圧倒的に Babel の方が楽です。

swc は現在 Rust を使ってプラグインを作れる仕組みが検討されていますが、このトランスフォーマー API がそのまま使われる場合、普通の JavaScript プログラマーには結構難しいような気がします。いい感じにトランスフォーマーを書けるようになると嬉しいですね。

ただ、swc の作者の kdy1 はかなり親切で、「トランスフォーマーの実装に着手してくれてありがとう!わからないことあったらなんでも聞いて!」という DM をくれました。なので、GitHub や swc の Discord で聞いたら教えてくれると思います。

おわりに

かなり雑な説明になりましたが、以上が static blocks を swc に実装したときの一連の流れになります。

楽しかったですが、パーサーやトランスパイラの実装に慣れていない人がいきなり手を出すのは若干ハードルが高いような気がします。

ただ、Rust 知識はあんまり必要なかった気がします。僕は swc やるまで Rust はチュートリアルやった程度でしたが、割と普通にかけました。逆に言うと、Rust パワーを高めるためにはあんまり役にたたないかもしれません。

Discussion