examples: lisp — drive wwstage by default; retire stale workarounds

Makefile sets WW_W6C=$(BIN)/w6c_ww so `make`, `make test`, and
`make demo` all use the ww-built backend. With the wwstage cgen
fixes in selfhost/cmd/wcc/ the demo no longer needs to dodge:

- bug #1+#2 (global addressing): symbol interner indexes
  sym_off / sym_len / sym_blob directly. No `let blob = sym_blob;`
  aliasing.
- bug #3 (chained non-pointer sub-struct field): not retired here
  (lexer.cur is still flattened) but the cgen now handles the
  shape; un-flattening is cosmetic.
- bug #4 (f64 through every boundary): vfloat writes p.fval = v
  directly; promote_v's FLOAT branch is one assign; to_f64 reads
  v.ival as f64 / v.fval directly. Drops fbuf, FVAL_OFF, copybytes.
- bug #7 (xs[i].field): builtins write xs[0].kind / xs[0].car
  directly; no `let p = xs[0];` first.

lisp_test still 101/101.

CLAUDE.md marks each historical bug as retired or still load-
bearing; #6 (slice-len in tagged-union return) and #8 (f64
compound assign) are the remaining shapes to avoid.
This commit is contained in:
2026-05-13 03:07:01 +09:00
parent 7c75dd218a
commit d9aba892f6
3 changed files with 96 additions and 175 deletions

View File

