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
production interpreter. Treat the files below as a worked example of
"what does and doesn't lower cleanly through the wwstage cgen today",
not as a reference Lisp implementation.
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
@@ -9,7 +9,7 @@ not as a reference Lisp implementation.
eval, apply, printer, REPL. Everything the entry
point and the test driver consume is `export`-ed.
- `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`
drops `-I` in single-file mode, so the Makefile
runs the binary itself.
@@ -24,86 +24,66 @@ test — use the bare names.
## wwstage cgen workarounds at play
Every workaround below is documented inline at its use site. The
shape of the bug is what matters, not the specific symptom — the
same bug class shows up in any new code that hits the same pattern.
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.
1. **Top-level `[N]T` arrays mis-address inside functions.**
`globalarr[i] = …` lowers to `LEAQ (BP), BX` instead of
`LEAQ globalarr(SB), BX` — the global address gets treated as a
stack frame, corrupting both. Heap-allocate via `os.alloc` into a
`*T` and alias to a local at the top of each function before
indexing. See `sym_off` / `sym_blob` / `obuf`.
1. **(retired) Top-level `[N]T` arrays mis-address inside functions.**
`globalarr[i]` used to lower to `LEAQ (BP), BX`. cgindex/cgassign
now detect top-level array idents and emit `LEAQ name(SB), BX`,
and emitletdataw lays the array bytes into DATAW.
2. **`global_ptr[i]` mis-addresses too.** Even after switching to a
pointer global, `MOVQ global_ptr(SB), BX` is replaced with
`MOVQ (BP), BX`. Alias `let p = global_ptr;` at function entry,
then `p[i]`. See every helper that touches the symbol interner.
2. **(retired) `global_ptr[i]` mis-addresses.** cgindex/cgassign
detect top-level `*T` idents and emit `MOVQ name(SB), BX` with
the correct element scaling. `lispcore`'s symbol interner is back
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.**
`L.cur.kind = k` or `L.src.ptr = buf.ptr` (where `cur`/`src` is a
non-pointer struct field of a struct reached through a pointer) is
silently dropped — the function body emits no store. Either
flatten the sub-struct (see `lexer.curkind`/`curival`/…) or build
the whole sub-struct as a local and do a single whole-struct
assign (`let s: str = …; L.src = s;`).
3. **(retired) Two-level field write through a non-pointer
sub-struct.** cgassign/cgdot now handle the chained
`(*L).cur.kind` shape both as read and write. The lexer keeps the
flattened `curkind` / `curival` / … fields for now because every
call site uses them; un-flattening is a stylistic improvement, not
a correctness fix.
4. **f64 through every boundary is unreliable.** The cgen routes f64
stores via integer registers; in most paths AX gets stored where
X0 should have been written.
- `alloc(value{ fval = v })`: writes the kind enum (AX still
holds it) instead of the f64.
- `p.fval = v` through a `*T`: same — stores AX.
- `*p = v` for `*f64`: stores AX.
- `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.
4. **(partially retired) f64 through every boundary.** Struct-field
`p.fval = v` through `*T`, `*p = v` for `*f64`, and `alloc(T{…})`
sugar for f64 fields all route through X0 now (`vfloat`,
`promote_v`, `to_f64` are back to direct `p.fval = …` /
`*out = v.fval` form). Function-arg passing of an f64 struct-field
value also works (the cgen's `exprfloatkind` now recognises
`p.field` whose declared type is f64/f32).
What does work: `MOVSD X0, global(SB)` (a top-level f64 global)
and `MOVSD X0, off(BP)` (a local f64 slot). The interpreter
threads f64 through a scratch global (`fbuf`) and byte-copies
8 bytes wherever a `*f64` would normally suffice. See `vfloat`,
`to_f64`, `copybytes`.
5. **(retired) `alloc(value{ text = s })` writes only `s.ptr`.** Both
the `alloc(T{…})` builtin and the bare struct-literal init now
emit both halves of the str. `vstr` still does manual
`p.text = s` (semantically equivalent, no longer required).
5. **`alloc(value{ text = s })` writes only `s.ptr`.** The cgen sets
up `s.ptr` in AX, sets the new-pointer in BX, then needs `s.len`
in another reg — but the same BX gets clobbered by the new-pointer
reload, so the `.len` store never happens. Manual init via
`p.text = s` writes both halves correctly.
6. **`(slice | E)` tagged-union returns drop `slice.len`.** Still
present. The wwstage return convention is AX=tag, DX=payload1,
CX=payload2 — a 24-byte slice header doesn't fit. Inline the
slice-building loop into the caller. See `eval`'s argument-eval
inline.
6. **`(slice | E)` tagged-union returns drop `slice.len`.** The
wwstage return convention is AX=tag, DX=payload1, CX=payload2. A
slice header is 24 bytes (ptr/len/cap); only ptr and cap come
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.
7. **(retired) `xs[i].kind` drops the trailing field load.** cgdot
now handles N_INDEX bases. Builtins are back to
`xs[0].kind` / `xs[0].car` directly — no `let p = xs[0];` first.
8. **f64 compound assigns are mis-lowered to `acc = d` (no OP).**
`acc += f` / `acc /= f` etc. on f64 locals drop the operator.
Write the explicit form: `acc = acc + f` / `acc = acc / f`.
Integer compound assigns work fine, so `acc += i` on `i64`
stays as-is.
Still present. Write the explicit form: `acc = acc + f`. Integer
compound assigns work fine.
9. **`let r = call(); foreign_call(); return r?;` corrupts `r` when
the call returned a tagged union.** The (`tag`, `payload1`,
`payload2`) triple sits in AX/DX/CX after the call, and the
foreign call between capture and `?`-unwrap clobbers at least one
register before the cgen has spilled it to the local slot.
Symptom: a `(*value | rterror)` whose `rterror` carries a string
literal prints with a `str.len` of tens of thousands. Workaround:
`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`).
9. **(retired in practice) `let r = call(); foreign_call(); return
r?;` corrupts `r`.** 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 the `os.free` happens *after*
classification rather than after the unwrap.
If a new function "should work but acts weird", the bug is almost
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.
If a new function "should work but acts weird", check #6 first.
## 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.
- **No bigints.** `i64` wraps silently on overflow. `(fact 21)`
rolls over.
- **Float printing is fixed `%.6f`.** `1.0` prints as `1.000000`.
Strconv has no `ftos` yet.
- **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 as `huge`. Graduate-to-Ryū requires `f64`↔`u64` bit-
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.

View File

@@ -15,11 +15,18 @@
# (otherwise `-I.` collapses to ".", and the cgen mangles symbols as
# `..helper`, which then breaks the assembler).
WW := $(shell cd ../..; pwd)/out/bin/ww
HERE := $(shell pwd)
# Drive the wwstage cgen (w6c_ww) by default. The C-stage cgen has
# 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
$(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
# 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.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
# 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;
};
// 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 = {
fbuf = v;
let p: *value = arena_alloc(VALUE_SZ): *value;
p.kind = valkind.FLOAT;
let raw: *u8 = p: *u8;
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; };
p.fval = v;
return p;
};
@@ -371,29 +359,20 @@ let SID_LET: i32 = 0;
let SID_BEGIN: 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 = {
if (a.len != len) { return false; };
let blob: *u8 = sym_blob;
let i: i32 = 0;
for (i < len) {
if (a[i] != blob[off + i]) { return false; };
if (a[i] != sym_blob[off + i]) { return false; };
i += 1;
};
return true;
};
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;
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;
};
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 j: i32 = 0;
for (j < s.len) {
blob[off + j] = s[j];
sym_blob[off + j] = s[j];
j += 1;
};
offt[sym_count] = off;
lent[sym_count] = s.len;
sym_off[sym_count] = off;
sym_len[sym_count] = s.len;
let id: i32 = sym_count;
sym_count += 1;
sym_blobuse += s.len;
@@ -413,11 +392,9 @@ export fn intern(s: str) i32 = {
};
fn symname(id: i32) str = {
let offt: *i32 = sym_off;
let lent: *i32 = sym_len;
let r: str;
r.ptr = sym_blob + offt[id];
r.len = lent[id];
r.ptr = sym_blob + sym_off[id];
r.len = sym_len[id];
return r;
};
@@ -883,15 +860,7 @@ fn promote_v(v: *value) *value = {
n.car = p.car;
n.cdr = p.cdr;
n.envp = p.envp;
if (p.kind == valkind.FLOAT) {
// 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.FLOAT) { n.fval = p.fval; };
if (p.kind == valkind.STR) {
// Deep-copy the bytes into perm. vstr put them in trans, and
// 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
// 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 = {
let dst: *u8 = out: *u8;
if (v.kind == valkind.INT) {
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;
};
if (v.kind == valkind.INT) { *out = v.ival: f64; return true; };
if (v.kind == valkind.FLOAT) { *out = v.fval; return true; };
return false;
};
@@ -1149,22 +1092,16 @@ fn b_cons(xs: []*value) (*value | rterror) = {
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) = {
if (xs.len != 1) { return "car: need 1 arg": rterror; };
let p: *value = xs[0];
if (p.kind != valkind.CONS) { return "car: not a pair": rterror; };
return p.car;
if (xs[0].kind != valkind.CONS) { return "car: not a pair": rterror; };
return xs[0].car;
};
fn b_cdr(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "cdr: need 1 arg": rterror; };
let p: *value = xs[0];
if (p.kind != valkind.CONS) { return "cdr: not a pair": rterror; };
return p.cdr;
if (xs[0].kind != valkind.CONS) { return "cdr: not a pair": rterror; };
return xs[0].cdr;
};
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) = {
if (xs.len != 1) { return "null?: need 1 arg": rterror; };
let p: *value = xs[0];
return vbool(p.kind == valkind.NIL);
return vbool(xs[0].kind == valkind.NIL);
};
fn b_pairp(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "pair?: need 1 arg": rterror; };
let p: *value = xs[0];
return vbool(p.kind == valkind.CONS);
return vbool(xs[0].kind == valkind.CONS);
};
fn b_nump(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "number?: need 1 arg": rterror; };
let p: *value = xs[0];
let k: valkind = p.kind;
let k: valkind = xs[0].kind;
return vbool(k == valkind.INT || k == valkind.FLOAT);
};
fn b_symp(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "symbol?: need 1 arg": rterror; };
let p: *value = xs[0];
return vbool(p.kind == valkind.SYM);
return vbool(xs[0].kind == valkind.SYM);
};
fn b_eqp(xs: []*value) (*value | rterror) = {
if (xs.len != 2) { return "eq?: need 2 args": rterror; };
let a: *value = xs[0];
let b: *value = xs[1];
if (a.kind != b.kind) { return vbool(false); };
if (a.kind == valkind.NIL) { return vbool(true); };
if (a.kind == valkind.BOOL) { return vbool(a.bval == b.bval); };
if (a.kind == valkind.INT) { return vbool(a.ival == b.ival); };
if (a.kind == valkind.SYM) { return vbool(a.sid == b.sid); };
if (xs[0].kind != xs[1].kind) { return vbool(false); };
if (xs[0].kind == valkind.NIL) { return vbool(true); };
if (xs[0].kind == valkind.BOOL) { return vbool(xs[0].bval == xs[1].bval); };
if (xs[0].kind == valkind.INT) { return vbool(xs[0].ival == xs[1].ival); };
if (xs[0].kind == valkind.SYM) { return vbool(xs[0].sid == xs[1].sid); };
// Reference equality for everything else — matches eq? semantics
// in classic Lisps.
return vbool(a == b);
return vbool(xs[0] == xs[1]);
};
fn b_not(xs: []*value) (*value | rterror) = {
if (xs.len != 1) { return "not: need 1 arg": rterror; };
let p: *value = xs[0];
return vbool(!truthy(p));
return vbool(!truthy(xs[0]));
};
fn b_print(xs: []*value) (*value | rterror) = {