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

@@ -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;
};