Six fixes across the toolchain, surfaced by lib/lisp porting work.
1. f64 compound assigns (`acc += d`, `-=`, `*=`, `/=`). Both stages
load slot → X1, OP X0 into X1, store back (ADDSD/SUBSD/MULSD/
DIVSD are reg-reg only). Previous MOVSD-overwrite dropped the
OP. Locals and top-level lets.
2. Top-level `[N]u8` arrays + `&arr[i]`. let_emit_size grows a
TY_ARRAY branch so zero-init DATAW lands; cgindex / N_INDEX
store / `&base[i]` all detect a global array base and use
LEAQ name(SB) instead of LEAQ (BP). TK_AMP no longer pre-
evaluates the operand as a value-load — `&base[i]` computes
base + i*esz directly. Unblocks Hare's static-buffer pattern:
strconv.{u64,i64,f64}tos graduate to module-level `*_buf`
arrays and return owned views.
3. Cross-module `pkg.Enum.MEMBER`. Nested N_DOT chains that
don't fold to a known shape now emit `MOVQ <leaf>(SB), AX`
(mirrors the bare-IDENT unresolved fallback), so isolation
probes — and the test 990 cgen-match floor — stay consistent
across stages. strconv exposes `base` as a real `enum i32`;
callers updated. The `main` exemption (linker entry-point
keeps bare name even when not exported) mirrors C-side
collectmods into selfhost cgendecl.
4. Sum-typed parameter ABI. lib/bytes.{index,rindex} take
`(u8 | []u8)` needle; lib/strings.byteindex / rbyteindex take
`(str | rune)` needle (Hare-shaped; the byte-wise misnomer
`index` is dropped). tagged_arg_size cap bumps to 48 (6 int
regs), with a new partial-fit branch on the callee: when an
N-word tagged arg overflows remaining regs, fill what fits and
stitch the rest from positive BP offsets. scanlocals MCASE
handles slice binds (24B) and walks each arm with a saved /
restored seenmark set so two arms naming the same local each
get their own slot — matches cstage's per-arm scope reset.
5. 4-reg tagged-return ABI (AX=tag, DX=word0, CX=word1, R8=word2),
up from 3 regs. Slice-payload variants (`([]T | E)`, slot 32B)
round-trip ptr/len/cap end-to-end. Every receive site updates:
let-init via cgwidentaggedstore, match scrutinee spill, cgindex
tagged-element load (both N_IDENT and fallback bases),
pushargsrev tagged-ident arg (reads word count from slot size),
cgreturn slice variant in the shuffle path.
6. `expr: TaggedAlias` is a widening, not a re-interpret. C cgen +
selfhost cgwidentaggedstore peel an N_CAST whose destination IS
the union — so cgexpr's natural shape (str: AX=ptr, BX=len;
slice: AX=ptr, BX=len, CX=cap) is consumed by the matching
concrete-variant branch instead of being misread as a tagged
AX/DX/CX triple. Inner casts to a concrete variant (`7: i32`)
keep their type for proper tag lookup. `[N]Alias` arrays
resolve element size via slotsize + aliaslookup, and aliaslookup
strips a `pkg.` prefix so cross-module references work.
lib/fmt grows `formattable = (i64 | str | bool | rune)` plus
`printv` / `printlnv` taking an explicit `[]formattable` slice (the
receive side of Hare's `args: formattable...`). Call-site variadic
gather isn't wired — callers either hand-build the slice or compose
strconv.i64tos + strings.concat.
700_e2e: 114 → 123 rows (f64 compound, top-level u8 arrays + `&buf[i]`,
pkg.Enum.MEMBER, sum-typed (str|rune) and (u8|[]u8) params, 4-reg
slice-return ABI, formattable array). 26/26 tests, bootstrap stable
through ww4.
8.2 KiB
examples/lisp — tiny Lisp interpreter in pure ww. Demo program; not a
production interpreter. Drives the wwstage cgen (out/bin/w6c_ww) by
default; the Makefile sets WW_W6C so make picks the ww-built
backend rather than the C bootstrap one.
Layout
lispcore.wwinterpreter module: types, lexer, parser, env, eval, apply, printer, REPL. Everything the entry point and the test driver consume isexport-ed.lisp.wwentry point;use lispcore;+main().lisp_test.wwin-process test driver (101 probes). Built as a standalone binary, exec'd directly —ww testdrops-Iin single-file mode, so the Makefile runs the binary itself.test_*.lispdemo source files (cat test_X.lisp | ./lisp).
The lispcore.ww + lisp.ww split exists so the tests can use the
interpreter functions. Both files combine under the same module name
(the directory's basename, lisp), because ww's module resolver
takes the containing-directory basename when finding a sibling.
Cross-module type prefixes like lispcore.value don't resolve in the
test — use the bare names.
wwstage cgen workarounds at play
The wwstage cgen has been brought up to par on the bug classes this demo originally exercised. The remaining workarounds and the historical bugs they sidestepped are listed below; "(retired)" marks items the cgen now handles natively, kept here only as a record of the shape so a regression is easy to recognise.
-
(retired) Top-level
[N]Tarrays mis-address inside functions.globalarr[i]used to lower toLEAQ (BP), BX. cgindex/cgassign now detect top-level array idents and emitLEAQ name(SB), BX, and emitletdataw lays the array bytes into DATAW. -
(retired)
global_ptr[i]mis-addresses. cgindex/cgassign detect top-level*Tidents and emitMOVQ name(SB), BXwith the correct element scaling.lispcore's symbol interner is back to plainsym_blob[off + i]style; nolet blob: *u8 = sym_blobaliasing. -
(retired) Two-level field write through a non-pointer sub-struct. cgassign/cgdot now handle the chained
(*L).cur.kindshape both as read and write. The lexer keeps the flattenedcurkind/curival/ … fields for now because every call site uses them; un-flattening is a stylistic improvement, not a correctness fix. -
(partially retired) f64 through every boundary. Struct-field
p.fval = vthrough*T,*p = vfor*f64, andalloc(T{…})sugar for f64 fields all route through X0 now (vfloat,promote_v,to_f64are back to directp.fval = …/*out = v.fvalform). Function-arg passing of an f64 struct-field value also works (the cgen'sexprfloatkindnow recognisesp.fieldwhose declared type is f64/f32). -
(retired)
alloc(value{ text = s })writes onlys.ptr. Both thealloc(T{…})builtin and the bare struct-literal init now emit both halves of the str.vstrstill does manualp.text = s(semantically equivalent, no longer required). -
(retired)
(slice | E)tagged-union returns dropslice.len. The return ABI now uses 4 regs (AX=tag, DX=ptr, CX=len, R8=cap), covering slice-payload variants up to 32B. The inline args-eval inevalis still there but no longer required —applycould factor it back out into a(slice | rterror)helper. Left as a readability cleanup; not a correctness fix. -
(retired)
xs[i].kinddrops the trailing field load. cgdot now handles N_INDEX bases. Builtins are back toxs[0].kind/xs[0].cardirectly — nolet p = xs[0];first. -
f64 compound assigns are mis-lowered to
acc = d(no OP). Still present. Write the explicit form:acc = acc + f. Integer compound assigns work fine. -
(retired in practice)
let r = call(); foreign_call(); return r?;corruptsr. The wwstage cgen now spills the AX/DX/CX triple to the local's 24-byte slot at the assignment point, matching what cstage emits — the foreign call in between no longer clobbers an unspilled half. We still pre-match the union inline in eval's BUILTIN apply path so theos.freehappens after classification rather than after the unwrap.
If a new function "should work but acts weird", check #6 first.
Tail-call optimization
eval is a single for(true) trampoline; a tail position rewrites
v (current expr) and cure (current env) in place and continues
instead of recursing. Tail positions are:
- the chosen branch of
if - the last form of
begin/let/ a lambda body - a direct call in any of the above
A tailed flag flips on the first jump into a fresh scope (LET
binding, lambda body) so subsequent define/set! mutate the local
cure chain rather than the caller's ein slot — same scoping the
old recursive run_body(_, &pe) path gave.
Mutual recursion still doesn't work — that's a define tie-back
limitation, not a TCO one. Defining evn? before od? captures an
env where od? is unbound; the tie-back only adds the self-binding.
Interpreter limitations (design, not bug)
- Arena, not GC. Cells live in two bump-pointer arenas:
perm(top-level definitions and the value graph each one pins) andtrans(parse cells, intermediate evals, the form's printed result).repl()callsarena_reset_trans()between top-level forms; a Cheney-stylepromote_vdeep-copies the value graph at every top-leveldefine/set!boundary so no perm cell ever points into trans. Forwarding markers (pin = -1plus the new perm ptr stashed in.car/.val) break cycles. Detached trans chunks go onto a per-arena free list, so peak virtual address space is bounded by the largest form's working set. Builtin args-slice headers in eval's apply path are released withos.freeper dispatch — without that,rt_ensure's page-per- call allocation dominates the profile. test_huge peaks at ~2.6 MB under--pages-as-heap=yes, down from ~525 MB pre-arena (~200×). No real GC inside a single form, so a pathological one-shot like(fib 25)would still grow trans linearly until the form returns. - No bigints.
i64wraps silently on overflow.(fact 21)rolls over. - Float printing is 6-digit fixed-point. The printer delegates
to
strconv.f64tos, which trims trailing zeros and the trailing '.' (1.0→1,1.5→1.5,0.1→0.1). It does not yet emit scientific notation or detect NaN/Inf — magnitudes ≥ 9e18 print ashuge. Graduate-to-Ryū requiresf64↔u64bit- reinterpret in cgen. - String escapes are accepted but not translated.
"\n"in source lands as the two bytes\\+n, not a newline. - No
(load)/ file I/O builtins. Programs come in via stdin.
Latent issues — not observed, but the surface to watch
- Other i64 compound assigns in the cgen aren't audited site-by-site. Workaround #8 covers f64; if you add new arithmetic on integers inside a hot loop, scan the asm.
- The interactive REPL's
shift_buf(L.curstart)assumesparse_expralways primes exactly one token of lookahead after returning success. A future parser change that skips that prime silently eats input. - The
definetie-back prepends a self-binding frame ontobv.envp. It assumes constructors always initializeenvp. They do today. - Cross-module type names (
lispcore.value) don't resolve; we side-step by using bare names. Ifww's module resolver starts honoring the qualified form, the tests still work — but if it starts rejecting unqualified cross-module references, both files break.
Build / test / profile
make # build ./lisp
make test # build + run lisp_test (72 in-process probes)
make demo # cat each test_*.lisp through ./lisp
make clean
Demo files are pipe-driven: cat test_arith.lisp | ./lisp.
Valgrind:
valgrind --error-exitcode=2 ./lisp < test_huge.lisp
valgrind --tool=massif --pages-as-heap=yes ./lisp < test_huge.lisp
The memcheck leak counter reads "0 allocs / 0 frees" because
rt_alloc is mmap, not malloc. Memcheck still catches uninit
reads, bad accesses, and stack issues. For real heap-shape analysis
use massif with --pages-as-heap=yes.