From bccd111a16ac34e01edd13e88876f0371193d294 Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Sat, 13 Jun 2026 10:36:11 +0900 Subject: [PATCH] lib/fmt: encode rune args as UTF-8, not a truncated low byte (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeone and formatraw's rune arm did `buf[0] = r: u8; putbytes(...,1)`, emitting only the low byte — invalid UTF-8 for any rune > 0x7F, with a success return. rawlen's rune arm hardcoded 1, desyncing width-padding for multibyte runes. Route both write arms through utf8.encoderune and rawlen through utf8.runesz (ref/hare/fmt/print.ha:84). Uses the current in-tree encoderune(out: []u8, r) caller-buffer signature; report-#168's signature realignment is a separate item, not folded here. fmttest gains fprintrune (1/2/3/4-byte runes) + fprintf_rune_width (multibyte pad). --- lib/fmt/fmt.ww | 25 ++++++++++++++++++++----- lib/fmt/fmttest.ww | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/lib/fmt/fmt.ww b/lib/fmt/fmt.ww index 1cbdd99f..9a0d33b8 100644 --- a/lib/fmt/fmt.ww +++ b/lib/fmt/fmt.ww @@ -22,6 +22,7 @@ package fmt; import io; +import encoding.utf8; import math; import memio; import os; @@ -115,9 +116,15 @@ fn writeone(s: io.handle, a: formattable) (size | io.error) = { return putbytes(s, v.ptr, v.len); }; case let r: rune => { + // ref/hare/fmt/print.ha:84 io::write(out, utf8::encoderune(r)): + // emit the full UTF-8 encoding, not the truncated low byte. let buf: [4]u8; - buf[0] = r: u8; - return putbytes(s, &buf[0], 1); + let sl: []u8; + sl.ptr = &buf[0]; + sl.len = 4; + sl.cap = 4; + let nn: i32 = utf8.encoderune(sl, r); + return putbytes(s, &buf[0], nn); }; case let v: f64 => { // strconv.f64tos static-buffer view consumed before next @@ -348,7 +355,10 @@ fn rawlen(arg: formattable, m: *mods) i32 = { case let v: i64 => return rawleni64(v, m); case let v: str => return rawlenstr(v, m); case let b: bool => { if (b) { return 4; }; return 5; }; - case let r: rune => return 1; + // width-padding must count the rune's encoded byte length, else a + // multibyte rune desyncs the pad (ref/hare/fmt/print.ha:84 emits the + // full encoding; runesz is its length). + case let r: rune => return utf8.runesz(r); case let v: f64 => return rawlenf64(v, m); case let v: int => return rawleni64(v: i64, m); case let v: uint => return rawlenu64(v: u64, m); @@ -412,9 +422,14 @@ fn formatraw(s: io.handle, arg: formattable, m: *mods) (size | io.error) = { return putbytes(s, v.ptr, v.len); }; case let r: rune => { + // ref/hare/fmt/print.ha:84: full UTF-8 encoding, not the low byte. let buf: [4]u8; - buf[0] = r: u8; - return putbytes(s, &buf[0], 1); + let sl: []u8; + sl.ptr = &buf[0]; + sl.len = 4; + sl.cap = 4; + let nn: i32 = utf8.encoderune(sl, r); + return putbytes(s, &buf[0], nn); }; case let v: f64 => { // strconv.f64tos prepends '-' for negative values; peel here so diff --git a/lib/fmt/fmttest.ww b/lib/fmt/fmttest.ww index e60f2a66..3766bc8d 100644 --- a/lib/fmt/fmttest.ww +++ b/lib/fmt/fmttest.ww @@ -303,6 +303,46 @@ fn errsource() io.stream = { match (c) { case void => {}; case let eioe: io.error => abort(); }; }; +// ---- fprint: rune args emit full UTF-8, not a truncated byte (#66) ----- +// writeone/formatraw's rune arm did `buf[0] = r: u8; putbytes(...,1)`, +// emitting only the low byte (invalid UTF-8 for r > 0x7F). The fix routes +// through utf8.encoderune (ref/hare/fmt/print.ha:84). Edge runes: é (2B), +// € (3B), 😀 (4B), A (1B). + +@test fn fprintrune() void = { + let mem: memio.stream = memio.dynamic(); + let s: io.stream = &mem.vt; + + let r: (size | io.error) = fmt.fprint(s, 233: rune, 0x20AC: rune, + 0x1F600: rune, 65: rune); + match (r) { + case let n: size => { assert(!(n: i32 != 13)); }; // 2+1+3+1+4+1+1 + case let eioe: io.error => abort(); + }; + assert(!(!streq(memio.string(&mem), "é € 😀 A"))); + + let c: (void | io.error) = io.close(s); + match (c) { case void => {}; case let eioe: io.error => abort(); }; +}; + +// rawlen's rune arm hardcoded 1, desyncing width-padding for a multibyte +// rune. With runesz it counts the encoded length: € (3B) in width 5 pads +// to " €" (2 spaces + 3 bytes = 5). +@test fn fprintf_rune_width() void = { + let mem: memio.stream = memio.dynamic(); + let s: io.stream = &mem.vt; + + let r: (size | io.error) = fmt.fprintf(s, "{:5}", 0x20AC: rune); + match (r) { + case let n: size => { assert(!(n: i32 != 5)); }; + case let eioe: io.error => abort(); + }; + assert(!(!streq(memio.string(&mem), " €"))); + + let c: (void | io.error) = io.close(s); + match (c) { case void => {}; case let eioe: io.error => abort(); }; +}; + @test fn fprintf_pad_underscore() void = { let mem: memio.stream = memio.dynamic(); let s: io.stream = &mem.vt;