Files
ww/examples/lisp/CLAUDE.md

173 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
## Layout
- `lispcore.ww` interpreter module: types, lexer, parser, env,
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 (66 probes). Built as a
standalone binary, exec'd directly — `ww test`
drops `-I` in single-file mode, so the Makefile
runs the binary itself.
- `test_*.lisp` demo source files (`cat test_X.lisp | ./lisp`).
The lispcore.ww + lisp.ww split exists so the tests can `use` the
interpreter functions. Both files combine under the same module name
(the directory's basename, `lisp`), because `ww`'s module resolver
takes the *containing-directory* basename when finding a sibling.
Cross-module type prefixes like `lispcore.value` don't resolve in the
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.
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`.
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.
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;`).
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.
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. **`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`.** 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.
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.
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.
## Tail-call optimization
`eval` is a single `for(true)` trampoline; a tail position rewrites
`v` (current expr) and `cure` (current env) in place and `continue`s
instead of recursing. Tail positions are:
- the chosen branch of `if`
- the last form of `begin` / `let` / a lambda body
- a direct call in any of the above
A `tailed` flag flips on the first jump into a fresh scope (LET
binding, lambda body) so subsequent `define`/`set!` mutate the local
`cure` chain rather than the caller's `ein` slot — same scoping the
old recursive `run_body(_, &pe)` path gave.
Mutual recursion still doesn't work — that's a `define` tie-back
limitation, not a TCO one. Defining `evn?` before `od?` captures an
env where `od?` is unbound; the tie-back only adds the self-binding.
## Interpreter limitations (design, not bug)
- **No GC.** Every cons / value / env frame is `mmap`'d via
`rt_alloc` and never reclaimed. A long REPL session leaks until
the process exits. The test_huge demo peaks at ~595 MB under
`--pages-as-heap=yes`. Per-top-level-form arena reset would cut
this by ~100× — see the closing note in `repl()` for the hook
point. Note: `rt_alloc` is one mmap syscall per call returning a
whole 4KB page, so even tail-recursive loops are bottlenecked on
allocation, not on Lisp work — `(spin 100000 0)` runs in ~4s.
- **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.
- **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.
## Latent issues — not observed, but the surface to watch
- Other i64 compound assigns in the cgen aren't audited site-by-site.
Workaround #8 covers f64; if you add new arithmetic on integers
inside a hot loop, scan the asm.
- The interactive REPL's `shift_buf(L.curstart)` assumes `parse_expr`
always primes exactly one token of lookahead after returning success.
A future parser change that skips that prime silently eats input.
- The `define` tie-back prepends a self-binding frame onto
`bv.envp`. It assumes constructors always initialize `envp`. They
do today.
- Cross-module type names (`lispcore.value`) don't resolve; we
side-step by using bare names. If `ww`'s module resolver starts
honoring the qualified form, the tests still work — but if it
starts *rejecting* unqualified cross-module references, both
files break.
## Build / test / profile
```
make # build ./lisp
make test # build + run lisp_test (66 in-process probes)
make demo # cat each test_*.lisp through ./lisp
make clean
```
Demo files are pipe-driven: `cat test_arith.lisp | ./lisp`.
Valgrind:
```
valgrind --error-exitcode=2 ./lisp < test_huge.lisp
valgrind --tool=massif --pages-as-heap=yes ./lisp < test_huge.lisp
```
The memcheck leak counter reads "0 allocs / 0 frees" because
`rt_alloc` is `mmap`, not `malloc`. Memcheck still catches uninit
reads, bad accesses, and stack issues. For real heap-shape analysis
use massif with `--pages-as-heap=yes`.