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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user