A WASM backend, written in Penelope.
The Design choices section ended with: "the natural successor to the TS implementation is a Penelope-implemented runtime — not a Rust rewrite that would be obsolete the moment the self-hosted compiler can target WASM or native."
std/wasm.pen is a WASM backend written in Penelope. It takes an AST bundle (the kind std/parser.pen produces) and emits a complete WebAssembly module as a list of bytes — valid binary, loadable by any WASM runtime, no external assembler required.
$ pen wasm examples/09-fib.pen --out fib.wasm
wrote fib.wasm
$ node -e "
const m = await WebAssembly.compile(require('fs').readFileSync('fib.wasm'));
const i = await WebAssembly.instantiate(m, { js: {
print: (p, l) => { /* ... */ }, now: () => Date.now(),
random_int: (lo, hi) => lo + Math.floor(Math.random() * (hi - lo)),
net_fetch: () => 0, read_file: () => 0, write_file: () => {}
}});
console.log('fib(20) =', i.exports.main());
"
fib(20) = 6765
| Phase | Feature | How it maps to WASM |
|---|---|---|
| 6.A | int, bool, arithmetic, comparisons | i32 raw, then tagged via the 6.B Value layout. |
| 6.A | Local let, if/else, blocks | WASM local slots, structured if (blocktype i32) ... else ... end. |
| 6.A | Top-level fns, recursion, mutual recursion | One WASM function per binding, call $fn by index. |
| 6.B | Tagged Values + bump allocator | 8-byte cells, byte 0 = tag (1=int, 2=bool, 3=str, 4=unit, 5=list, 6=dict, 7=closure), bytes 4–7 = data/ptr. |
| 6.C | Strings + 13 str_* / char_* builtins | STR Value points to {len:i32, bytes:[u8]}; helpers like str_concat, str_find, str_chars, int_of_str compiled to memory ops. |
| 6.D | Lists + dicts | LIST is {len:i32, items:[i32]}; DICT is {n:i32, entries:[{key_ptr, value_ptr}]}. list_new(a, b, c) inlined as alloc + per-item store. |
| 6.E | First-class fn values + closures | Anonymous Fn nodes lifted to top-level fns with signature (env_ptr, ...args) -> i32. Closure env block laid out as [table_idx, cap_0, cap_1, ...]; closure Value is tag=7 + env_ptr. Indirect call via funcref TABLE + call_indirect. |
| 6.F | match with all 8 pattern kinds | If-else chains with i32.and for sub-pattern combination; literal / wildcard / var / list / dict / cons / or / guarded. |
| 6.G | 6 effects via host imports | js.print / js.now / js.random_int / js.net_fetch / js.read_file / js.write_file. Exported alloc lets the host write strings into WASM memory before returning a block pointer. |
96 end-to-end tests in test/wasm-backend.test.ts cover each piece. Every test runs the same source through both the pen-built WASM backend and the TS interpreter and asserts the results match.
An anonymous fn(x) { x + d } appearing inside fn(d) { fn(x) { x + d } } is the canonical closure: the inner fn captures d from the outer's scope. WASM has no native closures — every fn lives in a flat fn table with a fixed signature. So the backend does closure conversion:
find_free_vars walks each anonymous fn's body, computing which names it references that aren't parameters, locals, top-level fns, or builtins.collect_lifted_fns assigns each anonymous Fn a top-level WASM function index plus a table index. Its signature becomes (env_ptr, ...original_params) -> i32.4 + 4*N bytes where N is the number of captures. The first 4 bytes hold the table index; the next slots hold each captured Value pointer.f(x) where f is a local holding a closure Value: load env_ptr from f+4, push it as the first arg, push user args, load table_idx from env_ptr+0, call_indirect.env_ptr+(4+i*4) into a local slot, so the body's x + d compiles to a plain local.get for d.let make_adder = fn(d) {
fn(x) { x + d }
};
let add5 = make_adder(5);
add5(37); // 42
Two closures from the same make_adder capture distinct values:
let add1 = make_adder(1);
let add10 = make_adder(10);
add1(5) + add10(5); // 6 + 15 = 21
| Excluded | What it would take |
|---|---|
| Floats | Add f64 Value tag + arithmetic helpers; parser already accepts float literals. |
| Arity-3+ closures | Each new arity needs a new type index in the type section; mechanical extension. |
| Captures from across multiple nested fns | The current find_free_vars handles each level locally — nested captures would need recursive env chains or flattened capture lists. |
| Snapshot / pause / resume (Phase 6.H) | Architectural change. WASM's structured-stack execution model has no native way to suspend a fn mid-execution and serialize its continuation. The two paths: (1) CPS transform on the AST so every fn returns an explicit continuation, then pause = "save continuation + heap, exit"; or (2) ship a Penelope-bytecode-interpreter compiled to WASM and snapshot the bytecode VM state. Either is a paper-sized design conversation, not a bolt-on. |
For pause/resume today, run programs through the regular interpreter or the JIT. The WASM backend is for the hot-path subset where you actually benefit from native execution.
One file: std/wasm.pen (~5400 lines of Penelope). Depends only on std/iter.pen. Structure:
leb128_u for unsigned (sizes, indexes), leb128_s_nonneg for signed-but-non-negative literals.vec_of_bytes, name_bytes, str_to_bytes.compile_expr walks the AST and emits opcode bytes. Builtins (print, str_concat, list_get, ...) are intercepted and routed to compiled-in helpers.make_int, make_bool, ...), tag unwrappers, string/list/dict helpers, char predicates, host-import shims, and make_closure.(id, length, payload), prepends the \0asm\01\00\00\00 header.A Penelope program, written by a user, parsed by a Penelope-implemented parser, compiled by a Penelope-implemented WASM backend, runs in any environment that supports WebAssembly — Node, browsers, Cloudflare Workers, Wasmtime, the WASM runtime baked into your phone. The TS implementation is the bootstrap stage; for the WASM-targetable subset of the language, the TS code is now optional, not load-bearing.
Each phase from 6.A to 6.G expanded that subset:
match. "Pattern matching works."What's left in the language vs the WASM target is essentially: floats, snapshot/resume, and a handful of edge cases. For everything else, the WASM backend is the path forward.