lib/fmt+test: add f64 dispatch arm (#17)

#30 (82be8b9) unblocked tagged-union widen for runtime f64. Add the
f64 arm to fmt's formattable union and dispatch.

`formattable` gains an f64 case (appended last to preserve existing
tag indices). fdprint / fprint mirror their i64-arm shape. formatraw
peels strconv's natural '-' so signof folds neg/+/space uniformly
with i64. formatfield uses the inline widen-per-arm #18 sidestep.
rawlenf64 renders via strconv.f64tos to count bytes for width
alignment (Hare's print.ha:53 uses an io::empty sink for this; ww
has none yet, so we render twice — acceptable v1 trade).

Width / alignment / pad / sign mods honored. prec / base ignored
with inline rationale (no ffmt/fflags in mods yet; Hare aborts on
non-DEC base, ww silently falls through). NaN/Inf deferred — Inf
renders deterministically as "huge"/"-huge" via strconv's `f >= cap`
path; NaN is garbage. Detection waits on f64↔u64 bit-reinterpret in
cgen.

13 test rows at signalled 31-43 cover basic/int-valued/neg/zero/
small-frac/huge/sign±/space/width-right/width-left + fprint
variadic + bsprintf + asprintf sinks. Each pins exact byte output.

Three rows bind negative literal via intermediate `let nv: f64 =
-2.5;` to route around #40 (cstage drops payload on N_UNARY-of-
N_FLOATLIT in tagged-union widen). Comments cite #40 at each row.
Probe at .ai/probe_f64_unary_neg.ww.

Strconv f64tos is fixed-point today; graduate to Ryū (ref/hare/
strconv/ftos.ha:432) when needed.
This commit is contained in:
2026-05-16 14:36:45 +09:00
parent 82be8b9b4b
commit 7f320d3e75
2 changed files with 309 additions and 2 deletions

View File

@@ -68,8 +68,14 @@ fn i64dec(v: i64) str = {
// formattable — tagged union of types fmt can render. Mirrors Hare's
// `fmt::formattable = (...types::numeric | uintptr | str | rune |
// bool | nullable *opaque | void)`, narrowed to the set ww actually
// has codegen for. Slot size is 24B (8 tag + 16 str payload).
export type formattable = (i64 | str | bool | rune);
// has codegen for. Slot size is 24B (8 tag + 16 str payload — f64
// arm is only 8B and rides under the str payload).
//
// No `f32` arm: strconv ships no `f32tos` and there is no in-tree
// caller. Callers with an `f32` cast at the call site (`myf: f64`),
// mirroring how `i64` covers every int width today. Ship the `f32`
// arm when the first in-tree caller needs it.
export type formattable = (i64 | str | bool | rune | f64);
// ---- fd sinks --------------------------------------------------------
@@ -117,6 +123,15 @@ export fn fdprint(fd: i32, args: formattable...) i64 = {
if (n < 0) { return n; };
total += n;
};
case let v: f64 => {
// strconv.f64tos's static-buffer view is consumed
// immediately by os.write; no intervening strconv
// call against the same buffer between bind and write.
let s: str = strconv.f64tos(v);
let r: i64 = os.write(fd, s.ptr, s.len: u64);
if (r < 0) { return r; };
total += r;
};
};
i += 1;
};
@@ -201,6 +216,18 @@ export fn fprint(s: *io.stream, args: formattable...) (i32 | io.closed) = {
case io.closed => { let c: io.closed; return c; };
};
};
case let v: f64 => {
// strconv.f64tos returns a static-buffer view —
// emit to the sink before yielding control to the
// next arg iteration so no sibling f64/i64 strconv
// call clobbers the buffer mid-flight.
let view: str = strconv.f64tos(v);
let rs: (i32 | io.closed) = putbytes(s, view.ptr, view.len);
match (rs) {
case let m: i32 => { total += m; };
case io.closed => { let c: io.closed; return c; };
};
};
};
i += 1;
};
@@ -487,10 +514,66 @@ fn formatraw(out: *io.stream, arg: formattable, m: *mods) (i32 | io.closed) = {
buf[0] = r: u8;
return putbytes(out, &buf[0], 1);
};
case let v: f64 => {
// strconv.f64tos already prepends '-' for negative values;
// peel it back off so [[signof]] can fold neg/plus/space
// mods uniformly with the i64 arm. The view bytes are then
// emitted directly — strconv's static buf is not held past
// the putbytes call (no sibling strconv call lands between).
//
// Mods scope: width / alignment / pad / sign honored via
// the same raw-then-pad machinery as i64. `prec` ignored —
// strconv.f64tos has no precision knob; graduate when a
// Ryū-shaped strconv lands and mods grows ffmt / fflags.
// `base` ignored (Hare does too — base is int-only).
// NaN/±Inf print as whatever strconv emits today (garbage);
// strconv detection blocked on f64↔u64 bit-reinterpret cgen.
let view: str = strconv.f64tos(v);
let neg_flag: bool = false;
if (view.len > 0 && view.ptr[0] == 45u8) { // '-'
neg_flag = true;
view.ptr = view.ptr + 1u64;
view.len -= 1;
};
let sb: u8 = signof(neg_flag, m);
let total: i32 = 0;
if (sb != 0u8) {
let buf: [1]u8;
buf[0] = sb;
let r: (i32 | io.closed) = putbytes(out, &buf[0], 1);
match (r) {
case let n: i32 => { total += n; };
case io.closed => { let c: io.closed; return c; };
};
};
let r: (i32 | io.closed) = putbytes(out, view.ptr, view.len);
match (r) {
case let n: i32 => { total += n; };
case io.closed => { let c: io.closed; return c; };
};
return total;
};
};
let z: io.closed; return z; // unreachable — match is exhaustive
};
// rawlenf64 — bytes the raw render of `v` under `m` would emit.
// Calls strconv.f64tos to count digits + decimal point; peels off
// the natural '-' so signof's neg/plus/space byte is counted exactly
// once, the same accounting [[rawleni64]] does.
fn rawlenf64(v: f64, m: *mods) i32 = {
let view: str = strconv.f64tos(v);
let body: i32 = view.len;
let had_neg: bool = false;
if (body > 0 && view.ptr[0] == 45u8) {
had_neg = true;
body -= 1;
};
let signlen: i32 = 0;
if (signof(had_neg, m) != 0u8) { signlen = 1; };
return signlen + body;
};
// rawlen — bytes formatraw would emit for `arg` under `m`. Used by
// formatone's width-alignment path (Hare's print.ha:53 calls
// format_raw with io::empty for the same purpose).
@@ -500,6 +583,7 @@ fn rawlen(arg: formattable, m: *mods) i32 = {
case let v: str => return rawlenstr(v, m);
case let b: bool => { if (b) { return 4; }; return 5; };
case let r: rune => return 1;
case let v: f64 => return rawlenf64(v, m);
};
return 0; // unreachable — match is exhaustive
};
@@ -574,6 +658,10 @@ fn formatfield(out: *io.stream, f: field, m: *mods) (i32 | io.closed) = {
let a: formattable = r;
return formatone(out, a, m);
};
case let v: f64 => {
let a: formattable = v;
return formatone(out, a, m);
};
case let p: *mods => { fmtabort(); let c: io.closed; return c; };
};
};

