Penelope

A WASM backend, written in Penelope.

The promise made in Phase 5

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

What works

PhaseFeatureHow it maps to WASM
6.Aint, bool, arithmetic, comparisonsi32 raw, then tagged via the 6.B Value layout.
6.ALocal let, if/else, blocksWASM local slots, structured if (blocktype i32) ... else ... end.
6.ATop-level fns, recursion, mutual recursionOne WASM function per binding, call $fn by index.
6.BTagged Values + bump allocator8-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.CStrings + 13 str_* / char_* builtinsSTR Value points to {len:i32, bytes:[u8]}; helpers like str_concat, str_find, str_chars, int_of_str compiled to memory ops.
6.DLists + dictsLIST 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.EFirst-class fn values + closuresAnonymous 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.Fmatch with all 8 pattern kindsIf-else chains with i32.and for sub-pattern combination; literal / wildcard / var / list / dict / cons / or / guarded.
6.G6 effects via host importsjs.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.

How closures work (Phase 6.E)

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:

  1. Free-variable analysis: find_free_vars walks each anonymous fn's body, computing which names it references that aren't parameters, locals, top-level fns, or builtins.
  2. Lifting: 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.
  3. Env block layout: when a Fn-literal expression is evaluated, the backend allocates 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.
  4. Closure Value: the env block pointer is wrapped in a CLOSURE-tagged 8-byte cell (tag=7, bytes 4–7 = env_ptr). That's a first-class value: storeable in locals, passable as arguments, returnable.
  5. Indirect call: calling 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.
  6. Capture prelude: the lifted fn's body starts with a generated prelude that loads each captured value from 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

What's out of scope (documented honestly)

ExcludedWhat it would take
FloatsAdd f64 Value tag + arithmetic helpers; parser already accepts float literals.
Arity-3+ closuresEach new arity needs a new type index in the type section; mechanical extension.
Captures from across multiple nested fnsThe 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.

How it's built

One file: std/wasm.pen (~5400 lines of Penelope). Depends only on std/iter.pen. Structure:

  1. LEB128 encodersleb128_u for unsigned (sizes, indexes), leb128_s_nonneg for signed-but-non-negative literals.
  2. Byte-buffer helpers — buffers are plain Penelope lists of ints. vec_of_bytes, name_bytes, str_to_bytes.
  3. Three-pass compilation — pass 1 collects top-level fn names and assigns indexes; pass 2 finds all anonymous Fn nodes and assigns lifted fn_idx + table_idx + captures; pass 3 emits each fn body's WASM instructions.
  4. AST → instructionscompile_expr walks the AST and emits opcode bytes. Builtins (print, str_concat, list_get, ...) are intercepted and routed to compiled-in helpers.
  5. 45 helper fns emitted into the code section: allocator, tag wrappers (make_int, make_bool, ...), tag unwrappers, string/list/dict helpers, char predicates, host-import shims, and make_closure.
  6. Module assembler — type → import → function → table → memory → global → export → element → code. Wraps each section in (id, length, payload), prepends the \0asm\01\00\00\00 header.

Why this matters

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:

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.