From 09a336cb748e492176b7d2b4a52c452cce74766d Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Wed, 13 May 2026 16:07:18 +0900 Subject: [PATCH] =?UTF-8?q?lib+test/wcc:=20add=20memio,=20rename=20io=20st?= =?UTF-8?q?ream.ww=E2=86=92io.ww?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors Hare's memio: fixed, dynamic, dynamicfrom, buffer, string, reset, borrowedread. Caller owns the state + stream slots because w6c lacks &x.field and 32B return-by-value. Tests table-driven via parallel arrays. io.stream now exported. --- lib/io/{stream.ww => io.ww} | 2 +- lib/memio/memio.ww | 212 +++++++++++++++++++++ lib/memio/memiotest.ww | 366 ++++++++++++++++++++++++++++++++++++ test/wcc/900_stdlib.c | 2 +- test/wcc/990_selfhost.c | 2 +- 5 files changed, 581 insertions(+), 3 deletions(-) rename lib/io/{stream.ww => io.ww} (97%) create mode 100644 lib/memio/memio.ww create mode 100644 lib/memio/memiotest.ww diff --git a/lib/io/stream.ww b/lib/io/io.ww similarity index 97% rename from lib/io/stream.ww rename to lib/io/io.ww index 2653262a..62ca039e 100644 --- a/lib/io/stream.ww +++ b/lib/io/io.ww @@ -13,7 +13,7 @@ export type eof = void; // closed. NAMED-void; same shape, different tag. export type closed = void; -type stream = struct { +export type stream = struct { ctx: *void, read: fn(s: *stream, buf: []u8) (i32 | eof | closed), write: fn(s: *stream, buf: []u8) (i32 | closed), diff --git a/lib/memio/memio.ww b/lib/memio/memio.ww new file mode 100644 index 00000000..2ed5fb3e --- /dev/null +++ b/lib/memio/memio.ww @@ -0,0 +1,212 @@ +// memio — in-memory io stream. +// +// Hare's memio:: surface, drop underscores. Two flavours behind a +// single [[io.stream]]: +// +// fixed caller owns the buffer, writes stop when full. +// dynamic memio owns the buffer, writes grow it; close frees. +// +// Call shape divergence from Hare: the caller supplies both the +// memio `state` and the `io.stream` slot, by pointer. ww cgen does +// not yet implement &x.field or 32B-struct return-by-value, so the +// Hare `let s = memio::fixed(buf)` shape isn't reachable; collapse +// to a single returned struct when those land (lib/CLAUDE.md +// "graduate in one go"). +// +// let mem: memio.state; +// let s: io.stream; +// memio.fixed(&mem, &s, buf); +// io.write(&s, bytes); +// +// Subset of Hare's surface: io.stream's variants are {eof, closed}, +// so memio drops Hare's NONBLOCK flag (would need an `again` variant +// in lib/io) and string()'s utf8 validation (lib has no fromutf8 +// today). Hare's seek / copy callbacks are likewise absent: lib/io's +// stream vtable has only read/write/close slots, so memio can't wire +// a seeker or copier even if we wanted to. All three come back when +// their dependencies do. + +use io; + +// Direct rt_alloc/rt_free bindings rather than `use os;` because os +// exports read/write/close, which collide with io.read/write/close +// under the driver's flat-scope concatenation. Same pattern as +// rt/ensure.ww. +@symbol("rt_alloc") fn rtalloc(n: u64) *void; +@symbol("rt_free") fn rtfree(p: *void, n: u64) void; + +// state — memio's per-stream bookkeeping. The caller owns the slot +// and passes its address into a constructor. `ptr/len/cap` are the +// slice fields kept flat to dodge a chained-dot write through the +// state pointer (cgen doesn't store into `m.buf.ptr` reliably). +export type state = struct { + ptr: *u8, + len: i32, + cap: i32, + pos: i32, +}; + +// fixed — wire `s` over a caller-supplied buffer. Writes never grow; +// they return 0 once `pos` reaches the end of the buffer. +export fn fixed(m: *state, s: *io.stream, buf: []u8) void = { + m.ptr = buf.ptr; + m.len = buf.len; + m.cap = buf.len; + m.pos = 0; + s.ctx = m: *void; + s.read = readfn; + s.write = fixedwrite; + s.close = closenoop; +}; + +// dynamic — wire `s` with no initial buffer. Writes grow the backing +// allocation; [[io.close]] frees it. +export fn dynamic(m: *state, s: *io.stream) void = { + m.ptr = nil; + m.len = 0; + m.cap = 0; + m.pos = 0; + s.ctx = m: *void; + s.read = readfn; + s.write = dynamicwrite; + s.close = dynamicclose; +}; + +// dynamicfrom — like [[dynamic]] but seeded with an existing slice. +// Ownership of the slice transfers to the stream; [[io.close]] frees +// it. The slice must come from the runtime allocator: close calls +// rt_free with `m.cap` bytes, which is taken from `buf.cap` (the +// slice's allocated capacity), not its logical length. Passing a +// half-filled append slice (len < cap) and using only `buf.len` here +// would under-free on close. +export fn dynamicfrom(m: *state, s: *io.stream, buf: []u8) void = { + m.ptr = buf.ptr; + m.len = buf.len; + m.cap = buf.cap; + m.pos = 0; + s.ctx = m: *void; + s.read = readfn; + s.write = dynamicwrite; + s.close = dynamicclose; +}; + +// buffer — borrowed view of bytes written so far (buf[..pos]). +// Seek to the end before calling if the full buffer is wanted. +export fn buffer(m: *state) []u8 = { + let r: []u8; + r.ptr = m.ptr; + r.len = m.pos; + return r; +}; + +// string — bytes written so far, as a str view. Hare returns +// (str | utf8::invalid); ww doesn't ship utf8 validation yet, so +// this returns the unchecked view. +export fn string(m: *state) str = { + let r: str; + r.ptr = m.ptr; + r.len = m.pos; + return r; +}; + +// reset — rewind the cursor and truncate the logical content to 0. +// Backing storage is preserved; subsequent writes (dynamic) re-fill +// from the start without reallocation. +export fn reset(m: *state) void = { + m.pos = 0; + m.len = 0; +}; + +// borrowedread — return an `amt`-byte view starting at `pos` without +// copying, advancing the cursor. eof if fewer bytes are available. +export fn borrowedread(m: *state, amt: i32) ([]u8 | io.eof) = { + if (m.len - m.pos < amt) { + let e: io.eof; + return e; + }; + let r: []u8; + r.ptr = m.ptr + (m.pos: u64); + r.len = amt; + m.pos += amt; + return r; +}; + +// ---- vtable callbacks ------------------------------------------------ + +fn readfn(s: *io.stream, buf: []u8) (i32 | io.eof | io.closed) = { + let m: *state = s.ctx: *state; + if (m.pos >= m.len) { + let e: io.eof; + return e; + }; + let avail: i32 = m.len - m.pos; + let n: i32 = buf.len; + if (avail < n) { n = avail; }; + let i: i32 = 0; + for (i < n) { + buf[i] = m.ptr[m.pos + i]; + i += 1; + }; + m.pos += n; + return n; +}; + +fn fixedwrite(s: *io.stream, buf: []u8) (i32 | io.closed) = { + let m: *state = s.ctx: *state; + if (m.pos >= m.len) { return 0; }; + let space: i32 = m.len - m.pos; + let n: i32 = buf.len; + if (space < n) { n = space; }; + let i: i32 = 0; + for (i < n) { + m.ptr[m.pos + i] = buf[i]; + i += 1; + }; + m.pos += n; + return n; +}; + +fn dynamicwrite(s: *io.stream, buf: []u8) (i32 | io.closed) = { + let m: *state = s.ctx: *state; + let need: i32 = m.pos + buf.len; + if (need > m.cap) { grow(m, need); }; + let i: i32 = 0; + for (i < buf.len) { + m.ptr[m.pos + i] = buf[i]; + i += 1; + }; + m.pos += buf.len; + if (m.pos > m.len) { m.len = m.pos; }; + return buf.len; +}; + +fn dynamicclose(s: *io.stream) (void | io.closed) = { + let m: *state = s.ctx: *state; + if (m.cap > 0) { rtfree(m.ptr: *void, m.cap: u64); }; + m.ptr = nil; + m.len = 0; + m.cap = 0; + m.pos = 0; + return; +}; + +fn closenoop(s: *io.stream) (void | io.closed) = { + return; +}; + +// Double-and-copy growth. Initial bump from 0 lands at 8 to amortise +// small write bursts without a tail of reallocs. +fn grow(m: *state, need: i32) void = { + let newcap: i32 = m.cap; + if (newcap < 8) { newcap = 8; }; + for (newcap < need) { newcap *= 2; }; + let nbuf: *u8 = rtalloc(newcap: u64): *u8; + let i: i32 = 0; + for (i < m.len) { + nbuf[i] = m.ptr[i]; + i += 1; + }; + if (m.cap > 0) { rtfree(m.ptr: *void, m.cap: u64); }; + m.ptr = nbuf; + m.cap = newcap; +}; diff --git a/lib/memio/memiotest.ww b/lib/memio/memiotest.ww new file mode 100644 index 00000000..34dbcab1 --- /dev/null +++ b/lib/memio/memiotest.ww @@ -0,0 +1,366 @@ +// memiotest — exercises lib/memio. Run with `out/bin/ww run lib/memio/memiotest.ww`. +// +// Every @test enumerates parallel `[N]T` arrays of inputs and +// expectations, then iterates one body across them. Parallel arrays +// (rather than `[N]struct{...}`) sidestep the cstage cgen's chained +// `arr[i].field` store gap (task #6). + +use bytes; +use io; +use memio; + +// Direct exit(2) binding rather than `use os;` — os exports +// read/write/close, which collide with io.read/write/close under +// the driver's flat-scope concat (task #7). +@symbol("rt_syscall") fn syscall1ww(num: i64, a: i64) i64; +fn doexit(code: i32) void = { + syscall1ww(60i64, code: i64); +}; + +// signalled — bumped by main before each test so a failing exit code +// pinpoints the offending case. +let signalled: i32 = 0; + +fn fail() void = { doexit(signalled + 10); }; + +fn putstr(s: str, into: []u8, off: i32) i32 = { + let i: i32 = 0; + for (i < s.len) { + into[off + i] = s[i]; + i += 1; + }; + return off + s.len; +}; + +// ---- fixedread: read sizes drive partial / full / eof outcomes ---------- + +@test fn fixedread() void = { + let arr: [8]u8; + arr[0] = 1u8; arr[1] = 2u8; arr[2] = 3u8; arr[3] = 4u8; + arr[4] = 5u8; arr[5] = 6u8; arr[6] = 7u8; arr[7] = 8u8; + + // (request, wantn, wantfirst, wantlast, weof) + // 5 rows: under-remaining, full slot, last-byte-only, post-end, 0-len-at-end. + let req: [5]i32; + let wantn: [5]i32; + let wantf: [5]u8; + let wantl: [5]u8; + let weof: [5]i32; + req[0]=3; wantn[0]=3; wantf[0]=1u8; wantl[0]=3u8; weof[0]=0; + req[1]=4; wantn[1]=4; wantf[1]=4u8; wantl[1]=7u8; weof[1]=0; + req[2]=5; wantn[2]=1; wantf[2]=8u8; wantl[2]=8u8; weof[2]=0; + req[3]=1; wantn[3]=0; wantf[3]=0u8; wantl[3]=0u8; weof[3]=1; + req[4]=0; wantn[4]=0; wantf[4]=0u8; wantl[4]=0u8; weof[4]=1; + + let mem: memio.state; + let s: io.stream; + memio.fixed(&mem, &s, arr[0:8]); + + let out: [8]u8; + let i: i32 = 0; + for (i < 5) { + // Clear out so spot checks read real data, not stale bytes. + let k: i32 = 0; + for (k < 8) { out[k] = 0u8; k += 1; }; + + let r: (i32 | io.eof | io.closed) = io.read(&s, out[0:req[i]]); + match (r) { + case let n: i32 => { + if (weof[i] != 0) { fail(); }; + if (n != wantn[i]) { fail(); }; + if (n > 0 && out[0] != wantf[i]) { fail(); }; + if (n > 0 && out[n - 1] != wantl[i]) { fail(); }; + }; + case io.eof => { if (weof[i] == 0) { fail(); }; }; + case io.closed => fail(); + }; + i += 1; + }; + + // closenoop on a fixed stream returns void. + let c: (void | io.closed) = io.close(&s); + match (c) { case void => {}; case io.closed => fail(); }; +}; + +// ---- fixedwritecases: full-fit / exact-fill / partial / overflow ------------- + +@test fn fixedwritecases() void = { + let dst: [16]u8; + let mem: memio.state; + let s: io.stream; + memio.fixed(&mem, &s, dst[0:16]); + + // One flat source; rows index into it via (off, len). Sequence: + // "hello " (6), "world!!" (7), "XXXXXXX" (7), "Y" (1). + let src: [32]u8; + let _: i32 = putstr("hello world!!XXXXXXXY", src[0:32], 0); + + // (srcoff, inlen, wantn) — wantn diverges from inlen on the + // partial and overflow rows. + let off: [4]i32; + let ln: [4]i32; + let wantn: [4]i32; + off[0]=0; ln[0]=6; wantn[0]=6; // full fit + off[1]=6; ln[1]=7; wantn[1]=7; // exact-fills cap (pos=13) + off[2]=13; ln[2]=7; wantn[2]=3; // partial: 3 free + off[3]=20; ln[3]=1; wantn[3]=0; // overflow: 0 free → 0 + + let i: i32 = 0; + for (i < 4) { + let lo: i32 = off[i]; + let hi: i32 = lo + ln[i]; + let r: (i32 | io.closed) = io.write(&s, src[lo:hi]); + match (r) { + case let n: i32 => { if (n != wantn[i]) { fail(); }; }; + case io.closed => fail(); + }; + i += 1; + }; + + let want: [16]u8; + let _: i32 = putstr("hello world!!XXX", want[0:16], 0); + if (!bytes.equal(dst[0:16], want[0:16])) { fail(); }; + + // 0-byte write is always 0 with no side effects. + let r0: (i32 | io.closed) = io.write(&s, src[0:0]); + match (r0) { + case let n: i32 => { if (n != 0) { fail(); }; }; + case io.closed => fail(); + }; + + let c: (void | io.closed) = io.close(&s); + match (c) { case void => {}; case io.closed => fail(); }; +}; + +// ---- dynamicgrow: every cap doubling exercised -------------------------- + +// Drive grow 0 → 8 → 16 → 32 by writing sized chunks. Verify +// accumulated `pos` after each step. +@test fn dynamicgrow() void = { + let mem: memio.state; + let s: io.stream; + memio.dynamic(&mem, &s); + + let src: [32]u8; + let _: i32 = putstr("abcdefghijklmnopqrstuvwxyz012345", src[0:32], 0); + + // (chunkn, totaln) — cap transitions: 0→8 at row 0; 8→16 at row 2; + // 16→32 at row 4. + let chunk: [5]i32; + let total: [5]i32; + chunk[0]=3; total[0]=3; + chunk[1]=5; total[1]=8; + chunk[2]=1; total[2]=9; + chunk[3]=7; total[3]=16; + chunk[4]=10; total[4]=26; + + let off: i32 = 0; + let i: i32 = 0; + for (i < 5) { + let lo: i32 = off; + let hi: i32 = lo + chunk[i]; + let r: (i32 | io.closed) = io.write(&s, src[lo:hi]); + match (r) { + case let n: i32 => { if (n != chunk[i]) { fail(); }; }; + case io.closed => fail(); + }; + if (memio.buffer(&mem).len != total[i]) { fail(); }; + off += chunk[i]; + i += 1; + }; + + if (!bytes.equal(memio.buffer(&mem), src[0:26])) { fail(); }; + + let c: (void | io.closed) = io.close(&s); + match (c) { case void => {}; case io.closed => fail(); }; +}; + +// ---- dynamicreset: write / reset / write cycles ------------------------- + +// op=0 writes `ln` bytes from a rolling source; op=1 resets and ignores +// ln. After each row the accumulated len must equal `want`. +@test fn dynamicreset() void = { + let mem: memio.state; + let s: io.stream; + memio.dynamic(&mem, &s); + + let src: [16]u8; + src[0]=10u8; src[1]=20u8; src[2]=30u8; src[3]=40u8; + src[4]=50u8; src[5]=60u8; src[6]=70u8; src[7]=80u8; + + let op: [6]i32; + let ln: [6]i32; + let want: [6]i32; + op[0]=0; ln[0]=5; want[0]=5; // initial write + op[1]=1; ln[1]=0; want[1]=0; // reset (after writes) + op[2]=0; ln[2]=3; want[2]=3; // refill from 0 + op[3]=0; ln[3]=2; want[3]=5; // continue + op[4]=1; ln[4]=0; want[4]=0; // reset (after partial) + op[5]=0; ln[5]=8; want[5]=8; // larger refill + + let off: i32 = 0; + let i: i32 = 0; + for (i < 6) { + if (op[i] == 1) { + memio.reset(&mem); + off = 0; + } else { + let lo: i32 = off; + let hi: i32 = lo + ln[i]; + let r: (i32 | io.closed) = io.write(&s, src[lo:hi]); + match (r) { + case let n: i32 => { if (n != ln[i]) { fail(); }; }; + case io.closed => fail(); + }; + off += ln[i]; + }; + if (memio.buffer(&mem).len != want[i]) { fail(); }; + i += 1; + }; + + if (!bytes.equal(memio.buffer(&mem), src[0:8])) { fail(); }; + + let c: (void | io.closed) = io.close(&s); + match (c) { case void => {}; case io.closed => fail(); }; +}; + +// ---- borrowedread: under / exact / over / 0-byte ------------------------ + +@test fn borrowedreadcases() void = { + let arr: [6]u8; + arr[0]=0u8; arr[1]=1u8; arr[2]=2u8; arr[3]=3u8; arr[4]=4u8; arr[5]=5u8; + + let mem: memio.state; + let s: io.stream; + memio.fixed(&mem, &s, arr[0:6]); + + // (amt, wantfirst, wantlast, weof) + let amt: [4]i32; + let wf: [4]u8; + let wl: [4]u8; + let weof: [4]i32; + amt[0]=4; wf[0]=0u8; wl[0]=3u8; weof[0]=0; // under remaining + amt[1]=2; wf[1]=4u8; wl[1]=5u8; weof[1]=0; // exact remaining + amt[2]=1; wf[2]=0u8; wl[2]=0u8; weof[2]=1; // past end → eof + amt[3]=0; wf[3]=0u8; wl[3]=0u8; weof[3]=0; // 0-byte view always ok + + let i: i32 = 0; + for (i < 4) { + let r: ([]u8 | io.eof) = memio.borrowedread(&mem, amt[i]); + match (r) { + case let v: []u8 => { + if (weof[i] != 0) { fail(); }; + if (v.len != amt[i]) { fail(); }; + if (v.len > 0 && v[0] != wf[i]) { fail(); }; + if (v.len > 0 && v[v.len - 1] != wl[i]) { fail(); }; + }; + case io.eof => { if (weof[i] == 0) { fail(); }; }; + }; + i += 1; + }; +}; + +// ---- stringview: len + endpoints track pos across appends --------------- + +@test fn stringview() void = { + let mem: memio.state; + let s: io.stream; + memio.dynamic(&mem, &s); + + if (memio.string(&mem).len != 0) { fail(); }; + + let src: [32]u8; + let _: i32 = putstr("hello world!!", src[0:32], 0); + + // (srcoff, inlen, wantacc, wantfirst, wantlast) + let off: [3]i32; + let ln: [3]i32; + let want: [3]i32; + let wf: [3]u8; + let wl: [3]u8; + off[0]=0; ln[0]=5; want[0]=5; wf[0]=104u8; wl[0]=111u8; // "hello" h..o + off[1]=5; ln[1]=6; want[1]=11; wf[1]=104u8; wl[1]=100u8; // +" world" h..d + off[2]=11; ln[2]=2; want[2]=13; wf[2]=104u8; wl[2]=33u8; // +"!!" h..! + + let i: i32 = 0; + for (i < 3) { + let lo: i32 = off[i]; + let hi: i32 = lo + ln[i]; + let r: (i32 | io.closed) = io.write(&s, src[lo:hi]); + match (r) { case let n: i32 => {}; case io.closed => fail(); }; + let v: str = memio.string(&mem); + if (v.len != want[i]) { fail(); }; + if (v[0] != wf[i]) { fail(); }; + if (v[v.len - 1] != wl[i]) { fail(); }; + i += 1; + }; + + let c: (void | io.closed) = io.close(&s); + match (c) { case void => {}; case io.closed => fail(); }; +}; + +// ---- dynamicfromseed: ownership-transferred seed, read then write ------- + +@test fn dynamicfromseed() void = { + // Build a heap-allocated seed via append (rt_ensure path), then + // hand ownership to memio. dynamicclose then frees `seed.cap` + // bytes — the regression that motivated the `m.cap = buf.cap` + // fix in lib/memio/memio.ww. + let seed: []u8; + seed.ptr = nil; seed.len = 0; seed.cap = 0; + append(seed, 100u8, 101u8, 102u8, 103u8); + + let mem: memio.state; + let s: io.stream; + memio.dynamicfrom(&mem, &s, seed); + + // (readn, wantbyte, weof) + let rn: [5]i32; + let want: [5]u8; + let weof: [5]i32; + rn[0]=1; want[0]=100u8; weof[0]=0; + rn[1]=1; want[1]=101u8; weof[1]=0; + rn[2]=1; want[2]=102u8; weof[2]=0; + rn[3]=1; want[3]=103u8; weof[3]=0; + rn[4]=1; want[4]=0u8; weof[4]=1; // past seed end → eof + + let out: [4]u8; + let i: i32 = 0; + for (i < 5) { + let r: (i32 | io.eof | io.closed) = io.read(&s, out[0:rn[i]]); + match (r) { + case let n: i32 => { + if (weof[i] != 0) { fail(); }; + if (n != rn[i]) { fail(); }; + if (out[0] != want[i]) { fail(); }; + }; + case io.eof => { if (weof[i] == 0) { fail(); }; }; + case io.closed => fail(); + }; + i += 1; + }; + + // Now write extends past the seed; grow path runs and close + // must free the (post-grow) cap, not the seed cap. + let extra: [4]u8; + extra[0]=200u8; extra[1]=201u8; extra[2]=202u8; extra[3]=203u8; + let w: (i32 | io.closed) = io.write(&s, extra[0:4]); + match (w) { + case let n: i32 => { if (n != 4) { fail(); }; }; + case io.closed => fail(); + }; + + let c: (void | io.closed) = io.close(&s); + match (c) { case void => {}; case io.closed => fail(); }; +}; + +export fn main() i32 = { + signalled = 1; fixedread(); + signalled = 2; fixedwritecases(); + signalled = 3; dynamicgrow(); + signalled = 4; dynamicreset(); + signalled = 5; borrowedreadcases(); + signalled = 6; stringview(); + signalled = 7; dynamicfromseed(); + return 0; +}; diff --git a/test/wcc/900_stdlib.c b/test/wcc/900_stdlib.c index 9aeae980..bf4c7bf3 100644 --- a/test/wcc/900_stdlib.c +++ b/test/wcc/900_stdlib.c @@ -14,7 +14,7 @@ static const char *modules[] = { "lib/ascii/ascii.ww", "lib/bytes/bytes.ww", "lib/strings/strings.ww", - "lib/io/stream.ww", + "lib/io/io.ww", "lib/errors/errors.ww", "lib/os/os.ww", "lib/strconv/strconv.ww", diff --git a/test/wcc/990_selfhost.c b/test/wcc/990_selfhost.c index 54e6bf1a..41aeaf1f 100644 --- a/test/wcc/990_selfhost.c +++ b/test/wcc/990_selfhost.c @@ -204,7 +204,7 @@ probe_dump_diff(const char *bin) "lib/fmt/fmt.ww", "lib/strings/strings.ww", "lib/bytes/bytes.ww", - "lib/io/stream.ww", + "lib/io/io.ww", "lib/errors/errors.ww", "lib/bufio/bufio.ww", "lib/types/types.ww",