diff --git a/lib/fmt/fmt.ww b/lib/fmt/fmt.ww index a3f73b31..f48fa022 100644 --- a/lib/fmt/fmt.ww +++ b/lib/fmt/fmt.ww @@ -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; +}; diff --git a/lib/fmt/fmttest.ww b/lib/fmt/fmttest.ww index e13f4472..0257f31c 100644 --- a/lib/fmt/fmttest.ww +++ b/lib/fmt/fmttest.ww @@ -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; };