diff --git a/examples/lisp/CLAUDE.md b/examples/lisp/CLAUDE.md index 28029e06..ddb43aa2 100644 --- a/examples/lisp/CLAUDE.md +++ b/examples/lisp/CLAUDE.md @@ -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. diff --git a/examples/lisp/Makefile b/examples/lisp/Makefile index 056b3a82..2586e2fe 100644 --- a/examples/lisp/Makefile +++ b/examples/lisp/Makefile @@ -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 ` 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 diff --git a/examples/lisp/lispcore.ww b/examples/lisp/lispcore.ww index f2114ee8..930025c3 100644 --- a/examples/lisp/lispcore.ww +++ b/examples/lisp/lispcore.ww @@ -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) = {