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.
Surfaced via examples/lisp, which had to work around the following in
source. Each lowering now matches cstage on the same shape.
- cgassign / cgdot: two-level field through a non-pointer sub-struct.
`(*L).cur.kind = k` (cur a struct-by-value field of L) silently
dropped the store; the corresponding read fell into the SB-symbol
fallback and the linker reported `undefined reference to kind`. The
two new branches resolve outer-field offset + inner-field offset
and emit a single direct store/load at the combined slot, both for
T-by-value and *T-base shapes.
- cgdot: `xs[i].field` chains the trailing field load through the
N_INDEX result for [N]T / []T / *T element-of-struct-ptr. The
cgforrange loop variable now carries the elem tnode so the same
fast path covers `for (let x .. xs) { x.field }`.
- cgindex / cgassign: top-level `[N]T` array and `*T` pointer used
as an index base. cgindex now emits LEAQ name(SB) (array) or
MOVQ name(SB) (pointer) with the correct element scaling; without
this the fallback emitted neither base and walked off the saved
BP slot. Adds letvartnode() helper, an N_TARRAY branch to
letemitsize so the array shows up in c.lets, and an N_TARRAY
initialiser path in emitletdataw that lays the literal bytes into
DATAW.
- cglet / scanlocals: infer the local's tnode for an unannotated
`let x = f()` / `let x = f()?`. inferletcalltype() reads the
callee's declared return; `?` and `!` strip to the success variant
so a tagged-union let allocates the full 24B slot and the
struct-field dispatch in cgdot/cgassign sees the right type.
letslotsize now defers to slotsize on the inferred type.
- slotsize: follow type aliases for tagged-union variants. With
`type parserr = !str;`, the variant slot was 8B instead of the
required 16B; the tagged let stomped on the next slot at the
AX/DX/CX spill.
- cgreturn: tagged-union return forwarding. `return f();` where f
also returns a tagged union now passes the (tag, payload1,
payload2) triple through unchanged instead of re-wrapping it.
- cgreturn / cglet / taggedvariantindex: dispatch by variant name
with module-qualified-vs-bare matching, and recognise N_STRUCTLIT
as the variant tag for `return eof{};`. cgexpr default emits
`MOVQ $0, AX` so the surrounding return shuffle isn't left with
a stale AX.
- isstrtype / nodeisstr: resolve through `!T` aliases. `parserr =
!str` was not propagating the str-shape to the rhs check and the
MOVQ BX,CX shuffle was being dropped from str-typed local
returns.
- exprfloatkind: recognise `p.field` as f64/f32 when the struct
field is so declared, so `v.fval: i64` lowers to CVTTSD2SI on X0.
- cgassign: str field on a direct struct local writes both halves.
`L.src = s;` previously dropped s.len.
- cgcall: pop into the int reg window only up to 6 (DI..R9); rest
stays on the stack and the caller emits ADDQ to clean up.
cgfnparams accepts >6-arg signatures by registering the overflow
params at positive BP offsets (16+8*k(BP)), no spill instruction
emitted.
All 26 harness tests pass; bootstrap reaches a byte-stable fixed
point at ww3 == ww4.
vstr now copies the input bytes into the trans arena and promote_v
does the same into perm at the top-level boundary. STR cells used
to borrow the lexer's input slice; the REPL's buf-shift between
forms overwrote those bytes, so a top-level (define x "...") would
print garbage after the next read. Mirror of Hare's strings::dup,
arena-routed so the bytes share the cell's lifetime.
Parser learns dotted-pair literals: '(a b . c) splices the tail
into the cdr of the last cons. A bare '.' inside a list lexes as
tkind.DOT; outside a list it's still a parser error. Pre-fix the
'.' lexed as a one-byte SYM, producing a 3-element proper list.
Drop the unused args_to_slice — eval inlines on purpose (the
wwstage cgen drops slice.len through a tagged-union return).
Tests: 18 new probes (str-survives-3-defines, str-from-lambda,
dotted-pair walk + error edges) + a check_str helper. 101/101.
cgexpr leaves float results in X0, not AX, but the struct-field paths
emitted MOVQ AX,off(BX) and MOVQ off(BX),AX — so every store wrote
garbage and every load read garbage, except by accidental register
survival across an unrelated call. examples/lisp only worked because
parsef's X0 happened to live across the broken MOVQ shuffle into
vfloat; any inserted f64 op between them would silently corrupt.
Wire MOVSD/MOVSS X0,… (and the matching loads) into eight field
sites on both compilers: alloc(T{...}), p.x = v through local/ptr/
global, chained r.sub.x = v, *p = v for *f64, let v: T = T{...},
base.x reads, *T.x reads, and chained a.b.c.x reads.
83/83 lisp_test probes still pass; bootstrap reaches a byte-stable
fixed point at ww3 == ww4.
Two bump arenas. arena_reset_trans() runs between top-level forms;
top-level define / set! deep-copy the bound value graph into perm
via Cheney-style forwarding (pin = -1 + stashed fwd pointer in
.car/.val) so no perm cell ever points into trans. Args slice in
eval's apply path also gets explicit os.free per dispatch — without
that the rt_ensure page-per-call leak dominated and masked the
reset. test_huge peaks at ~2.6 MB under massif --pages-as-heap=yes,
down from ~525 MB pre-arena (~200x).
Replaces rt_alloc-per-cell (one 4 KiB mmap each) with arena_alloc
over 64 KiB chunks. test_huge peak under massif --pages-as-heap=yes
drops from ~525 MB to ~253 MB. Same lifetime semantics; remaining
bulk is per-call append() in eval's arg slice (rt_ensure still
mmaps page-per-call).
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.
Exercises match/yield/?/!/alloc/free/slice/tagged-union end-to-end:
the @symbol FFI binds initscr/mvaddch/init_pair/getch/napms; setup
returns (*void | initerr) propagated via `?`; main unwraps the clock
via `!`; dispatch is a nested match-as-expression that yields a
bool; key handlers fold into switch/enum (q/space) and (i32 |
speederr | void) for 1..4 + the '0' error overlay.
No definite or indirect leaks under valgrind (the only "possibly
lost" / "still reachable" bytes are libncurses's process-lifetime
terminfo caches, freed only with --with-leaks).
Three gaps in the wwstage cgen relative to C w6c, plus a matching
C-side bug surfaced along the way.
cgmatch (selfhost) handles non-ident scrutinees: `match (foo())` now
spills the AX:DX:CX return triple into a 24B `@match_spill` slot
rather than reading garbage off BP+0. scanlocals counts the slot so
the prologue SUBQ stays in sync. For N_CALL we recover the return
type via fnretlookup so nullable dispatch picks the pointer-vs-null
discriminator. Mirrors @match_spill in cmd/w6c/cgen.c N_MATCH.
cgcall (selfhost) special-cases `alloc(structlit{...})`: lower to
rt_alloc(totsize) + per-field MOV* at the struct's field offsets,
mirroring cmd/w6c/cgen.c's existing path. Previously the structlit
fell into pushargsrev and produced wrong code.
check.ww's N_MCASE branch now pushes a fresh scope around each arm
body. Without this, `case let e: str` inside a fn with an outer
`let e: *T` collided with scopedefine's same-scope dedup, the inner
binding silently dropped, and references to `e` inside the arm
resolved through the outer type.
Both cgens save/restore the locals head around case bodies so arm
binds (and nested arm-body lets) don't leak past the arm — code
after the match resolves names back through the outer scope.
w6c gains `local_alloc`: same as `localoff` minus the dedup. N_MATCH
case-bind allocation switches to it. Previously `let e: *T` (8B)
shadowed by `case let e: str` (16B) reused the outer 8B slot and the
inner str.len store overflowed into the saved BP, segfaulting on
return.
Tests 26/26.
Mirror of 548547a in the wwstage cgen.
cgexpr learns N_SLICE: `base[lo:hi]` leaves (AX=ptr, BX=len, CX=cap)
so callers (return, arg push, reassignment, let init for fn-returning-
slice / slice-ident) share one triple ABI. Without this, the existing
let-init that just forwards (AX,BX,CX) to the slot was silently storing
junk on a base[lo:hi] rhs.
cgassign gains a slice branch parallel to str: for a local slice ident
lhs, store all three halves to off+0/+8/+16; for a slice global, stash
CX into DI before LEAQ-ing the symbol address into CX (CX is both the
incoming cap and the LEAQ scratch), then store AX/BX/DI at +0/+8/+16.
N_IDENT slice-local triple load was already in place from the earlier
selfhost port; only the cgexpr and cgassign halves needed adding.
Byte-identical to C w6c on the corpus — make test 26/26, 994_w6c_ww
passes on 10 inputs, 990_selfhost + 995_self_rebuild reach fixed point.
Lexer: `lexnum` now parses the digit/exponent tail into an f64 via a
new `parsef64` (decimal-only, integer-arith driver + pow-10 multiply,
no strtod). The IEEE bits are also stashed in tok.uval via pointer
reinterpret so cgen consumers stay integer-only.
Parser: TK_FLOAT → N_FLOATLIT, carrying both fval and uval. Parser
state grows curfval to plumb the lexer's f64 through refill.
cgen:
- cgfloatlit reads n.uval and materialises X0 via the standard
MOVQ-PUSHQ-MOVSD-ADDQ trampoline.
- cglet, cgident, cgassign learn float-typed branches: MOVSS/MOVSD
for locals; LEAQ-indirect MOVSS/MOVSD for globals.
- cgbin handles ADDSD/SUBSD/MULSD/DIVSD (+ SS variants) and
UCOMISD/UCOMISS-based comparisons. cgun handles float negate
via the `0 - X0` shape C cgen uses.
- cgcast routes int↔float and f32↔f64 through CVTSI2SD/CVTTSD2SI/
CVTSD2SS/CVTSS2SD and their SS twins.
- cgcall + pushargsrev push float args via SUBQ+MOVSD and pop into
the X0..X7 stream, tracked by a per-class counter alongside the
int DI..R9 stream. cgfnparams loads float params from the same
stream.
- emitletdataw bakes FLOATLIT init bits into DATAW (4B for f32,
8B for f64).
Tests: smoke programs (literal init, reassign, arithmetic, fn args/
returns, casts) produce byte-identical asm through `w6c` and
`wwdump_ww -c`, and the resulting binary exits with the same value
whether compiled by the C or wwstage toolchain. Full `make test` is
26/26 and `make bootstrap` still reaches its byte-identical
ww2==ww3==ww4 fixed point.
.data now lives at the end of the dyn-path R+W PT_LOAD, just after
.dynamic. The single segment covers .got.plt + .dynamic + .data; its
filesz drops trailing zeros (BSS) while memsz spans the full extent.
Relocation moves from main.c into each emit function so the static
and dynamic paths use their own data_va — text→data refs land on the
right VA regardless of path. Removes the early-error in dynout.c
that previously refused any .data with -l/-L.
Tests: 810_dyn gains two new dyn+.data fixtures (mutable read+write
of an i32, plus a zero-init i64 verifying the BSS scan still produces
a valid p_filesz<p_memsz under the shared segment).
Scan the consolidated .data buffer (post-relocation) for trailing zero
bytes; set the R+W PT_LOAD's p_filesz to exclude them while p_memsz
covers the full region. The loader zero-fills the gap, so behaviour is
unchanged. Saves up to a page per binary on programs whose globals are
zero-init.
Mirrored in selfhost/cmd/w6l/out.ww so test 992's byte-identity diff
still holds. Dynamic-link path is untouched — it still errors on any
mutable global; that's the next feature.
N_IDENT for a slice local now loads (AX=ptr, BX=len, CX=cap), matching
the existing global-slice load.
cgexpr learns N_SLICE: `base[lo:hi]` leaves the same triple in
registers, so callers (return, arg push, reassignment) all share
one shape. The let-init's pre-existing N_SLICE direct-store path
stays as a specialisation; the new generic slice let-init catches
fn-returning-slice and slice-ident initialisers.
N_ASSIGN gains a TY_SLICE branch parallel to TY_STR: store all
three halves to the local slot or, for globals, stash CX into DI
before LEAQ-ing the address (CX is both the new cap and the
address scratch).
Field write extends the existing TY_TAGGED branch with an is_global
arm: LEAQ name(SB),CX after cgexpr (no AX/BX clobber), then MOVQ
into slot+foff+0 (tag) and slot+foff+8 (value, plus +16 for str-
typed variants).
Field read now treats tagged fields specially — load AX=tag,
DX=val0, CX=val1 (when union >16B), mirroring the tagged-return
ABI that let-init and match dispatch already expect. Previously
the scalar-load path read 8B into AX and left DX/CX with junk,
which silently broke local tagged-field reads too.
f32 → 4B slot, f64 → 8B. C cgen bakes the FLOATLIT bit pattern into
DATAW directly; selfhost emits zero-init only (its parser doesn't
lex N_FLOATLIT yet). Read/write goes LEAQ name(SB),CX + MOVSS/MOVSD
since w6a has no D_EXTERN operand form for SSE moves.
Bring the wwstage toolchain to parity with C-side DATAW / DATAR /
.data / .rela.data support. With this, w6c_ww + w6a_ww + w6l_ww can
compile, assemble and link `let g: str = "lit";` (and the scalar /
str / slice / struct globals that landed earlier) end-to-end, with
output that's byte-identical to the C-side pipeline.
w6a (types.ww / parse.ww / asm.ww / obj.ww):
- A_DATAW + A_DATAR opcodes; parser learns `name+disp(SB)`;
A_DATAR records an R_X86_64_64 reloc in .data via the new
addrelocdata helper; areloc gains a `section` flag and asym
an `isdata` flag; obj.ww splits relocs into .rela.text /
.rela.data, emits .data PROGBITS + .rela.data conditionally,
and shuffles section indices the same way cmd/w6a/obj.c does
so byte output stays identical when no DATAW/DATAR are used.
w6l (sym.ww / obj.ww / pass.ww / out.ww / dynout.ww / main.ww):
- lrel grows `section`; lsym grows `indata`; lobj tracks
dataoff / datasize; lnk grows combined .data buffer;
- obj.ww loads .data and .rela.data, registers data symbols
with indata=1 and val shifted by the input's data_off, and
the archive scanner includes both .text and .data globals;
- pass.ww adds R_X86_64_64 (patch 8 bytes in .text or .data
with sym_va + addend); relocate's signature becomes
(textva, datava);
- out.ww emits a second PT_LOAD (R+W) when datalen > 0, with
.data at the page-aligned offset after .text;
- dynout.ww refuses .data + -l/-L cleanly (matches the C-side
error message);
- main.ww computes text_va / data_va and passes both to
relocate.
wcc cgen (cgen.ww / cgendecl.ww):
- letpreintern walks top-level str-lets and interns the strlit
BEFORE emitdatasection emits its DATA row, so emitletdataw
can later look up the same label;
- emitletdataw's 16B branch detects non-empty strlit init and
emits the 8-zero + 8-LE-len DATAW plus a DATAR slot+0,strlit
reloc, mirroring cmd/w6c/cgen.c.
Verified: `wwdump_ww -c` byte-matches `w6c` on a `let g: str =
"hello world\n";` fixture; `w6a_ww` and `w6l_ww` produce a
binary byte-identical to the C-side pipeline that runs and
prints "hello world". Bootstrap fixed-point holds (ww2 == ww3 ==
ww4), 26/26 tests green.
Use the new DATAR mechanism so str-literal init on a top-level
mutable `let` lands in .data and links cleanly.
emit_lets, when it sees `let s: str = "lit"` (non-empty strlit),
emits:
DATAW s(SB),"<8 zero placeholder><8 LE bytes of len>"
DATAR s+0(SB),<strlit_label>(SB)
The linker patches the placeholder with the strlit's runtime VA at
program load time, so `s.ptr` reads as the real pointer and `s.len`
as the literal length. A new let_pre_intern pass scans top-level
lets ahead of emit_data so the strlit gets a DATA row in the same
.s file; running emit_lets after emit_data instead would have
flipped the (DATA strlits, DATAW lets) section order in the .s and
broken byte-identity with the wwstage cgen.
The wwstage cgen still emits the zero-init shape for str lets,
which only matters if the wwstage is asked to compile source that
uses str-literal init. None of the selfhost combined sources do
that today, so test 994 / 990 stay green. The selfhost mirror for
DATAR + DATAW + this w6c branch is a follow-up.
630_let_global gains two fixtures: a length-readback and a first-
byte readback through the patched ptr.
Unblock literal initialisers for str/slice/struct globals by wiring
an R_X86_64_64 relocation kind through both assembler and static
linker.
w6a:
- new A_DATAR directive, syntax `DATAR slot+off(SB),target(SB)`,
records an R_X86_64_64 reloc at slot+off in .data pointing at
target. The slot must be pre-defined by a prior DATAW;
- parse_operand learned the `name+disp(SB)` shape so the slot's
byte offset can be addressed explicitly;
- Areloc carries a `section` flag (0=.text / 1=.data) and obj.c
splits the reloc list into .rela.text and .rela.data, emitting
the latter conditionally with sh_info pointing at .data.
w6l:
- Lrel grows the same `section` flag; obj.c loads `.rela.data`
sections into the global reloc list with offsets shifted by
each input's data_off;
- pass.c handles R_X86_64_64: target VA is data_va+sym.val for
in_data symbols (else text_va+sym.val), addend is added, and
the 8-byte slot is patched in l->data (or l->text).
Inputs without DATAR are unaffected — bootstrap, 991 (selfhost .o
diff) and 992 (selfhost exe diff) keep their byte-identical
output. 520_datar covers the new path: asm a DATAW+DATAR pair,
verify .rela.data has exactly one R_X86_64_64 entry, link, run,
confirm the relocated pointer feeds a 5-byte write that prints
"hello".
Selfhost mirror + w6c emission for str/slice/struct literal init
land in follow-ups.
Extend top-level mutable `let` to cover structs. Same approach as
str / slice: take the field-access base through &name(SB) instead
of off(BP).
- emit_lets / emitletdataw: emit `sizeof(T)` zero bytes for any
struct global without a baked-in initialiser. Struct-literal
init is skipped → undefined symbol at link if used;
- cgdot read path: when the IDENT base's local lookup misses and
the name is a struct let, LEAQ name(SB), CX and load the field
at fi.foff(CX) with the width-aware op (MOVQ / MOVL /
MOVZBQ / MOVSXD; MOVQ pair for str fields);
- cgassign write path: parallel handling for plain `=` (incl. str
fields) and the compound ops (+=, -=) via load → push → eval
rhs → combine → store with a re-LEAQ between cgexpr clobbers.
Tagged-union fields on struct globals are unsupported in v1 — the
local path's tagged branch isn't generalised yet. Whole-struct
by-value flow through expressions remains NYI (matches the local
status). 630_let_global gains 3 fixtures (read/write, compound +=,
narrow u8 field); selfhost mirror keeps test 990 / 994 / 995 byte
identical; bootstrap fixed point holds.
Extend top-level mutable `let` to cover slices. Same shape as the
str work, with one more 8-byte field and the address holder CX
overwritten by the cap as the last load step:
- emit_lets / emitletdataw: 24-byte zero DATAW for `let v: []u8;`
(and the trivial `nil` init); no slice-literal syntax exists
so the no-init path is the only supported shape;
- cgident: LEAQ name(SB), CX → MOVQ (CX), AX → MOVQ 8(CX), BX →
MOVQ 16(CX), CX, so the slice ABI triple lands in (AX, BX, CX);
- cgdot: .cap delta 16 wired alongside .ptr / .len through the
same &name(SB) base.
Slice reassignment (`v = some_slice;`) is still unsupported — slice
values don't yet flow as a full (AX, BX, CX) triple through general
expressions even for locals — so reads/`&` are the supported surface
today. Manual fill through `(&v): *u64` continues to work.
Tests 630 (10/10), 990, 994, 995 stay green; bootstrap fixed point
holds.
Extend top-level mutable `let` to cover str. The cgen now:
- emits a 16-byte zero DATAW for `let s: str;` (and the trivial
`nil` / `""` inits); a non-empty strlit init is skipped because
a compile-time .data → .text reloc isn't supported yet, so the
user gets a clean undefined-symbol error at link;
- loads `s` as `(LEAQ s(SB), CX; MOVQ (CX), AX; MOVQ 8(CX), BX)`
so the (AX=ptr, BX=len) pair convention is preserved;
- stores via the same `&s` indirection for `s = expr;` and routes
the `.ptr` / `.len` pseudo-field N_DOT branch through it; and
- tracks the declared type on each LetVar so cgident / cgdot /
cgassign pick the right load/store shape.
Selfhost cgen mirrors all four paths byte-for-byte; test 990
(cgen-match on err.ww) and tests 994/995 (self-rebuild) stay
green. 630_let_global gains two new fixtures (`let msg: str;` +
runtime assign, plus reassign from a helper).
Slice and struct globals still NYI — same scope deferred.
Mirror the C cgen's N_IDENT load fallback. Reads of a top-level
scalar `let` now emit `MOVQ name(SB), AX` (RIP-relative) instead
of silently dropping. Truly undefined names still fall through to
the silent return, matching the pre-existing defensive behaviour;
the C side's broader unconditional fallback is intentionally not
mirrored here so typos surface as nothing-emitted rather than a
link-time stub.
Third step toward writable globals. The C cgen and its selfhost
mirror now:
- emit DATAW <name>(SB),"<8 LE bytes>" for every top-level `let`
whose type lands in the scalar set (i8..i64/u8..u64/bool/rune/
int/uint/uintptr/ptr; floats and multi-word types deferred);
- drop the "no writable .data" silent-drop guard at the N_IDENT
store path, replacing it with a RIP-relative MOVQ for `=` and
a load→combine→store sequence for the compound ops; and
- route `&name` through LEAQ name(SB) instead of dropping it.
Type aliases resolve via aliaslookup so `type counter = i32; let c:
counter = 0;` still emits a DATAW slot. Non-literal initialisers
silently skip, which surfaces as a clean undefined-symbol error if
the binding is ever referenced.
The selfhost mirror lands in the same commit because test 990
diffs the C cgen against wwdump_ww -c on err.ww (which has
top-level `let nerrors: i32 = 0; ... nerrors += 1;`). Any drift
between the two cgens makes 990 fail. Bootstrap stays at a fixed
point: ww2 == ww3 == ww4 byte-identical.
Second step toward top-level mutable `let`. The static path now loads
.data PROGBITS sections from input .o files, page-aligns them after
.text, and emits a second PT_LOAD (R+W) covering them. Relocations
targeting data symbols compute against the data VA; text→text
displacements still cancel the absolute VAs and stay correct.
Inputs without any .data keep the original single-PT_LOAD layout
byte-for-byte — 992 (selfhost w6l .o diff) and 995 (self-rebuild)
depend on that invariant.
Dynamic-link path (-l/-L) rejects .data for now with a clear error;
folding writable globals into the existing R+W segment alongside
.got.plt/.dynamic is a follow-up.
First step toward top-level mutable `let`. Adds a sibling directive to
DATA whose bytes land in a separate writable .data PROGBITS section
(SHF_ALLOC|SHF_WRITE, STT_OBJECT) instead of .text. The section is
emitted only when DATAW was used, so inputs without it produce a
byte-identical .o — tests 991 (selfhost .o diff) and 995 (self-rebuild)
keep passing unchanged.
w6l still treats data-resident syms as undefined; that's the next step.
Cascades the four enum kinds through every signature and local that
holds one of their values, then removes the type_assignable /
unify_arith relaxation that previously let bare i32 mix with the
named enum types.
Signature updates:
- kwlookup() now returns `tkind` (not i32); tokname() takes `tkind`
- accepttok / expecttok / bprec / isassignop take `tkind`
- parsearglist's closekind is `tkind`
- newtype / prim take `tykind`; scopedefine takes `skind`
- newnode / nkname take `nkind`
Struct fields:
- tok.kind is `tkind`; parser.curkind is `tkind`
- node.kind is `nkind`; node.op is `tkind`
- tinfo.kind is `tykind`; sym.skind is `skind`
Locals holding kinds across lex/parse/check/cgen are now typed with
their enum, including sentinel patterns like `let lkind: nkind =
nkind.N_NONE; if (...) lkind = tn.kind;`.
The selfhost cgen had a load-width bug exposed by this: fieldsize()
fell back to 8 bytes for any TNAME that wasn't a struct or primitive.
For a tkind-typed field that gave `MOVQ (BX), AX` instead of `MOVL`,
diverging from the C cgen on tok.kind / parser.curkind / etc. Two
fixes:
- fieldsize now consults the enum registry and returns the storage
type's size (4 for `enum i32`)
- collectenums runs before collectstructs in cgfile so the registry
is populated when registerstruct asks for field sizes
All 22 tests stay green; 990/993/995 byte-identity probes pass with
the strict typing in place.
`type tkind = enum i32 { TK_NONE = 0, TK_EOF = 1, ... TK_LAST = 86 }`
replaces the 87-line `def TK_*: i32 = N` cluster in lib/ww/lex/tok.ww.
Numeric values explicit so 990_selfhost's byte-diff against the C-side
`Tkind` enum still passes.
All ~270 reference sites in lib/ww and selfhost/cmd/{wcc,wwdump}
sed-renamed `TK_X` → `tkind.TK_X`. Struct fields (`tok.kind`,
`parser.curkind`) intentionally kept as `i32` — making them `tkind`
shifted some byte-positions in the cgen output and broke 990/993/995
byte-identity probes without an obvious win.
To make the rename non-cascading on every signature, type_assignable
and unify_arith in cmd/wcc/check+type relax to allow enum ↔ int
mixing when storage matches (a `tkind` value flows into an `i32`
slot and vice versa, no explicit cast). This deviates from Hare's
strict enum semantics; doc'd as an explicit pragmatic relaxation
for the compiler's internal enum-shaped kinds. External user code
can still get the type-safety benefit if they declare their
parameters with the enum type.
combined.ww files regenerated by ww build.
`type nr = enum i64 { READ, WRITE, OPEN, ... }`. syscall0..4 take
`num: nr` so the wrong-arg-order trap is now a compile error
(`syscall1(0i64, ...)` no longer typechecks — it has to be
`syscall1(nr.READ, ...)`).
Internal-only (callers outside os.ww never touched the constants),
so no external API change. ABI is unchanged: nr's storage is i64
and rt_syscall's RDI is unchanged.
The selfhost combined.ww files regenerate as a side effect of
`make wwstage`.
Mirrors Hare's `fs::flag` and `io::whence`:
export type flag = enum i32 {
RDONLY = 0,
WRONLY = 1,
RDWR = 2,
CREATE = 64, // 0o100
TRUNC = 512, // 0o1000
};
export type whence = enum i32 { SET = 0, CUR = 1, END = 2 };
open/tryopen/lseek signatures take the enum types (`flags: flag`,
`w: whence`) so callers get type-checked: `os.open(p, os.flag.RDONLY,
0)` is the correct shape, and `os.flag.WRONLY | os.flag.CREATE |
os.flag.TRUNC` typechecks as a `flag` via the same-named-type rule.
Callers in selfhost/cmd/{ww,w6c,w6a,w6l,wwdump} updated from
`os.O_RDONLY` etc. to `os.flag.RDONLY`. SYS_* syscall numbers kept
as `def` for now (internal-only, ABI surface, no Hare analogue in
this scope).
selfhost/test/smoke.ww keeps its standalone-compile property by
using a numeric literal (`0`, RDONLY's value) for the open flags
arg — probe 6 in 990_selfhost compiles smoke.ww with no `use`
expansion, so cross-module type refs like `os.flag.RDONLY` can't
resolve there. Untyped 0 → flag via type_isnum.
Driver-side concatenation flattens module names, but enum member
lookup keyed off the exact lhs ident — so `whence.CUR` worked while
`os.whence.CUR` fell through to w6l with `undefined main.whence`.
C side: fold TY_ENUM members in the post-cexpr cascade too, not
just the early SK_TYPE shortcut. The recursive cexpr lands the
inner N_DOT(os, whence) on the named enum type; the outer access
then folds normally.
Selfhost: cgdot now treats `N_IDENT.MEMBER` and `N_DOT.MEMBER` the
same way, keying off the leaf name. enumlookup strips a trailing
`.`-prefix from the lookup key.
Adds e2e test 700: `use os; os.whence.CUR as i32 == 1`.
w6c_ww now compiles enum end-to-end and emits byte-identical
asm to the C w6c on the new 994 corpus case (`type mode = enum u8
{ R, W, RW = R | W }; main() { return (mode.RW): i32 }`). Mechanism
mirrors the C side:
- collectenums walks every `type X = enum {...}` at file scope and
pre-resolves each member's u64 value (auto-increment from prior,
sibling-ref folding for `RDWR = READ | WRITE`).
- cgdot recognises `EnumName.MEMBER` before the local lookup and
emits MOVQ $value, AX directly.
- cgtypeassert short-circuits when either side is enum: cgexpr on
the LHS lands the value in AX with the right integer width; no
tag/unwrap.
main.combined.ww (wwdump/ + w6c/) regenerated by ww build.
`ww build` on a lib/ module drops a .combined.ww snapshot next
to the source. Only the bootstrap-frozen copies under
selfhost/cmd/*/main.combined.ww are intentionally tracked; the
lib/ ones are transient. lex.combined.ww slipped in via an
ad-hoc `git add lib/`.
Parses byte-identical to the C frontend on enum sources (verified
via `diff` of wwdump vs wwdump_ww -a on an enum-using fixture).
The selfhost side reserves the slot in the AST and TY_* enums so
later check.ww and cgen mirror work doesn't shift numeric IDs.
Codegen-side enum support (member-value folding in cgdot, enum↔int
pass-through in cgtypeassert) is deferred — current selfhost sources
don't use enum, so 990_selfhost / 995_self_rebuild stay green.
main.combined.ww in wwdump/ and w6c/ regenerated by `ww build` as
a side effect of `make wwstage`.
`type Foo = enum [intT] { NAME [= expr], ... };`. Storage defaults
to i32; members auto-increment from 0 (or last+1) when `= expr` is
omitted, and value expressions can reference earlier siblings —
enough surface for io::mode-style flag enums (`RDWR = READ | WRITE`).
`Foo.MEMBER` folds to an N_INTLIT in the checker, typed as the
named enum. Binops on enum values yield the same enum (type_eq on
the named pointer), so `mode.R | mode.W` is a `mode`. Enum ↔ int
is a reinterpret-only `as` cast — same register, no tag wrap — so
`mode.RDWR as i32` and `1 as mode` both work without runtime ops.
`is`/`?`/`!` are still tagged-union-only. CSP runtime (chan/proc)
is unchanged; only the type-system slot is touched here.
@test parsing already works under ww_ww (parseattrs lives in the
shared lib/ww/parse/decl.ww, picked up by both Cstage and wwstage),
so this is the test-side parity: 997 is 910 with `ww run` swapped
for `ww_ww run`, exercising the selfhost driver+compiler+assembler+
linker end-to-end on the same attest_pass.ww fixture. Suite is now
22 tests.
Three more structural checks from C check.c ported to selfhost,
at the AST level (no resolved tinfo).
is/as validity: e is T / e as T require e's declared type to be a
tagged union and T to name a variant. Mirrors the case-variant
check that just landed.
let init-type and return-type assignability: a new exprtype helper
infers an AST type-node for literal/ident/call/cast/?/as/is
expressions; isassignable approximates C type_assignable on the
shapes we can resolve — exact match, untyped numeric → typed
numeric, untyped nil → ptr/slice/chan/fn, variant inclusion, and
two-primitive-mismatch.
isassignable returns (ok, confident). When confident=false the
check emits no error — better to miss a real bug than fire a
false positive on a binary-op expression we can't infer. This
keeps existing selfhost code clean while still catching the
common typo cases (let x: bool = 42; return "hi" from i32 fn).
Naming: all new helpers follow Plan 9 run-together convention per
CLAUDE.md (`typeeqast`, `isassignable`, `exprtype`, ...). Earlier
work that used snake_case helpers (`case_variant_in`,
`check_match_exhaustive`, ...) got the same treatment — bulk
renamed in this commit.
Five new rows in 950_selfcheck exercise the new checks
(is-not-a-variant, two let mismatches, return mismatch, plus the
case-variant row already there).
`match (u) { case T => ... }` where T isn't a variant of u was
silently accepted by both checkers. The cgen would emit a tag
comparison against an index that never appears, leaving the arm
unreachable — wasted code that's almost always a bug or typo.
C check.c now mirrors the existing is/as rule for match arms:
each `case T` and each alt of multi-pattern `case T1 | T2` is
checked against the scrutinee's variant list via variant_present.
selfhost check.ww gets the same shape with AST-level type_eq_ast
comparison. Both checks land in the same scope-aware pass that
already runs exhaustiveness and ? subset.
New test rows in 300_check (C side) and 950_selfcheck (selfhost
side) exercise both single-pattern and multi-pattern alt typos.
The 950 driver's err_present detector picks up the new
"case: not a variant" prefix.
The selfhost checker did name resolution only — anything tagged-
union-shaped sailed through silently. The C check.c implements
three structural checks; this commit mirrors them at the AST level
in selfhost/cmd/wcc/check.ww:
1. Match exhaustiveness: every variant of the scrutinee's tagged
union must be covered by a case arm (incl. multi-pattern alts)
or a default arm. Operates on the scrutinee's declared type
(N_TTAGGED via N_IDENT's sym.decl.lhs).
2. ? subset propagation: each error variant of the operand's type
must be a variant of the enclosing fn's return type. Enclosing
return must itself be a tagged union when the operand has any
errors.
3. !-flag semantics: in flag-aware mode (any variant marked `!T`),
error subset = flagged variants. Legacy mode (no flags) =
everything-but-first. is_error_variant unifies both rules.
No tinfo / type-inference work: the checks read declared AST type
nodes directly. `resolvealias` chases N_TNAME → typedecl body to
handle aliased tagged unions. `type_eq_ast` does structural
comparison on the subset of type-expression shapes the checks
encounter (TNAME by string, TPTR/TSLICE/TCHAN recursive).
Folded into resolvewalk rather than a separate second pass, so the
checks see the same per-statement scope state as resolve. fnret is
threaded through resolvefnbody so ? can find the enclosing return.
New test/wcc/950_selfcheck.c — five rows exercising each error path
(missing variant, non-tagged enclosing, missing error subset
member, the flag-aware happy path, the flag-aware missing-error
case). Test suite now reports 21 ok.
Hare-style `@test fn check_foo() void = { ... }` now parses. The
attribute is recognised by making the args list optional in
parseattrs: `@symbol("rt_syscall")` still requires the parens;
`@test` doesn't. Same change mirrored in lib/ww/parse/decl.ww.
The runner (test/wcc/910_at_test.c) scans a fixture for
`@test fn IDENT(`, synthesises a wrapper `main()` that calls each
test fn, builds it via `ww run`, and asserts exit 0. A failing
@test would either explicitly call abort or trip a runtime trap
(div-by-zero, etc.) and the whole driver exits non-zero.
The 910_at_test target sits alongside the existing C-side test
binaries; `make test` now runs 20 tests instead of 19.
Fixture: test/wcc/data/attest_pass.ww exercises two passing tests
(simple arithmetic and a match-with-yield).
C cgen has carried defer for a while (defers[] global + reverse
walk on every return). Selfhost cgen now mirrors:
- cgen struct: deferbuf (**node, LIFO stack) + defertop counter.
- cgstmt N_DEFER: push n.lhs.
- cgreturn: rundefers() at entry — same as the C cgen pattern.
- cgfn fall-through return: rundefers() before zero-AX+RET.
DEFER_MAX = 16 matches C cgen.
Two new e2e rows: defer with an explicit `return acc;` (321 mod 256
= 65), and defer firing on an implicit void-fn fall-through (87).
Both rows verified via the wwstage cgen too.
Defer's semantics: queued exprs fire LIFO before the return expr
is evaluated, so a return that reads memory mutated by a deferred
call sees the post-defer state. Matches C cgen and Hare.