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.