This document describes the internal architecture of ClojureWasm.
CW uses a strict 4-zone layered architecture. Lower layers never import from higher layers. Zone dependencies are enforced by CI (0 violations).
Layer 0: src/runtime/ — Value, collections, GC, environment
Layer 1: src/engine/ — Reader, Analyzer, Compiler, VM, TreeWalk
Layer 2: src/lang/ — Built-in functions, interop, lib namespaces
Layer 3: src/app/ — CLI, REPL, deps.edn, Wasm bridge
When a lower layer needs to call a higher layer, the vtable pattern
is used (runtime/dispatch.zig): the lower layer defines function
pointers that the higher layer sets during initialization.
ClojureWasm processes Clojure source code through a four-stage pipeline:
Source text
│
▼
┌──────────┐
│ Reader │ Tokenizer → Reader → Form (syntax tree)
└────┬─────┘
│ Form
▼
┌──────────┐
│ Analyzer │ Form → Node (executable AST)
└────┬─────┘
│ Node
▼
┌──────────────────────────────────┐
│ Dual Backend │
│ │
│ ┌────────────┐ ┌─────────────┐ │
│ │ Compiler │ │ TreeWalk │ │
│ │ Node→Chunk │ │ Node→Value │ │
│ └─────┬──────┘ └─────────────┘ │
│ │ Bytecode │
│ ▼ │
│ ┌────────────┐ │
│ │ VM │ │
│ │ Chunk→Value│ │
│ └────────────┘ │
└──────────────────────────────────┘
The VM (bytecode compiler + virtual machine) is the default backend.
The TreeWalk interpreter serves as the reference implementation for
correctness validation. Both backends produce identical results — verified
by EvalEngine.compare() tests.
Files: src/engine/reader/reader.zig, src/engine/reader/tokenizer.zig, src/engine/reader/form.zig
The reader converts source text into a Form tree (a syntax tree before semantic analysis). It handles:
- All Clojure literals: integers, floats, BigInt (
42N), BigDecimal (42M), Ratio (1/3), strings, characters, keywords, symbols, regex (#"pattern") - Collections: lists
(), vectors[], maps{}, sets#{} - Reader macros: quote
', deref@, syntax-quote`, unquote~, unquote-splicing~@, metadata^, fn literal#(), var#', tagged literals#inst,#uuid
Files: src/engine/analyzer/analyzer.zig, src/engine/analyzer/node.zig
The analyzer transforms Forms into Nodes (an executable AST). It resolves variable bindings, expands macros, and compiles regex patterns at analysis time. Special forms are dispatched via a comptime string map.
Key responsibilities:
- Local variable resolution (let, fn parameters, loop bindings)
- Macro expansion (requires runtime Env for macro lookup)
- Special form analysis (def, fn, let, if, do, loop, recur, try, etc.)
- Source location tracking for error messages
Files: src/engine/compiler/compiler.zig, src/engine/compiler/opcodes.zig,
src/engine/compiler/chunk.zig, src/engine/vm/vm.zig
The compiler transforms Nodes into bytecode stored in Chunks. Each instruction is a fixed 3-byte format: u8 opcode + u16 operand.
75 opcodes across 10 categories:
| Range | Category | Examples |
|---|---|---|
| 0x00-0x0F | Constants/Literals | const_load, nil, true_val, false_val |
| 0x10-0x1F | Stack | pop, dup, pop_under |
| 0x20-0x2F | Local variables | local_load, local_store |
| 0x40-0x4F | Var operations | var_load, def, defmulti, lazy_seq |
| 0x50-0x5F | Control flow | jump, jump_if_false, jump_back |
| 0x60-0x6F | Functions | call, tail_call, ret, closure |
| 0x80-0x8F | Collections | list_new, vec_new, map_new, set_new |
| 0xA0-0xAF | Exceptions | try_begin, catch_begin, throw_ex |
| 0xB0-0xBF | Arithmetic | add, sub, mul, div, eq, lt, gt |
| 0xC0-0xDF | Superinstructions | add_locals, branch_ne_locals, recur_loop |
Superinstructions (0xC0-0xDF) fuse common multi-instruction sequences into
single opcodes. A peephole optimizer in the compiler detects patterns like
local_load + local_load + add and replaces them with add_locals.
The VM is a stack-based machine with:
- Value stack: 4096 slots (NaN-boxed 8-byte values)
- Call frames: 256 max depth, each tracking IP, base pointer, code, constants
- Dispatch: Zig
switchon opcode (compiles to jump table) - GC safe points: Every 256 instructions (wrapping u8 counter)
- Exception handling: Handler stack with scope-aware unwinding
File: src/engine/vm/jit.zig
A proof-of-concept JIT compiler for hot integer loops on ARM64.
- Detects hot loops after 64 iterations in
vmRecurLoop - Compiles integer comparison + arithmetic + recur patterns to native ARM64
- Deoptimizes to interpreter on non-integer values
- Uses mmap for executable memory with W^X protection (mprotect)
- Registers: x3-x15 for loop variables (unboxed i64), x16 for base pointer
File: src/engine/evaluator/tree_walk.zig
The TreeWalk interpreter evaluates Nodes directly without compilation. It maintains a local binding stack (256 slots) and closure capture.
Primary uses:
- Reference implementation for VM correctness validation
- Bootstrap: core.clj is initially evaluated via TreeWalk
- Macro expansion during analysis
File: src/runtime/value.zig
All values are represented as 8-byte NaN-boxed u64 values. The scheme
uses the NaN space of IEEE 754 doubles to encode non-float types:
Float: raw f64 bits (any bit pattern < 0xFFF9_0000_0000_0000)
Integer: 0xFFF9 | i48 (signed 48-bit integer)
Char: 0xFFFC | u21 (Unicode codepoint)
Const: 0xFFFB | id (0=nil, 1=true, 2=false)
Heap: tag[16] | subtype[3] | shifted_addr[45]
Heap pointers use 4 tag groups (0xFFFA, 0xFFF8, 0xFFFE, 0xFFFF), each holding 8 subtypes. The address is right-shifted by 3 bits (exploiting 8-byte alignment), giving an effective 48-bit address space.
Heap types include: string, symbol, keyword, list, vector, hash_map, hash_set, fn_val, atom, lazy_seq, cons, var_ref, protocol, multi_fn, regex, matcher, big_int, big_decimal, ratio, array, wasm_module, and more.
File: src/runtime/gc.zig
Mark-sweep collector with free-pool recycling:
- Mark phase: Trace from root set (VM stack, environments, namespaces) through all reachable heap values
- Sweep phase: Recycle unreachable allocations to size-specific free pools, or free them if pools are full
- Trigger: When
bytes_allocated >= threshold(initial 1MB, doubles after collection if pressure remains)
Free pools are per-(size, alignment) intrusive linked lists. Dead allocations are cached (up to 4096 blocks per pool) for O(1) reuse.
File: src/engine/bootstrap.zig
ClojureWasm uses a two-phase bootstrap:
-
Phase 1: TreeWalk evaluates core.clj (embedded via
@embedFile). Each top-level form is read, analyzed, and evaluated sequentially. Macros registered bydefmacroare available for subsequent forms. -
Phase 2: Hot functions (transducer factories like
map,filter,comp, plusget-in,assoc-in,update-in) are recompiled to VM bytecode for performance.
Additional namespaces (clojure.test, clojure.set, clojure.walk, etc.) are similarly embedded and evaluated during bootstrap.
A bootstrap cache (pre-compiled at Zig build time) allows startup in ~4ms by skipping the parse/analyze/eval cycle for core.clj.
Files: src/app/wasm/*.zig, src/runtime/wasm_types.zig
The Wasm runtime is powered by zwasm,
supporting 523 opcodes (236 core + 256 SIMD + 31 GC). Clojure code can load
and call Wasm modules via the cljw.wasm namespace.
Key components:
- Module parser: Decodes Wasm binary format (types, functions, tables, memory, globals, imports, exports)
- Predecoder: Converts variable-width Wasm bytecode to fixed-width 8-byte
instructions (
PreInstr) at load time, with superinstruction fusion - VM: Stack-based interpreter with switch dispatch over predecoded IR
- WASI: File I/O, clock, random, args, environ
- Multi-module linking: Cross-module function imports
- SIMD: v128 type with 256 SIMD opcodes
- GC proposal: 31 GC opcodes (struct, array, ref types)
Performance: zwasm uses Register IR with ARM64/x86_64 JIT compilation, winning 14/23 benchmarks against wasmtime with a ~43x smaller binary.
Files: src/regex/regex.zig, src/regex/matcher.zig
A built-in regex engine (no external C library dependency). Patterns are compiled at analysis time into a bytecode representation, then matched at runtime.
Files: src/app/repl/nrepl.zig, src/app/repl/bencode.zig
A CIDER-compatible nREPL server supporting 14 operations: eval, load-file, complete, info, lookup, stacktrace, clone, close, describe, ls-sessions, interrupt, stdin, eldoc, and ns-list.