Files
ww/examples/lisp/CLAUDE.md
Hojun-Cho ab173b095a examples: lisp — pure-ww Lisp interpreter, REPL, in-process tests
Demo program that lives entirely on lib/* and libwwrt.a — no @symbol
FFI of its own. The interpreter sits in lispcore.ww (exports for the
test driver); lisp.ww is a 3-line entry that calls lispcore.repl().

Language surface: integers, floats, symbols, strings, lists, lambdas
with closures, define / set! / if / quote / let / begin, recursion
(fact / fib / ackermann / gcd), map / filter / reduce as user code.

REPL is line-buffered: each read tries to parse one top-level form,
asks for more on "unterminated list", evaluates and prints, then
shifts consumed bytes off the front of the buffer. Lookahead-aware —
the parser primes one extra token so we shift to L.curstart, not
L.pos, otherwise the first byte of the next form gets eaten.

lisp_test.ww exec'd as a regular binary (ww test drops -I in single-
file mode); 66 probes cover arithmetic, lists, closures, recursion,
errors. test_*.lisp drive the live REPL through `make demo`.

The wwstage cgen still mis-lowers a handful of patterns at this
shape of program — top-level array indexing, global-ptr deref,
two-level field stores, f64 routing through *T, alloc(structlit{})
for f64/str fields, (slice | E) returns, xs[i].kind chains, f64
compound assigns. Each workaround is annotated at its use site;
the full taxonomy is in examples/lisp/CLAUDE.md.
2026-05-12 22:33:24 +09:00

7.1 KiB
Raw Blame History

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.

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.
  • No tail-call optimization. Recursion grows the C-side stack one frame per Lisp call. (spin 100000 0) will eventually stack- overflow even though it's tail-recursive in source.
  • 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.