WATはS式で書かれたただのテキストファイルです。
(module
(func (export "main") (result i64)
(i64.const 1)
(i64.const 2)
(i64.const 3)
i64.mul
i64.add
)
)
これは「1 + 2 * 3」を計算するモジュールです。中身はスタックマシンの命令列そのもので、(i64.const n)で値をスタックに積み、i64.addのような命令がスタックから値をpopして計算しpushする、という仕組みになっています。バイナリの.wasmにするにはwat2wasmというツール(WABTというツールキットに含まれる)に投げるだけです。
つまり「自作言語 → WAT文字列を組み立てる → wat2wasmに渡す」という流れさえ作れば、バイナリフォーマットの詳細を一切知らなくてもWasmを吐くコンパイラが作れます。今回はRustで四則演算だけの超ミニ言語 minimini_lang を作って、これを試してみました。
パーサーはlalrpopというRust用のパーサジェネレータを使って書きました。
use std::str::FromStr;
use crate::ast::{Expr, BinOp};
grammar;
match {
r"\s*" => {},
r"//[^\n\r]*" => {}, // line comments
_
}
pub Expr: Expr = {
<l:Expr> "+" <r:Term> => Expr::binop(l, BinOp::Add, r),
<l:Expr> "-" <r:Term> => Expr::binop(l, BinOp::Sub, r),
Term,
};
Term: Expr = {
<l:Term> "*" <r:Factor> => Expr::binop(l, BinOp::Mul, r),
<l:Term> "/" <r:Factor> => Expr::binop(l, BinOp::Div, r),
Factor,
};
Factor: Expr = {
Num => Expr::Int(<>),
"(" <Expr> ")",
};
Num: i64 = {
r"[0-9]+" => i64::from_str(<>).unwrap(),
};
+ -より* /の方が結合が強く、どちらも左結合、という一般的な四則演算の文法です。
パース結果はこのシンプルなASTになります。
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
Int(i64),
BinOp { lhs: Box<Expr>, op: BinOp, rhs: Box<Expr> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinOp {
Add,
Sub,
Mul,
Div,
}
impl Expr {
pub fn binop(lhs: Expr, op: BinOp, rhs: Expr) -> Expr {
Expr::BinOp {
lhs: Box::new(lhs),
op,
rhs: Box::new(rhs),
}
}
pub fn get_children(&self) -> Vec<&Expr> {
match self {
Expr::Int(_) => vec![],
Expr::BinOp { lhs, rhs, .. } => vec![lhs.as_ref(), rhs.as_ref()],
}
}
}
コンパイラの本体はここです。ASTを再帰的に辿って、後置記法(スタックマシンなので当然そうなる)の命令列を組み立てているだけです。
pub fn compile_to_wat(ast: &crate::ast::Expr) -> String {
let instrs = ast_to_wat_string(ast);
format!(
"(module\n (func (export \"main\") (result i64)\n {}\n )\n)\n",
instrs.join("\n ")
)
}
fn ast_to_wat_string(ast: &crate::ast::Expr) -> Vec<String> {
match ast {
crate::ast::Expr::Int(n) => vec![format!("(i64.const {})", n)],
crate::ast::Expr::BinOp { lhs, op, rhs } => {
let lhs_str = ast_to_wat_string(lhs);
let rhs_str = ast_to_wat_string(rhs);
let op_str = match op {
crate::ast::BinOp::Add => "i64.add",
crate::ast::BinOp::Sub => "i64.sub",
crate::ast::BinOp::Mul => "i64.mul",
crate::ast::BinOp::Div => "i64.div_s",
};
let mut result = Vec::new();
result.extend(lhs_str);
result.extend(rhs_str);
result.push(op_str.to_string());
result
}
}
}
#[cfg(test)]
mod tests{
use super::*;
#[test]
fn test_ast_to_wat_string() {
let ast = crate::ast::Expr::binop(
crate::ast::Expr::Int(1),
crate::ast::BinOp::Add,
crate::ast::Expr::binop(
crate::ast::Expr::Int(2),
crate::ast::BinOp::Mul,
crate::ast::Expr::Int(3),
),
);
let wat_strings = ast_to_wat_string(&ast);
let expected = vec![
"(i64.const 1)".to_string(),
"(i64.const 2)".to_string(),
"(i64.const 3)".to_string(),
"i64.mul".to_string(),
"i64.add".to_string(),
];
assert_eq!(wat_strings, expected);
}
}```
`ast_to_wat_string`が命令列を作り、`compile_to_wat`がそれを`(module (func (export "main") (result i64) ...))`というテンプレートに埋め込んでいます。文字列を`format!`で組み立てているだけなので、コンパイラのバックエンドとしてはかなり牧歌的です。
## CLI
あとはファイルを受け取ってWATを吐き出すだけのCLIです。入力は`.mmm`拡張子のファイルにして、出力は同じ名前の`.wat`ファイルに書き出すようにしました。
```rust
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::exit;
fn main() {
let args: Vec<String> = env::args().collect();
let Some(path) = args.get(1) else {
eprintln!("usage: {} <file.mmm>", args[0]);
exit(1);
};
let src = match fs::read_to_string(path) {
Ok(src) => src,
Err(e) => {
eprintln!("failed to read {path}: {e}");
exit(1);
}
};
let ast = match minimini_lang::parse_expr(&src) {
Ok(ast) => ast,
Err(e) => {
eprintln!("parse error: {e}");
exit(1);
}
};
let wat = minimini_lang::compiler::compile_to_wat(&ast);
let out_path = PathBuf::from(path).with_extension("wat");
if let Err(e) = fs::write(&out_path, wat) {
eprintln!("failed to write {}: {e}", out_path.display());
exit(1);
}
println!("wrote {}", out_path.display());
}
1 + 2 * 3 と書いたファイルをコンパイルすると:
$ cargo run -- simple.mmm
wrote simple.wat
先頭に貼ったWATがそのまま出力されます。これをwat2wasmでバイナリ化して、Node.jsのWebAssemblyAPIから呼び出すと、ちゃんと計算結果が返ってきます。
$ wat2wasm simple.wat -o simple.wasm
$ node -e "
const bytes = require('fs').readFileSync('simple.wasm');
WebAssembly.instantiate(bytes).then(({instance}) => {
console.log(instance.exports.main());
});
"
7n
1 + 2 * 3 = 7。優先順位も含めてちゃんと計算できています。
.wasm化はwat2wasm(WABT)に任せられるWebAssembly APIでも可能「Wasmを吐くコンパイラを作る」というと身構えてしまいますが、WATを経由すれば、パーサーさえ書ければ半日もあれば動くものができてしまいます。次は変数や関数呼び出しなど、もう少し言語らしい機能を足していきたいところです。