@@ -1,7 +1,7 @@
examples/lisp — tiny Lisp interpreter in pure ww. Demo program; not a examples/lisp — tiny Lisp interpreter in pure ww. Demo program; not a
production interpreter. Treat the files below as a worked example of production interpreter. Drives the wwstage cgen (`out/bin/w6c_ww`) by
"what does and doesn't lower cleanly through the wwstage cgen today", default; the Makefile sets `WW_W6C` so `make` picks the ww-built
not as a reference Lisp implementation. backend rather than the C bootstrap one.
## Layout ## Layout
@@ -9,7 +9,7 @@ not as a reference Lisp implementation.
eval, apply, printer, REPL. Everything the entry eval, apply, printer, REPL. Everything the entry
point and the test driver consume is `export`-ed. point and the test driver consume is `export`-ed.
- `lisp.ww` entry point; `use lispcore;` + `main()`. - `lisp.ww` entry point; `use lispcore;` + `main()`.
- `lisp_test.ww` in-process test driver (72 probes). Built as a - `lisp_test.ww` in-process test driver (101 probes). Built as a
standalone binary, exec'd directly — `ww test` standalone binary, exec'd directly — `ww test`
drops `-I` in single-file mode, so the Makefile drops `-I` in single-file mode, so the Makefile
runs the binary itself. runs the binary itself.
@@ -24,86 +24,66 @@ test — use the bare names.
## wwstage cgen workarounds at play ## wwstage cgen workarounds at play
Every workaround below is documented inline at its use site. The The wwstage cgen has been brought up to par on the bug classes this
shape of the bug is what matters, not the specific symptom — the demo originally exercised. The remaining workarounds and the
same bug class shows up in any new code that hits the same pattern. 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.
1. **Top-level `[N]T` arrays mis-address inside functions.** 1. **(retired) Top-level `[N]T` arrays mis-address inside functions.**
`globalarr[i] = …` lowers to `LEAQ (BP), BX` instead of `globalarr[i]` used to lower to `LEAQ (BP), BX`. cgindex/cgassign
`LEAQ globalarr(SB), BX` — the global address gets treated as a now detect top-level array idents and emit `LEAQ name(SB), BX`,
stack frame, corrupting both. Heap-allocate via `os.alloc` into a and emitletdataw lays the array bytes into DATAW.
`*T` and alias to a local at the top of each function before
indexing. See `sym_off` / `sym_blob` / `obuf`.
2. **`global_ptr[i]` mis-addresses too.** Even after switching to a 2. **(retired) `global_ptr[i]` mis-addresses.** cgindex/cgassign
pointer global, `MOVQ global_ptr(SB), BX` is replaced with detect top-level `*T` idents and emit `MOVQ name(SB), BX` with
`MOVQ (BP), BX`. Alias `let p = global_ptr;` at function entry, the correct element scaling. `lispcore`'s symbol interner is back
then `p[i]`. See every helper that touches the symbol interner. to plain `sym_blob[off + i]` style; no `let blob: *u8 = sym_blob`
aliasing.
3. **Two-level field write through a non-pointer sub-struct.** 3. **(retired) Two-level field write through a non-pointer
`L.cur.kind = k` or `L.src.ptr = buf.ptr` (where `cur`/`src` is a sub-struct.** cgassign/cgdot now handle the chained
non-pointer struct field of a struct reached through a pointer) is `(*L).cur.kind` shape both as read and write. The lexer keeps the
silently dropped — the function body emits no store. Either flattened `curkind` / `curival` / … fields for now because every
flatten the sub-struct (see `lexer.curkind`/`curival`/…) or build call site uses them; un-flattening is a stylistic improvement, not
the whole sub-struct as a local and do a single whole-struct a correctness fix.
assign (`let s: str = …; L.src = s;`).
4. **f64 through every boundary is unreliable.** The cgen routes f64 4. **(partially retired) f64 through every boundary.** Struct-field
stores via integer registers; in most paths AX gets stored where `p.fval = v` through `*T`, `*p = v` for `*f64`, and `alloc(T{…})`
X0 should have been written. sugar for f64 fields all route through X0 now (`vfloat`,
- `alloc(value{ fval = v })`: writes the kind enum (AX still `promote_v`, `to_f64` are back to direct `p.fval = …` /
holds it) instead of the f64. `*out = v.fval` form). Function-arg passing of an f64 struct-field
- `p.fval = v` through a `*T`: same — stores AX. value also works (the cgen's `exprfloatkind` now recognises
- `*p = v` for `*f64`: stores AX. `p.field` whose declared type is f64/f32).
- `func(f64_arg)` where the source is a struct field: cgen does
`MOVQ off(BX), AX` (integer load) and never puts the bits in X0.
What does work: `MOVSD X0, global(SB)` (a top-level f64 global) 5. **(retired) `alloc(value{ text = s })` writes only `s.ptr`.** Both
and `MOVSD X0, off(BP)` (a local f64 slot). The interpreter the `alloc(T{…})` builtin and the bare struct-literal init now
threads f64 through a scratch global (`fbuf`) and byte-copies emit both halves of the str. `vstr` still does manual
8 bytes wherever a `*f64` would normally suffice. See `vfloat`, `p.text = s` (semantically equivalent, no longer required).
`to_f64`, `copybytes`.
5. **`alloc(value{ text = s })` writes only `s.ptr`.** The cgen sets 6. **`(slice | E)` tagged-union returns drop `slice.len`.** Still
up `s.ptr` in AX, sets the new-pointer in BX, then needs `s.len` present. The wwstage return convention is AX=tag, DX=payload1,
in another reg — but the same BX gets clobbered by the new-pointer CX=payload2 — a 24-byte slice header doesn't fit. Inline the
reload, so the `.len` store never happens. Manual init via slice-building loop into the caller. See `eval`'s argument-eval
`p.text = s` writes both halves correctly. inline.
6. **`(slice | E)` tagged-union returns drop `slice.len`.** The 7. **(retired) `xs[i].kind` drops the trailing field load.** cgdot
wwstage return convention is AX=tag, DX=payload1, CX=payload2. A now handles N_INDEX bases. Builtins are back to
slice header is 24 bytes (ptr/len/cap); only ptr and cap come `xs[0].kind` / `xs[0].car` directly — no `let p = xs[0];` first.
through in DX/CX. The intermediate `BX = s.len` is loaded but
never moved into a return register. Inline the slice-building
loop into the caller instead of factoring it into a helper. See
`eval`'s argument-eval inline.
7. **`xs[i].kind` drops the trailing field load.** Field access on a
slice element gives back only the bytes at `&xs[i]` — the cgen
doesn't chain the dereference. Bind to a local first:
`let p = xs[i]; if (p.kind …)`. See every builtin.
8. **f64 compound assigns are mis-lowered to `acc = d` (no OP).** 8. **f64 compound assigns are mis-lowered to `acc = d` (no OP).**
`acc += f` / `acc /= f` etc. on f64 locals drop the operator. Still present. Write the explicit form: `acc = acc + f`. Integer
Write the explicit form: `acc = acc + f` / `acc = acc / f`. compound assigns work fine.
Integer compound assigns work fine, so `acc += i` on `i64`
stays as-is.
9. **`let r = call(); foreign_call(); return r?;` corrupts `r` when 9. **(retired in practice) `let r = call(); foreign_call(); return
the call returned a tagged union.** The (`tag`, `payload1`, r?;` corrupts `r`.** The wwstage cgen now spills the AX/DX/CX
`payload2`) triple sits in AX/DX/CX after the call, and the triple to the local's 24-byte slot at the assignment point,
foreign call between capture and `?`-unwrap clobbers at least one matching what cstage emits — the foreign call in between no longer
register before the cgen has spilled it to the local slot. clobbers an unspilled half. We still pre-match the union inline in
Symptom: a `(*value | rterror)` whose `rterror` carries a string eval's BUILTIN apply path so the `os.free` happens *after*
literal prints with a `str.len` of tens of thousands. Workaround: classification rather than after the unwrap.
`match` the union inline before the foreign call and let each arm
return its own typed result. See eval's BUILTIN apply path
(where we free the args slice after `apply_builtin`).
If a new function "should work but acts weird", the bug is almost If a new function "should work but acts weird", check #6 first.
always one of the above and shows up under valgrind/gdb the same
way it did the first time: silently dropped store, missing field
read, garbage payload after a tagged-union return.
## Tail-call optimization ## Tail-call optimization
@@ -144,8 +124,12 @@ env where `od?` is unbound; the tie-back only adds the self-binding.
`(fib 25)` would still grow trans linearly until the form returns. `(fib 25)` would still grow trans linearly until the form returns.
- **No bigints.** `i64` wraps silently on overflow. `(fact 21)` - **No bigints.** `i64` wraps silently on overflow. `(fact 21)`
rolls over. rolls over.
- **Float printing is fixed `%.6f`.** `1.0` prints as `1.000000`. - **Float printing is 6-digit fixed-point.** The printer delegates
Strconv has no `ftos` yet. 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 as `huge`. Graduate-to-Ryū requires `f64`↔`u64` bit-
reinterpret in cgen.
- **String escapes are accepted but not translated.** `"\n"` in - **String escapes are accepted but not translated.** `"\n"` in
source lands as the two bytes `\\` + `n`, not a newline. source lands as the two bytes `\\` + `n`, not a newline.
- **No `(load)` / file I/O builtins.** Programs come in via stdin. - **No `(load)` / file I/O builtins.** Programs come in via stdin.

