自作言語コンパイラをウェブブラウザで動くようにした話

## はじめに
自作言語コンパイラがウェブブラウザで動くと面白いという発想でえいやっとした話です。

## 自作言語コンパイラの概要
既存の言語への理解を深めるためにoriglangという仮題で自作言語のコンパイラを開発しています。詳細は [GitHubレポジトリ](https://github.com/KisaragiEffective/origlang)をご覧ください。

https://github.com/KisaragiEffective/origlang

## ウェブブラウザで動かすための技術選定

ウェブブラウザのサポート対象はモダンブラウザ (Internet Explorerや古いSafariを除いたブラウザの総称) としました。理由としては古いブラウザをサポートする特別の理由がないからです。

そして、モダンブラウザのみに限定するならJavaScript (や[asm.js](https://asmjs.org/)) よりWebAssemblyだろうという結論に至りました。その理由として、次の3点が挙げられます。

1. WebAssemblyは静的型付け
1. JavaScriptは人間に読めるのでバンドルサイズが大きい
1. WebAssemblyのほうが保証されている性質が多いので、よりよい最適化が期待できる

この記事を書くにあたって事後調査をしたらasm.jsはコーナーケースで[実装の差がある](https://qiita.com/mod_poppo/items/de5c6f2f4604b84b1bc1) という記事を発見しました。なのでasm.jsを使わなかったのは正解なんだと思います。

## クレートの分割
コンパイラは1つのクレートの中にあるサブモジュール群から構成されていましたが、それらのサブモジュール群を`lib`クレートとしてそれぞれ別々のクレートに分割しました。理由は2つあります：

1. [Rustのコンパイラ](https://github.com/rust-lang/rust/tree/master/compiler)を見習った
1. `#[cfg]` で実装を切り替えるのが手間だと思っていた

こうすることで原因が切り分けやすくなるのでやって良かったと思います。実際は次のように分割しました。

* `origlang-ast` - AST (抽象構文木) の構造のモデリング
* `origlang-cli` - OS上におけるツールチェインのフロントエンド
* `origlang-compiler` - レキサー、パーサー、型チェック
* `origlang-runtime` - スコープや中間コードなど、実行時の情報の格納
* `origlang-platform` - プラットフォーム事情のカプセル化
* `origlang-typesystem` - 型システムのモデル

次に`use`がどこを指しているかわからないと言ってくるので、適宜サブモジュールの`Cargo.toml`に依存を追加しながら`struct`や関数の可視性を調整します。

ルートディレクトリで`cargo c`を実行し、コンパイルが通ればOKです。

## WebAssembly用サブモジュールの追加

次にWebAssembly用のサブモジュールを追加します。ここでは`origlang-interop`と命名しました。

`Cargo.toml`を開いて、次のスニペットを追記します[^5]：

[^5]: もし忘れても`wasm-pack`がエラーで指示してくるので忘れても大丈夫です

```toml:Cargo.toml
[lib]
crate-type = ["cdylib"]

[dependencies]
# 必要に応じてバージョンを変えてください
wasm-bindgen = "0.2.74"
```

`lib.rs`にかかれているであろうコードを全て削除します。[Hello World](https://rustwasm.github.io/docs/wasm-bindgen/examples/hello-world.html)を見ながら適切なシグネチャでJavaScript側に後で作る関数を宣言します。宣言するためには`wasm-bindgen`の`#[wasm_bindgen]`というproc-macro[^6]を使います。これを`extern`[^7]節につけると[JavaScript側で宣言されている関数を宣言](https://rustwasm.github.io/docs/wasm-bindgen/examples/import-js.html)し、`pub fn`[^1]につけると[WebAssembly側で宣言する関数を宣言](https://rustwasm.github.io/docs/wasm-bindgen/examples/hello-world.html)します。

[^6]: アノテーションみたいなものです。
[^7]: ABIを省略すると`extern "C"`と同様の意味になります。今回は私が「JavaScriptなのに`extern "C"`は実装の都合なので`extern`を選択する」という選択をしたので、このまま省略しておきます
[^1]: `pub`でない場合、サポートされていないというエラーが出てコンパイルできません

サブモジュール側で適切にコンパイラをインスタンス化してコンパイルを開始する関数を呼びます。WebAssemblyは標準出力を持たないので、もし式の結果を出力することがあれば都度JavaScriptに委譲します。実際の実装はこうなりました：

https://github.com/KisaragiEffective/origlang/blob/76061d3d61ea1f4e4ac1e22d757c3e02416b2934/package/origlang-interop/src/lib.rs

これをビルドするために`wasm-pack`というツールを[インストール](https://rustwasm.github.io/wasm-pack/)する必要があります。

## フロントエンドの作成

簡単に[^2]フロントエンドを作っておきます。`extern`節につけた`#[wasm_bindgen]`と同じ名前、互換性のあるシグネチャを受理できる関数を作っておきましょう。また、それよりも前の`<script>`で`wasm-script`から出力された`origlang_interop.js`を`type="module"`として、`origlang_interop_bg.wasm`を`type="application/wasm"`としてそれぞれ読み込んでおきましょう。

[^2]: 必要最低限のインターフェースしか持たない実装

以下に例を示します。

https://github.com/KisaragiEffective/origlang/blob/76061d3d61ea1f4e4ac1e22d757c3e02416b2934/package/origlang-interop-frontend-webserver/www/index.html

## ローカルで立ち上げるHTTPサーバー用のサブモジュールの追加

最近のブラウザはCORSの制限が厳しいので、それを避けるために手元で動かすことを想定したHTTPサーバーを立ち上げるサブモジュールを新しく作ります。ここでは`origlang-interop-frontend-webserver`と命名しました。使うクレートは何でもいいですが、`actix-web`と`actix-files`を使いました。

以下に実際の実装を示します。

https://github.com/KisaragiEffective/origlang/blob/76061d3d61ea1f4e4ac1e22d757c3e02416b2934/package/origlang-interop-frontend-webserver/src/main.rs

一通り立ち上がることが確認できたら、次へ進みます。

## GitHub Actionsの設定

### wasm-packのインストール

`wasm-pack`はHosted runnerに入っていないので、自分でインストールする必要があります。とりあえず一行で済む

```sh
cargo install wasm-pack
```

で入れました。


### cargo install wasm-pack が遅い

しかし、`cargo install`は何も設定を渡さないと[リリースモードでソースからコンパイル](https://doc.rust-lang.org/cargo/commands/cargo-install.html#compilation-options)を行います。それは許容できなかったので`--debug`をつけたのですがあまり有意な縮み方にはなりませんでした。

そこで、[GitHubのReleases](https://github.com/rustwasm/wasm-pack/releases)からすでにビルドされたバイナリを持ってきて動かすことにしました。`*.tar.xz`という名前から、適切に解凍してやる必要があると推測できます[^4]。また、今回必要なのは実行ファイルのみなので実行ファイルのみを解凍します。

[^4]: と言っても、私がやる必要があるのは `tar xf hoge.tar.xz` だけです

### wasm-pack が見つからない？

最初はwasm-packが見つからないと言われました。実行ファイルをフルパスで指定しなかったときに見つからなかったと言われる原因の99.99%はその実行ファイル (を含んでいるディレクトリ) がPATHに通ってないからです。`mv wasm-pack /usr/local/bin`として解決しました。

### WindowsだとPowerShellと解釈される

origlangのworkflowでは過去の失敗からUbuntuとWindowsの両方においてコンパイルが通るかチェックしていますが、WebAssemblyをビルドする変更を入れるとCIが失敗するようになってしまいました。これは好ましくありません[^3]。

[^3]: 2つ理由があります。1つ目はビルドが失敗するたびに Notification に通知されて大変愉快なことになるということです。2つ目はそしてレポジトリのステータスとして部分的であってもビルドが失敗しているのは見栄えが悪いと私が考えていることです。

その筆頭としてWindowsのrunnerが直書きのスクリプトをPowerShellとして解釈し、失敗するという問題がありました。これは`shell: bash`として明示的にインタプリタを指定することで解消しました。

### Windowsのbashには`/usr/local/bin` がない

なぜかははっきりしていませんが、Windowsのbashは`/usr/local/bin`がないようです。仕方がないのでもし移動に失敗した場合は`/usr/bin`への移動を試すことにしました。

### WindowsのbashはLinux向けにコンパイルされたバイナリを解釈できない

当たり前といえば当たり前かも知れませんが、WindowsのbashはLinux向けにコンパイルされたバイナリを解釈できないようです。そのため、適切なターゲットトリプルをworkflowのmatrixに持たせ、ダウンロードするURLを変更するようにしました。

### GitHub Actionsのworkflowにおけるmatrixがデカルト積を生成する
workflowのYAMLに`matrix`という名前でキーを指定すると、workflowに渡る値の組み合わせを切り替えられる仕組みがあります ([GitHub](https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs))。これ**自体は**いい機能です。デフォルトで設定の値のデカルト積 ([Wikipedia 日本語版の記事](https://ja.wikipedia.org/wiki/%E7%9B%B4%E7%A9%8D%E9%9B%86%E5%90%88)) を生成し、その組み合わせの**全て**を試す実装になっています。今回のワークフローにおいては、これは望ましい実装ではありません。なぜならWindowsがLinux向けにコンパイルされたバイナリを解釈しようとしたり、その逆が起きたりするためです。

各設定項目をまとめたオブジェクトの配列を`matrix`に渡すと正しく解釈されず、workflowの実行自体がスキップされてしまいました。幸いにも、`matrix`の代わりに`include`を指定することでデカルト積を生成せずに各設定の組み合わせを追加することができたのでこれを採用しました。

### 実行ファイルに拡張子が付いていることがある

wasm-packに含まれている実行ファイルは、Linux向けにコンパイルされたものだと拡張子が付いていません。一方で、Windows向けにコンパイルされたものは拡張子が付いています。これは文化圏の差ですが、すっかり忘れていました。

実行ファイルの名前もworkflowのmatrixに持たせることで解消しました。

## NGシーン: `build.rs` の内部から `wasm-pack`

当初は`origlang-interop` の `build.rs`から`wasm-pack`を実行すればCIでも手元でも同じような実装を繰り返さなくて済むと考えていたことがありました。しかし、これはうまく行きませんでした。なぜかはわかっていませんが、`wasm-pack`の出力から推察される理由として、`wasm-pack`自体が`cargo build`に相当することをやっていることが挙げられます。`cargo build`自体はビルドに対する専有ロックを取得しますが、取得できなかった場合は取得できるまで実行がブロックされます。

それによって、次のようなデッドロックが生まれてしまったと推察できます。

|パッケージ|先に進むための条件|
|:-------|:--------------|
|`origlang`|`origlang-interop`のビルドが終わること|
|`origlang-interop`|`wasm-pack`によるビルドが終わること|
|(`wasm-pack`)|`origlang`が抱えているビルドのロックが開放されること|

三すくみです。これはどう頑張っても無限ループです。

`build.rs`によるアプローチを諦めて、`build.sh`から`wasm-pack`を実行して都度ビルドする運用に切り替えたら解消しました。

## おわりに

オチは特にありません。