sysl

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.

Search

Esc
↑↓ to navigate ↵ to open Esc to close