lib/fmt+test: add asprintf (heap-allocated formatter)

Now that os.alloc/free ship (db2b05b), the heap-shape printf wrapper
that bb10ee7 deferred is implementable.

`asprintf(fmt: str, args: field...) str` — Hare wrappers.ha:29 shape.
Body wires memio.dynamic, runs fprintf into it, takes a stringview,
and shrink-to-fit-copies into a fresh os.alloc(view.len) before
io.closing the dynamic stream (which frees the cap-sized internal
buffer). The shrink-to-fit copy is forced by os.free's (p, n) shape:
n must match the mmap length, so the caller can't free a
cap-allocated body if cap > len.

Caller contract documented inline: free with `os.free(r.ptr, r.len)`
when r.len > 0; skip when r.len == 0 (no allocation happens).

Mirrors strings.dup's shape; not a workaround.

The fprintf io.closed arm is matched-and-ignored — memio.dynamicwrite
only returns size, never io.closed (verified at memio.ww:163-175).
Same shape as Hare's `case size => void;` in print.ha.

Hare's nomem variant intentionally dropped; ww's os.alloc returns a
poisonous pointer on OOM (per #14 contract) which faults on deref —
no in-band error to model.

Tests (signalled 27-30): basic (str + i64), growth (34B output
through 8→16→32→64 grow), empty (no-alloc / skip-free), indexed_mods
({1:_05} through heap sink).

errorf / error / errorln family deferred — drew's call to ship the
whole error story in one commit when the error type lands.
This commit is contained in:
2026-05-16 10:06:09 +09:00
parent b5632b1fbe
commit f906081c8c
2 changed files with 93 additions and 0 deletions

View File

@@ -753,3 +753,57 @@ export fn bsprintf(buf: []u8, fmt: str, args: field...) (str | io.closed) = {
case io.closed => { let c: io.closed; return c; };
};
};
// asprintf — render `fmt`/`args` into a heap-allocated str through
// memio.dynamic, then shrink to a tight allocation so the caller's
// free(r.ptr, r.len) matches the underlying mmap length. Mirrors
// wrappers.ha:29 modulo:
//
// - Bare `str` return. Hare returns `(str | nomem)`; ww has no
// `nomem` variant — os.alloc faults on OOM per lib/os.ww's
// contract.
// - The `io.closed` arm of fprintf is statically unreachable
// here (memio.dynamicwrite never returns io.closed, memio.ww:163),
// but the type checker still requires the match; both arms have
// empty bodies. Same effect as Hare's `case size => void`.
// - Shrink-to-fit copy. memio.dynamic's `cap` doubles past `pos`
// during growth (memio.ww:193); the close path frees the
// cap-sized mapping (memio.ww:179). Returning memio.string(&m)
// directly would either leak the cap-vs-len slack (skip close)
// or dangle the returned view (close first). Copying into a
// fresh `view.len`-sized alloc lets the caller free with
// `r.len`, matching strings.dup's tight-alloc contract.
//
// Caller frees with `os.free(r.ptr, r.len: u64)` when `r.len > 0`;
// skip the free when `r.len == 0` — same empty-output shape as
// strings.dup (no allocation took place).
export fn asprintf(fmt: str, args: field...) str = {
let m: memio.state;
let s: io.stream;
memio.dynamic(&m, &s);
let wres: (i32 | io.closed) = fprintf(&s, fmt, args...);
match (wres) {
case let n: i32 => {};
case io.closed => {};
};
let view: str = memio.string(&m);
let out: str;
out.ptr = nil;
out.len = 0;
if (view.len == 0) {
let cres: (void | io.closed) = io.close(&s);
match (cres) { case void => {}; case io.closed => {}; };
return out;
};
let tight: *u8 = os.alloc(view.len: u64): *u8;
let i: i32 = 0;
for (i < view.len) {
tight[i] = view.ptr[i];
i += 1;
};
let cres: (void | io.closed) = io.close(&s);
match (cres) { case void => {}; case io.closed => {}; };
out.ptr = tight;
out.len = view.len;
return out;
};

View File

@@ -445,6 +445,41 @@ fn closedstream(s: *io.stream) void = {
};
};
// asprintf — heap-allocated render through memio.dynamic + shrink-to-fit.
// Caller frees via os.free(r.ptr, r.len) when r.len > 0.
@test fn asprintf_basic() void = {
let r: str = fmt.asprintf("{} {}", "hi", 42i64);
if (!streq(r, "hi 42")) { fail(); };
os.free(r.ptr: *void, r.len: u64);
};
// Output > memio.dynamic's initial 8B cap to exercise the
// double-and-copy grow path; 34B target traverses 8→16→32→64.
@test fn asprintf_growth() void = {
let r: str = fmt.asprintf("hello {} world {} value {}", "alpha", "beta", "gamma");
if (!streq(r, "hello alpha world beta value gamma")) { fail(); };
os.free(r.ptr: *void, r.len: u64);
};
// Empty format pins the no-allocation path: memio.dynamic.grow never
// fires (cap stays 0), and the asprintf shrink-to-fit path returns
// {nil, 0} without calling os.alloc. Free is skipped — matches the
// strings.dup empty-input contract.
@test fn asprintf_empty() void = {
let r: str = fmt.asprintf("");
if (r.len != 0) { fail(); };
};
// Indexed placeholder + modifier through asprintf — confirms the full
// {n:mods} parser path works through the heap-allocated sink, not just
// the memio.fixed (bsprintf) and memio.dynamic-via-fprintf paths.
@test fn asprintf_indexed_mods() void = {
let r: str = fmt.asprintf("{1:_05}", "zzz", 42i64);
if (!streq(r, "00042")) { fail(); };
os.free(r.ptr: *void, r.len: u64);
};
export fn main() i32 = {
signalled = 1; fprintbarestr();
signalled = 2; fprintintstr();
@@ -472,5 +507,9 @@ export fn main() i32 = {
signalled = 24; fprintf_bool_rune();
signalled = 25; bsprintf_trunc();
signalled = 26; bsprintf_width_trunc();
signalled = 27; asprintf_basic();
signalled = 28; asprintf_growth();
signalled = 29; asprintf_empty();
signalled = 30; asprintf_indexed_mods();
return 0;
};