View File

@@ -480,6 +480,212 @@ fn closedstream(s: *io.stream) void = {
os.free(r.ptr: *void, r.len: u64);
};
// ---- f64 dispatch arm -------------------------------------------------
//
// Pins the f64 formattable arm landed under task #17 (unblocked by #30
// — the variant-widen-from-X0 fix). strconv.f64tos drives the render;
// see lib/strconv/strconv.ww for the documented subset (fixed-point,
// 6 fractional digits, trailing-zero trim, magnitudes ≥ 9e18 → "huge",
// no NaN/±Inf detection).
//
// Mods scope under v1: width / alignment / pad / sign mods honored.
// `prec` and `base` ignored — graduate when strconv grows ffmt/fflags.
@test fn fprintf_f64_basic() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let r: (i32 | io.closed) = fmt.fprintf(&s, "{}", 1.5);
match (r) {
case let n: i32 => { if (n != 3) { fail(); }; };
case io.closed => fail();
};
if (!streq(memio.string(&mem), "1.5")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
@test fn fprintf_f64_int_valued() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let r: (i32 | io.closed) = fmt.fprintf(&s, "{}", 1.0);
match (r) {
case let n: i32 => { if (n != 1) { fail(); }; };
case io.closed => fail();
};
if (!streq(memio.string(&mem), "1")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
// Negative-valued f64 rendered through fmt. PENDING task #40 (cstage
// variant-widen drops payload on N_UNARY-of-N_FLOATLIT, residual of
// #30): the literal-arg form below renders as "0" on cstage today —
//
// fmt.fprintf(&s, "{}", -2.5) // ✗ cstage: "0"
// // ✓ wwstage: "-2.5"
//
// Workaround until #40 lands: bind to a typed local so the widen
// hits the post-#30 N_IDENT path. Matches the cast form
// (`-2.5: f64`), which also works on both stages. Realistic caller
// shape (negatives usually arrive through a variable, not as a
// literal printf arg). Probe: .ai/probe_f64_unary_neg.ww.
@test fn fprintf_f64_neg() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let nv: f64 = -2.5;
let r: (i32 | io.closed) = fmt.fprintf(&s, "{}", nv);
match (r) {
case let n: i32 => { if (n != 4) { fail(); }; };
case io.closed => fail();
};
if (!streq(memio.string(&mem), "-2.5")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
@test fn fprintf_f64_zero() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let r: (i32 | io.closed) = fmt.fprintf(&s, "{}", 0.0);
match (r) {
case let n: i32 => { if (n != 1) { fail(); }; };
case io.closed => fail();
};
if (!streq(memio.string(&mem), "0")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
@test fn fprintf_f64_small_frac() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let r: (i32 | io.closed) = fmt.fprintf(&s, "{}", 0.05);
match (r) {
case let n: i32 => { if (n != 4) { fail(); }; };
case io.closed => fail();
};
if (!streq(memio.string(&mem), "0.05")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
// Pins the strconv.f64tos "huge" fallback through the fmt dispatch.
// Catches any regression where mods accidentally pre-truncate the view.
@test fn fprintf_f64_huge() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let r: (i32 | io.closed) = fmt.fprintf(&s, "{}", 9.5e18);
match (r) {
case let n: i32 => { if (n != 4) { fail(); }; };
case io.closed => fail();
};
if (!streq(memio.string(&mem), "huge")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
// Sign-mod fold: '+' on positive, '-' still wins on negative (the
// natural '-' from strconv is peeled and signof reapplies). Negative
// routed through a typed local — same task #40 workaround as
// fprintf_f64_neg above.
@test fn fprintf_f64_sign_plus() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let nv: f64 = -1.5;
let r: (i32 | io.closed) = fmt.fprintf(&s, "{:+} {:+}", 1.5, nv);
match (r) {
case let n: i32 => { if (n != 9) { fail(); }; }; // "+1.5 -1.5"
case io.closed => fail();
};
if (!streq(memio.string(&mem), "+1.5 -1.5")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
@test fn fprintf_f64_sign_space() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let r: (i32 | io.closed) = fmt.fprintf(&s, "{: }", 1.5);
match (r) {
case let n: i32 => { if (n != 4) { fail(); }; };
case io.closed => fail();
};
if (!streq(memio.string(&mem), " 1.5")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
// Width + alignment round-trip through rawlenf64 (sign-aware len count).
@test fn fprintf_f64_width_right() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let r: (i32 | io.closed) = fmt.fprintf(&s, "{:8}", 1.5);
match (r) {
case let n: i32 => { if (n != 8) { fail(); }; };
case io.closed => fail();
};
if (!streq(memio.string(&mem), " 1.5")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
@test fn fprintf_f64_width_left() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let r: (i32 | io.closed) = fmt.fprintf(&s, "{:-8}", 1.5);
match (r) {
case let n: i32 => { if (n != 8) { fail(); }; };
case io.closed => fail();
};
if (!streq(memio.string(&mem), "1.5 ")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
// Legacy fprint variadic — covers the non-printf surface arm (where
// formatraw isn't reached because there's no mods parser).
@test fn fprint_f64_variadic() void = {
let mem: memio.state;
let s: io.stream;
memio.dynamic(&mem, &s);
let r: (i32 | io.closed) = fmt.fprint(&s, 1.5, "x");
match (r) {
case let n: i32 => { if (n != 5) { fail(); }; }; // "1.5 x"
case io.closed => fail();
};
if (!streq(memio.string(&mem), "1.5 x")) { fail(); };
let c: (void | io.closed) = io.close(&s);
match (c) { case void => {}; case io.closed => fail(); };
};
@test fn bsprintf_f64() void = {
let buf: [16]u8;
let r: (str | io.closed) = fmt.bsprintf(buf[0:16], "x={}", 1.5);
match (r) {
case let s: str => { if (!streq(s, "x=1.5")) { fail(); }; };
case io.closed => fail();
};
};
// Heap sink + both signs. Negative through typed local per task #40
// workaround (see fprintf_f64_neg comment).
@test fn asprintf_f64() void = {
let nv: f64 = -2.5;
let r: str = fmt.asprintf("{} {}", 1.5, nv);
if (!streq(r, "1.5 -2.5")) { fail(); };
os.free(r.ptr: *void, r.len: u64);
};
export fn main() i32 = {
signalled = 1; fprintbarestr();
signalled = 2; fprintintstr();
@@ -511,5 +717,18 @@ export fn main() i32 = {
signalled = 28; asprintf_growth();
signalled = 29; asprintf_empty();
signalled = 30; asprintf_indexed_mods();
signalled = 31; fprintf_f64_basic();
signalled = 32; fprintf_f64_int_valued();
signalled = 33; fprintf_f64_neg();
signalled = 34; fprintf_f64_zero();
signalled = 35; fprintf_f64_small_frac();
signalled = 36; fprintf_f64_huge();
signalled = 37; fprintf_f64_sign_plus();
signalled = 38; fprintf_f64_sign_space();
signalled = 39; fprintf_f64_width_right();
signalled = 40; fprintf_f64_width_left();
signalled = 41; fprint_f64_variadic();
signalled = 42; bsprintf_f64();
signalled = 43; asprintf_f64();
return 0;
};