View File

@@ -15,11 +15,18 @@
# (otherwise `-I.` collapses to ".", and the cgen mangles symbols as # (otherwise `-I.` collapses to ".", and the cgen mangles symbols as
# `..helper`, which then breaks the assembler). # `..helper`, which then breaks the assembler).
WW := $(shell cd ../..; pwd)/out/bin/ww # Drive the wwstage cgen (w6c_ww) by default. The C-stage cgen has
HERE := $(shell pwd) # unfixed silent-miscompilation traps that lispcore used to dodge by
# hand; with the wwstage fixes in place we can write the natural
# shape (no flattened sub-structs, no aliased global indexers, no
# `let p = xs[i];` per builtin) and lisp_test still passes.
WW := $(shell cd ../..; pwd)/out/bin/ww
W6CWW := $(shell cd ../..; pwd)/out/bin/w6c_ww
HERE := $(shell pwd)
WWENV := WW_W6C=$(W6CWW)
lisp: lisp.ww lispcore.ww lisp: lisp.ww lispcore.ww
$(WW) build lisp.ww -I $(HERE) $(WWENV) $(WW) build lisp.ww -I $(HERE)
# `ww test <file.ww>` in single-file mode discards extra args, so we # `ww test <file.ww>` in single-file mode discards extra args, so we
# can't pass `-I` through it. Build the test as a normal binary and # can't pass `-I` through it. Build the test as a normal binary and
@@ -28,7 +35,7 @@ test: lisp_test lisp
./lisp_test ./lisp_test
lisp_test: lisp_test.ww lispcore.ww lisp_test: lisp_test.ww lispcore.ww
$(WW) build lisp_test.ww -I $(HERE) $(WWENV) $(WW) build lisp_test.ww -I $(HERE)
# Demo programs in tree. `make demo` runs every test_*.lisp through # Demo programs in tree. `make demo` runs every test_*.lisp through
# the REPL; each prints its results to stdout (errors go to stderr # the REPL; each prints its results to stdout (errors go to stderr

