Jasmine is compiler for a tiny programming language. It's implemented in TypeScript and supports code generation for WebAssembly and native targets. The following is valid program, that computes the 10th number in the Fibonacci sequence:
fn fib(n: int) -> int {
if n <= 0 {
return 0;
} else if n == 1 {
return 1;
}
let a: int = 0;
let b: int = 1;
for i in 1..n {
let temp: int = a;
a = b;
b = temp + b;
}
return b;
}
println(fib(10));
It uses binaryen.js and QBE for the Wasm and native targets respectively. This project is part of my dissertation where I wanted to understand the experience of targeting Wasm. Turns out it is pretty terrible. I say this because I found using QBE way easier than using Binaryen.
The compiler has three phases, lexer, parser, and code generation. The first two phases were based heavily from my experience writing a previous interpreter. I'll use the term backend to refer to the file that converts an AST into something executable, Wasm or a native binary.
Both backends traverse the AST to process the nodes. The Wasm backend uses the API provided by Binaryen, while the native backend constructs the QBE intermediate language as a string in memory. The following methods process an if
statement, taken from the two backends:
export default class BinaryenCompiler {
// --snip--
ifStatement(stmt: IfStmt): binaryen.ExpressionRef {
let condition = this.compileExpression(stmt.condition)
let thenBranch = this.compileStatement(stmt.thenBranch)
if (stmt.elseBranch) {
let elseBranch = this.compileStatement(stmt.elseBranch)
return this.mod.if(condition, thenBranch, elseBranch)
}
return this.mod.if(condition, thenBranch)
}
}
export default class QBECompiler {
// --snip--
ifStatement(stmt: IfStmt) {
let condition = this.compileExpression(stmt.condition)
let thenLabel = this.getBlockLabel()
let elseLabel = stmt.elseBranch ? this.getBlockLabel() : null
let endLabel = this.getBlockLabel()
let falseTarget = elseLabel ? `${elseLabel}` : `${endLabel}`
this.ctx.push(`jnz ${condition}, ${thenLabel}, ${falseTarget}`)
this.ctx.push(`${thenLabel}`)
this.compileStatement(stmt.thenBranch)
if (this.ctx[this.ctx.length - 1]?.substring(0, 3) != 'ret')
this.ctx.push(`jmp ${endLabel}`)
if (stmt.elseBranch && elseLabel) {
this.ctx.push(`${elseLabel}`)
this.compileStatement(stmt.elseBranch)
if (this.ctx[this.ctx.length - 1]?.substring(0, 3) != 'ret')
this.ctx.push(`jmp ${endLabel}`)
}
this.ctx.push(`${endLabel}`)
}
}
The Binaryen version reads better and is shorter. But I liked working with QBE more, even though it is implemented using string interpolation. The QBE reference is much nicer than the binaryen.js equivalent which is just a list of function signatures. More importantly, I can see what is happening when I am constructing the IL. With Binaryen, we create an internal representation for the library, which remains opaque until it's time to emit the WebAssembly text.
I used Bun to process TypeScript. It has a lot of built in features. The test runner and the $ Shell were particularly useful.