Alabhya Jindal

Jasmine

9 September 2025

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.