The fmt module
sysl.fmt — the renderers behind an f-string’s specifiers: integers in hexadecimal, binary and octal as plain functions, floats correctly rounded under %f, %e and %g, and the shortest reading of a float — all in sysl, so they work on a target with no C library.
Every declaration in sysl.fmt, with its signature: the generated API page. This page is the argument — what the module is for, and how its pieces fit; that one is the list.
print writes an integer in decimal. A register dump, a memory address or a bit mask is read in
another base, and there are two ways to get one.
In an f-string, a specifier does it. %x, %X, %o and %b convert, and 0, a width and #
shape the field, as in C:
val v: u32 = 0xcafe
print(f"$v%x")
print(f"$v%08X")
print(f"$v%#x")
print(f"$v%b")
cafe
0000CAFE
0xcafe
1100101011111110
As a function, sysl.fmt does it — for code that wants the string without an f"…", or that
chooses the base or the width at run time. hex, binary and octal take any integer, and width is
a minimum count of digits, padded with zeros on the left:
import sysl.fmt.hex
import sysl.fmt.binary
import sysl.fmt.octal
val w: u32 = 0xdeadbeef
print(hex(w))
print(hex(u64(0xab), 16))
print(hex(u16(0xbeef), 6, true))
print(binary(u8(5), 8))
print(octal(u16(511)))
deadbeef
00000000000000ab
00BEEF
00000101
777
hex(v, width = 0, upper = false) | base sixteen, lower case unless upper; no 0x prefix |
binary(v, width = 0) | base two |
octal(v, width = 0) | base eight |
A negative value is its two’s complement at its own width, exactly as %x renders it: the width
is the type’s, not the value’s, so the same bits give the same digits whatever the sign.
import sysl.fmt.hex
print(hex(i8(-1)))
print(hex(i16(-2)))
print(hex(-1))
print(hex(i64(-1)))
ff
fffe
ffffffff
ffffffffffffffff
Every width is exact, u128 and i128 included, in an f-string hole and in these functions
alike, because both go through format_int. The powers of two are done by shift and mask, and decimal
divides in 32-bit limbs, so no 128-bit division is called:
import sysl.fmt.hex
val m: u128 = 340282366920938463463374607431768211455
val lo: i128 = -170141183460469231731687303715884105727 - 1
print(f"$m%x")
print(f"$m%d")
print(f"$lo%d")
print(hex(lo))
ffffffffffffffffffffffffffffffff
340282366920938463463374607431768211455
-170141183460469231731687303715884105728
80000000000000000000000000000000
A width never truncates — hex(u32(0x12345), 2) is 12345 — and a width of zero or less asks for
no padding.
Floats
A float under %f, %e or %g is rendered by format_real, or format_f32 for an f32, flag for
flag as C’s printf renders a double. A plain hole and str are not a specifier: they write what
print writes, which is what Rust’s {} writes — the fewest digits that read back, set out
positionally (print has the rule) — through
format_real_plain and its siblings at each width:
val x = 3.14159265358979
print(f"[${x}%.3f] [${x}%10.2e] [${x}%-8.3g] [${x}%+08.2f] [${1e-5}%g] [${2.5e6}%G]")
print(f"[${100.0}%#g] [${2.0}%.0e] [${0.0}%e] [${-0.0}%g]")
[3.142] [ 3.14e+00] [3.14 ] [+0003.14] [1e-05] [2.5E+06]
[100.000] [2e+00] [0.000000e+00] [-0]
The digits are the exact value’s, correctly rounded at any precision. A finite float is a
fraction with a power of two below it, so its decimal expansion is finite, and every digit of it is
there for the asking. A digit is rounded half to even, which is what decides a value that lies
exactly half way — 0.125 to two places and 2.5 to none:
print(f"${0.125}%.2f ${0.375}%.2f ${2.5}%.0f ${3.5}%.0f")
print(f"${0.1}%.25f")
print(f"${5e-324}%.3e")
0.12 0.38 2 4
0.1000000000000000055511151
4.941e-324
An infinity is inf and a NaN is nan, upper case under %E, %F or %G; neither is padded with
zeros, and each is signed as C signs it — a NaN whose sign bit is set is -nan, and + or a space
marks one that is not. A plain hole is not a C conversion, and there a NaN is NaN whatever its
bits, as Rust’s {} writes it. The width and the precision may be holes of their own, as an
integer’s may (${x}%.${n}f).
val neg = -real.nan
print(f"[${neg}%f] [${real.nan}%+6.2e] [${real.nan}% G] [${neg}%08f] [$neg]")
[-nan] [ +nan] [ NAN] [ -nan] [NaN]
One host differs, and it is the host that is wrong. Where %g rounds a tie down to the even
digit, macOS’s C library leaves the trailing zeros C requires dropped — its printf("%g", 1000005.0)
is 1.00000e+06 — and format_real writes 1e+06, as glibc does.
The shortest reading
%g‘s six digits lose a value, and seventeen print noise after one that needed fewer. What a
serializer wants is the fewest digits that read back as the same float, and shortest gives
it — among those, the one nearest the value, and a tie to the even digit, which is the reading
JavaScript and Python print. They are the digits print and str write too, spelled here with
%g‘s exponent where they set them out positionally. The algorithm is Ryu. An f32 is read at its own width, so it is
usually shorter than the same value widened:
import sysl.fmt.shortest
print(shortest(0.1), shortest(1.0 / 3.0), shortest(1e23), shortest(5e-324))
print(shortest(f32(0.1)), shortest(real(f32(0.1))))
0.1 0.3333333333333333 1e+23 5e-324
0.1 0.10000000149011612
The spelling is %g‘s: an exponent below 1e-4, and from 1e15 up where the digits do not already
reach that far, at least two
exponent digits, and never a trailing zero or point. Reading any of them back is
sysl.text.parse_real, which is exact too.
Writing one without allocating
format_real answers a string, which a program that may not allocate — an @no_alloc module, a C
library’s own printf — cannot take. write_real writes the same text to any Writer, working
the digits out in a scratch the caller lends, so it allocates nothing. Its specifier is a whole printf
conversion, %a and %A included, and an l or L before the letter is read and ignored, so a C
format’s own specifier can be handed over as it stands:
import sysl.fmt.{write_real, float_scratch_len, Short}
var scratch: [float_scratch_len]u8
write_real(0.1, stdout(), "[%.17g] [%a] [%+010.2Le]", 0, -1, scratch[..]) match
Err(why) -> print(why)
Ok(_) -> print("")
for spec in ["%.17g", "%a", "%+010.2Le", "%#.0e", "%9.3f"]
write_real(0.1, stdout(), spec, 0, -1, scratch[..]).unwrap()
print("")
write_real(0.1, stdout(), "%.40f", 0, -1, scratch[0..<16]) match
Err(Short(needed)) -> print(f"short: $needed bytes would do")
_ -> print("")
not a float conversion
0.10000000000000001
0x1.999999999999ap-4
+01.00e-01
1.e-01
0.100
short: 41 bytes would do
float_scratch_len bytes are always enough — the longest exact real runs to 767 significant
digits — and most requests need far fewer: %.17g needs seventeen, and %a, an infinity, a NaN and
a zero need none. A scratch too short is refused with Short before anything is written, naming a
length that would do; a specifier that is not one float conversion is BadSpec. %a writes a first
digit of 1 for every value but zero, a subnormal included (0x1p-1074), and rounds a fraction half to
even, as glibc and musl do. write_f32 and write_f128 are the same at the other widths, and an
f128‘s scratch is f128_scratch_len.
write_fixed is the same for a fixed-point value, handed the raw integer and the format it is
read at — width, signedness, fraction — since one function serves every iWqF. Its digits are the
exact value’s, as an f-string hole’s are, and float_scratch_len is enough for any format up to 128
bits. A wider format has format_fixed_wide(raw, frac, spec, width, precision) and
write_fixed_wide(raw, frac, out, spec, width, precision, scratch), which take x.bits at its own
integer type (fixed_scratch_len(bits) bytes of scratch always suffice); the f-string hole chooses
between the two:
import sysl.fmt.{write_fixed, float_scratch_len}
val x = i64q63::Max
var scratch: [float_scratch_len]u8
write_fixed(i128(x.bits), 64, true, 63, stdout(), "%.25f", 0, -1, scratch[..]).unwrap()
print("")
0.9999999999999999998915798
format_real(x, spec, width, precision) | a real under a printf specifier; width and precision are what a * reads |
format_f32(x, spec, width, precision) | the same for an f32, taken apart at its own width |
write_real(x, out, spec, width, precision, scratch) | the same text written to a *Writer, allocating nothing; write_f32 and write_f128 at the other widths |
format_fixed(raw, bits, signed, frac, spec, width, precision) | a fixed-point value under a float specifier, from its raw integer: what ${x}%.4f lowers to |
write_fixed(raw, bits, signed, frac, out, spec, width, precision, scratch) | the same text written to a *Writer, allocating nothing |
format_fixed_wide(raw, frac, spec, width, precision), write_fixed_wide(raw, frac, out, spec, width, precision, scratch) | the same for a fixed-point value wider than 128 bits, raw at its own integer type |
float_scratch_len, f128_scratch_len, fixed_scratch_len(bits) | scratch lengths that are always enough |
shortest(x) | the fewest digits that read back as x, a real or an f32 |
format_real_plain(x) | a real as str and a plain hole write it: Rust’s {}; format_f32_plain, format_f16_plain, format_bf16_plain and format_f128_plain at the other widths |
They are the f-string’s renderer, so a bare target has them too
An integer specifier is rendered by sysl.fmt.format_int, a string one by format_str and a float
one by format_real, all written in sysl, and hex, binary and octal only compose a specifier
and call one. Nothing here asks for snprintf, so a freestanding image that reaches hex links with
no C library symbol, as one that writes f"$v%016x", f"${s}%-8s" or f"${x}%.3f" does — the float
renderer is integer arithmetic throughout, so it asks a machine with no double-precision unit for
nothing it cannot do either. No specifier is refused for want of a C library.