View File

@@ -263,22 +263,10 @@ export fn vint(v: i64) *value = {
return p; return p;
}; };
// f64 → struct-field via a *T pointer is mis-lowered (stores AX instead
// of MOVSD from X0). MOVSD into a top-level f64 global works, so route
// the bits through a scratch global and copy them as bytes.
def FVAL_OFF: u64 = 16u64;
let fbuf: f64 = 0.0;
fn vfloat(v: f64) *value = { fn vfloat(v: f64) *value = {
fbuf = v;
let p: *value = arena_alloc(VALUE_SZ): *value; let p: *value = arena_alloc(VALUE_SZ): *value;
p.kind = valkind.FLOAT; p.kind = valkind.FLOAT;
let raw: *u8 = p: *u8; p.fval = v;
let dst: *u8 = raw + FVAL_OFF;
let srcp: *f64 = &fbuf;
let src: *u8 = srcp: *u8;
let i: u64 = 0u64;
for (i < 8u64) { dst[i] = src[i]; i += 1u64; };
return p; return p;
}; };
@@ -371,29 +359,20 @@ let SID_LET: i32 = 0;
let SID_BEGIN: i32 = 0; let SID_BEGIN: i32 = 0;
let SID_SETBANG: i32 = 0; let SID_SETBANG: i32 = 0;
// streqid / intern / symname all alias the global *T pointers into
// locals before indexing. The wwstage cgen mis-lowers `global[i]` when
// `global` is a pointer-typed top-level let: it reads BP+0 instead of
// `LEAQ global(SB)`. Through a local the address path is correct.
fn streqid(a: str, off: i32, len: i32) bool = { fn streqid(a: str, off: i32, len: i32) bool = {
if (a.len != len) { return false; }; if (a.len != len) { return false; };
let blob: *u8 = sym_blob;
let i: i32 = 0; let i: i32 = 0;
for (i < len) { for (i < len) {
if (a[i] != blob[off + i]) { return false; }; if (a[i] != sym_blob[off + i]) { return false; };
i += 1; i += 1;
}; };
return true; return true;
}; };
export fn intern(s: str) i32 = { export fn intern(s: str) i32 = {
let offt: *i32 = sym_off;
let lent: *i32 = sym_len;
let blob: *u8 = sym_blob;
let i: i32 = 0; let i: i32 = 0;
for (i < sym_count) { for (i < sym_count) {
if (streqid(s, offt[i], lent[i])) { return i; }; if (streqid(s, sym_off[i], sym_len[i])) { return i; };
i += 1; i += 1;
}; };
os.assert(sym_count < SYM_CAP, "sym table full"); os.assert(sym_count < SYM_CAP, "sym table full");
@@ -401,11 +380,11 @@ export fn intern(s: str) i32 = {
let off: i32 = sym_blobuse; let off: i32 = sym_blobuse;
let j: i32 = 0; let j: i32 = 0;
for (j < s.len) { for (j < s.len) {
blob[off + j] = s[j]; sym_blob[off + j] = s[j];
j += 1; j += 1;
}; };
offt[sym_count] = off; sym_off[sym_count] = off;
lent[sym_count] = s.len; sym_len[sym_count] = s.len;
let id: i32 = sym_count; let id: i32 = sym_count;
sym_count += 1; sym_count += 1;
sym_blobuse += s.len; sym_blobuse += s.len;
@@ -413,11 +392,9 @@ export fn intern(s: str) i32 = {
}; };
fn symname(id: i32) str = { fn symname(id: i32) str = {
let offt: *i32 = sym_off;
let lent: *i32 = sym_len;
let r: str; let r: str;
r.ptr = sym_blob + offt[id]; r.ptr = sym_blob + sym_off[id];
r.len = lent[id]; r.len = sym_len[id];
return r; return r;
}; };
@@ -883,15 +860,7 @@ fn promote_v(v: *value) *value = {
n.car = p.car; n.car = p.car;
n.cdr = p.cdr; n.cdr = p.cdr;
n.envp = p.envp; n.envp = p.envp;
if (p.kind == valkind.FLOAT) { if (p.kind == valkind.FLOAT) { n.fval = p.fval; };
// f64 byte copy — `n.fval = p.fval` through *value lowers
// to an integer-reg store at the f64 offset, same trap
// vfloat sidesteps via the `fbuf` scratch global.
let psrc: *u8 = (p: *u8) + FVAL_OFF;
let ndst: *u8 = (n: *u8) + FVAL_OFF;
let i: u64 = 0u64;
for (i < 8u64) { ndst[i] = psrc[i]; i += 1u64; };
};
if (p.kind == valkind.STR) { if (p.kind == valkind.STR) {
// Deep-copy the bytes into perm. vstr put them in trans, and // Deep-copy the bytes into perm. vstr put them in trans, and
// the trans reset that ends every top-level form would drop // the trans reset that ends every top-level form would drop
@@ -983,35 +952,9 @@ fn any_float(xs: []*value) bool = {
// X0 — so the caller sees garbage. An out-pointer sidesteps the // X0 — so the caller sees garbage. An out-pointer sidesteps the
// boundary entirely. // boundary entirely.
// f64 movement through the wwstage cgen is fragile: only MOVSD into a
// top-level global emits the right MOVSD. Field-of-pointer stores,
// struct-literal stores, and function-arg passing for f64 all run
// through integer registers and lose the value. We treat every
// "f64 stored at address A" as a byte-copy from a known-good source.
//
// copybytes(d, s) — pure 8-byte memcpy.
// to_f64 — INT branch routes through `fbuf` (global f64 store works
// for the CVTSI2SD-produced X0), then byte-copies; FLOAT
// branch is a direct bit-copy from `v.fval`.
fn copybytes(d: *u8, s: *u8) void = {
let i: u64 = 0u64;
for (i < 8u64) { d[i] = s[i]; i += 1u64; };
};
fn to_f64(v: *value, out: *f64) bool = { fn to_f64(v: *value, out: *f64) bool = {
let dst: *u8 = out: *u8; if (v.kind == valkind.INT) { *out = v.ival: f64; return true; };
if (v.kind == valkind.INT) { if (v.kind == valkind.FLOAT) { *out = v.fval; return true; };
fbuf = v.ival: f64;
let sp: *f64 = &fbuf;
copybytes(dst, sp: *u8);
return true;
};
if (v.kind == valkind.FLOAT) {
let raw: *u8 = v: *u8;
copybytes(dst, raw + FVAL_OFF);
return true;
};
return false; return false;
}; };
@@ -1149,22 +1092,16 @@ fn b_cons(xs: []*value) (*value | rterror) = {
return vcons(xs[0], xs[1]); return vcons(xs[0], xs[1]);
}; };
// Field access on a slice element (xs[i].kind) is mis-lowered by the
// wwstage cgen: it emits only the slice index, drops the trailing
// field load. Workaround: bind xs[i] to a local *value first.
fn b_car(xs: []*value) (*value | rterror) = { fn b_car(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "car: need 1 arg": rterror; }; if (xs.len != 1) { return "car: need 1 arg": rterror; };
let p: *value = xs[0]; if (xs[0].kind != valkind.CONS) { return "car: not a pair": rterror; };
if (p.kind != valkind.CONS) { return "car: not a pair": rterror; }; return xs[0].car;
return p.car;
}; };
fn b_cdr(xs: []*value) (*value | rterror) = { fn b_cdr(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "cdr: need 1 arg": rterror; }; if (xs.len != 1) { return "cdr: need 1 arg": rterror; };
let p: *value = xs[0]; if (xs[0].kind != valkind.CONS) { return "cdr: not a pair": rterror; };
if (p.kind != valkind.CONS) { return "cdr: not a pair": rterror; }; return xs[0].cdr;
return p.cdr;
}; };
fn b_list(xs: []*value) (*value | rterror) = { fn b_list(xs: []*value) (*value | rterror) = {
@@ -1179,47 +1116,40 @@ fn b_list(xs: []*value) (*value | rterror) = {
fn b_nullp(xs: []*value) (*value | rterror) = { fn b_nullp(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "null?: need 1 arg": rterror; }; if (xs.len != 1) { return "null?: need 1 arg": rterror; };
let p: *value = xs[0]; return vbool(xs[0].kind == valkind.NIL);
return vbool(p.kind == valkind.NIL);
}; };
fn b_pairp(xs: []*value) (*value | rterror) = { fn b_pairp(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "pair?: need 1 arg": rterror; }; if (xs.len != 1) { return "pair?: need 1 arg": rterror; };
let p: *value = xs[0]; return vbool(xs[0].kind == valkind.CONS);
return vbool(p.kind == valkind.CONS);
}; };
fn b_nump(xs: []*value) (*value | rterror) = { fn b_nump(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "number?: need 1 arg": rterror; }; if (xs.len != 1) { return "number?: need 1 arg": rterror; };
let p: *value = xs[0]; let k: valkind = xs[0].kind;
let k: valkind = p.kind;
return vbool(k == valkind.INT || k == valkind.FLOAT); return vbool(k == valkind.INT || k == valkind.FLOAT);
}; };
fn b_symp(xs: []*value) (*value | rterror) = { fn b_symp(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "symbol?: need 1 arg": rterror; }; if (xs.len != 1) { return "symbol?: need 1 arg": rterror; };
let p: *value = xs[0]; return vbool(xs[0].kind == valkind.SYM);
return vbool(p.kind == valkind.SYM);
}; };
fn b_eqp(xs: []*value) (*value | rterror) = { fn b_eqp(xs: []*value) (*value | rterror) = {
if (xs.len != 2) { return "eq?: need 2 args": rterror; }; if (xs.len != 2) { return "eq?: need 2 args": rterror; };
let a: *value = xs[0]; if (xs[0].kind != xs[1].kind) { return vbool(false); };
let b: *value = xs[1]; if (xs[0].kind == valkind.NIL) { return vbool(true); };
if (a.kind != b.kind) { return vbool(false); }; if (xs[0].kind == valkind.BOOL) { return vbool(xs[0].bval == xs[1].bval); };
if (a.kind == valkind.NIL) { return vbool(true); }; if (xs[0].kind == valkind.INT) { return vbool(xs[0].ival == xs[1].ival); };
if (a.kind == valkind.BOOL) { return vbool(a.bval == b.bval); }; if (xs[0].kind == valkind.SYM) { return vbool(xs[0].sid == xs[1].sid); };
if (a.kind == valkind.INT) { return vbool(a.ival == b.ival); };
if (a.kind == valkind.SYM) { return vbool(a.sid == b.sid); };
// Reference equality for everything else — matches eq? semantics // Reference equality for everything else — matches eq? semantics
// in classic Lisps. // in classic Lisps.
return vbool(a == b); return vbool(xs[0] == xs[1]);
}; };
fn b_not(xs: []*value) (*value | rterror) = { fn b_not(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "not: need 1 arg": rterror; }; if (xs.len != 1) { return "not: need 1 arg": rterror; };
let p: *value = xs[0]; return vbool(!truthy(xs[0]));
return vbool(!truthy(p));
}; };
fn b_print(xs: []*value) (*value | rterror) = { fn b_print(xs: []*value) (*value | rterror) = {