std.fmt
std.fmt separates user-defined display from debug output and streams UTF-8 to
any static std.io.Writer. It leaves builtin print unchanged.
Formatting policy is checked Plenty; the native primitives only write
floating-point spellings into existing byte storage. There is no runtime
reflection, format-string interpreter, or dynamic writer object.
f-strings compile to calls on
this module, and out.format(f"...") writes one to a formatter out without an
intermediate str.
The same formatter serves std.report: its writer-generic
Formattable.format protocol shares these methods and errors.
stderr_formatter(max_bytes, max_depth) creates a Formatter[Stderr] without
allocating; text, boolean and integer output on that path also needs no heap
storage. Advanced numeric formatting acquires scratch lazily.
import std.fmt
from std.fmt import Formatter
pub class Point:
x: i64
y: i64
pub def display[W](self, out: &mut Formatter[W]) -> Result[(), FormatError]:
out.text("(")?
out.integer(self.x, 0)?
out.text(", ")?
out.integer(self.y, 0)?
out.text(")")
def main() -> Result[(), AllocError | IoError | FormatError]:
print(std.fmt.to_string(&Point(3, -4), 80, 8)?)?
mut out = std.fmt.string_formatter(80, 8)?
out.text("total:")?
out.float_spec(1234.5, &std.fmt.spec(width=10, grouping=",", precision=2, kind="f"))?
print(std.fmt.finish(&out)?)?
Ok(())
(3, -4)
total: 1,234.50
Writers and strings
formatter(writer, scratch, max_bytes, max_depth) consumes a writer and a
Bytes scratch buffer and returns Result[Formatter[W], FormatError].
Scratch must already have length at least 32, not just capacity. Both
limits are NonNegative[i64], so a negative literal is a compile error. Depth
is at most 64, since nested display calls recurse on the native stack. A shorter
scratch or a larger depth traps with std.fmt.formatter: scratch is shorter than 32 bytes or std.fmt.formatter: max_depth is above 64; stderr_formatter and
string_formatter trap the same way for depth. Construction consumes both
arguments even on failure.
The formatter owns them; its public writer field lets the caller flush, close,
or inspect the underlying stream. Formatting never flushes or closes it.
string_formatter(max_bytes, max_depth) creates a growable in-memory cursor and
scratch. Text emissions on this collector stage their complete UTF-8 spelling
in reusable scratch before one cursor write, preserving the output on failed
allocation. finish(&output) validates and copies its current bytes into a str;
the formatter remains usable. Both are fallible. to_string(&value, max_bytes, max_depth) and to_debug(...) construct a formatter, invoke the corresponding
user contract below, and return a string. They return no partial string on error.
Use a formatter directly to inspect partial progress or reuse storage.
All formatting methods use an exclusive receiver and return
Result[(), FormatError]:
| Method | Form |
|---|---|
text(&str) | Exact UTF-8, including embedded NUL |
debug_text(&str) | Double quotes; escapes ", \, LF, CR, TAB; other C0/C1 controls use lowercase \u{hex}; other Unicode is preserved |
ascii_text(&str) | The text with each non-ASCII scalar as lowercase \u{hex}; ASCII is unchanged |
substr(&substr), debug_substr(&substr) | text and debug_text for a substring view, without copying it |
boolean(bool) | True or False |
integer(i64, width: NonNegative[i64] = 0) | Signed decimal, including i64.MIN |
unsigned(u64, base, width: NonNegative[i64] = 0) | Base 2, 8, 10, or 16, lowercase digits, no prefix |
floating(f64, width: NonNegative[i64]) | Shortest round-trip scientific decimal, e.g. 1.25e0, -0e0, inf, -inf, NaN |
Smaller integer/float types can be explicitly converted. Width is a minimum
byte count, padded on the left with ASCII spaces, including any sign.
It never truncates. An unsupported base returns InvalidSpec before output;
convert a computed width with NonNegative.try_from(width)?.
These numeric spellings are ASCII, so byte and scalar widths coincide; no
grapheme or terminal-cell width is promised. Text has no implicit padding.
floating has no precision or locale controls; promoting f32 to f64
spells the promoted value. The spec methods below add Python’s options. There
is no format-spec parser: a Spec is built from its fields, and f-strings
parse their specs while compiling.
Format specs
Spec holds one field’s options as public fields, in this order: fill (one
scalar), align ("", "<", ">", "^", "="), sign ("", "+",
"-", " "), coerce_zero (z), alternate (#), zero (0), width,
grouping ("", ",", "_"), precision (-1 when absent), and kind
(the type letter or ""). spec(...) builds one with keyword defaults, for
example spec(width=8, precision=2, kind="f"). Width and precision are at most
4096.
| Method | Options |
|---|---|
text_spec(&str, &Spec) | Fill, alignment (default left), width; precision truncates. Widths count scalars |
signed_spec(i64, &Spec), unsigned_spec(u64, &Spec) | Types d, x, X, o, b; # prefixes; sign; , (decimal) or _ grouping; 0 grouped zero padding; default right |
signed_spec, unsigned_spec with kind n | As d, without grouping |
char_spec(i64, &Spec) | Kind c: the scalar with that code point; fill, alignment (default right), width, 0 padding; InvalidSpec for a value that is not a Unicode scalar |
float_spec(f64, &Spec), float32_spec(f32, &Spec) | Types e, E, f, F, g, G, %, n (as g, without grouping), or none; precision (default 6 when typed); #; sign; coerce_zero drops the sign of a result whose digits are all zero; grouping; 0 padding |
Their spellings match Python’s format, except that a float with neither type
nor precision uses print’s spelling. A combination the method’s type does not
accept, such as a sign on text or a precision on an integer, returns
InvalidSpec before output. f-strings reject these at compile time.
Spec numbers are spelled in scratch at offset 8. A spelling longer than the
scratch, such as a 64-digit binary integer or 1e300 with f, first grows the
scratch fallibly (Allocation(...)); preallocate scratch to keep
formatting allocation-free. represent(&value, &mut out) writes the str.repr
spelling of any value str.repr accepts, which allocates a temporary string.
A Bytes buffer, bytes literal, or &[u8] view spells as a byte literal
(printing bytes); write hex with
std.encoding.hex_text.
repr_string(&value), debug_string(&text), and ascii_string(&text) return
the str.repr, debug_text, and ascii_text spellings as fallible strings.
A &T parameter cannot take a slice view,
so represent_view(v, &mut out) and repr_view_string(v) do the same for a
&[T]; f-strings call them for view fields. For a &substr, f-strings call
substr, substr_spec(v, &spec) (the text_spec options), and
repr_substr_string(v), the debug_substr spelling as a fallible string.
When a field’s str.repr spelling runs a debug method, the f-string calls
str.repr at its own site and passes the result to repr_text(spelled, &mut out) or repr_result(spelled), which turn AllocError | FormatError into a
FormatError.
An f-string builds its text as Result[str, FormatError] and converts the
result at its site: allocation_only(built) gives Result[str, AllocError]
when no field runs a user method or has a computed spec, and
allocation_or_format(built) gives Result[str, AllocError | FormatError]
otherwise. Both report Allocation(cause) as cause. allocation_only traps on any other
FormatError, which the compiler’s choice rules out.
f-strings check the values of a spec’s nested fields with three functions, each
returning InvalidSpec for a value the spec cannot take: spec_align(&align)
accepts "", <, >, ^, and =; spec_fill(&fill, &align) turns "" into
the default space and otherwise requires one scalar and a non-empty alignment;
spec_count(n) accepts a width or precision in 0..=4096.
Static user contracts and aggregates
A public class or enum opts in by providing either or both public methods:
pub def display[W](self, out: &mut Formatter[W]) -> Result[(), FormatError]:
...
pub def debug[W](self, out: &mut Formatter[W]) -> Result[(), FormatError]:
...
std.fmt.display(&value, &mut out) and debug(...) check the selected method at
each concrete generic instantiation. These helpers use ordinary structural
calls rather than named protocol bounds. Missing/private methods, wrong
signatures, moves, conflicting loans and worker effects get ordinary compiler
diagnostics. to_string/to_debug use these user methods; primitive scalars use
the explicit methods in the table instead of acquiring hidden methods.
An enum method can match its borrowed receiver to render the active payload.
print, str.repr and f-strings call the same methods, as
user formatting methods
describe; each call there starts a fresh depth of 32. Entrypoint error reports
do not call them, and anonymous sums do not automatically forward them to
members.
list_with(&values, &mut out, render) writes [a, b] using a statically typed
callback (&T, &mut Formatter[W]) -> Result[(), FormatError] for each borrowed
element. pair_with(&pair, &mut out, first, second) similarly writes (a, b)
with separate callbacks for a heterogeneous pair. Callbacks choose display or
debug and may recursively use these helpers. Other tuple arities, maps, enums
and classes compose these primitives explicitly; there is no automatic derive.
See the formatting lesson.
Display, debug, list and pair entrypoints each enter one depth level and restore
it on success or error. Depth zero permits scalars but rejects those
entrypoints. Custom recursive code must recurse through these entrypoints or
pair enter()/leave() itself; ordinary direct method recursion cannot be
policed. A custom renderer can still loop or deliberately emit unbounded work.
Errors and partial output
FormatError has InvalidSpec, LimitExceeded, RecursionLimit,
Allocation(AllocError), Output(StreamError), and OutputClosed variants.
String, scratch and sink allocation failures are Allocation(...).
Terminal stderr errors set has_failed(); later emissions on that formatter
return OutputClosed without writing. Other destinations retain their writer’s
recovery policy: a writer error does not make the formatter sticky. Allocation
and byte/depth limit failures do not set has_failed(). completed() is the exact count accepted by the
writer since construction, including a successful prefix before any error.
The writer’s positive short counts are retried, Interrupted is retried, zero
is WriteZero, and negative/oversized counts are InvalidProgress. An Err
must mean that call accepted no bytes, as required by std.io. There is no
rollback or implicit retry of an entire failed formatting operation. Retrying
it can duplicate its successful prefix. A partial OS write can split UTF-8;
finish then reports Bytes(InvalidUtf8) until valid text is restored.
The budget for text is checked for the whole UTF-8 input before output;
other emissions check each scalar, ASCII byte, or float spelling.
Limit errors never exceed it; previous output remains. Budgets count UTF-8 bytes,
escapes, delimiters and padding, not input characters. Direct writes through the
public writer field are outside the counter/budget contract. Callers must not
alter its position or contents during a formatting operation.
Formatting internals use explicit unchecked: regions for arithmetic already
bounded by validated output budgets, scratch positions, radix digit counts,
format widths/precision or recursion limits. Unrepresentable user input does
not acquire a hidden arithmetic trap: existing InvalidSpec, LimitExceeded,
allocation and output errors retain their contracts. User display/debug methods
still have their own declared arithmetic boundaries, independent of formatter
implementation modes.
Cost and availability
After construction, formatting scalars performs no allocation when the
writer does not allocate. The caller can preallocate a Cursor buffer to make
this true for in-memory streams. Caller-provided scratch is reused for scalar streaming; string
formatters may grow scratch to stage a whole text emission. String output grows explicitly and finish copies the complete bytes. A custom
renderer/callback can allocate, and its ordinary effects are not suppressed.
Text is linear in input bytes plus output bytes, integer work is bounded by 64
digits plus padding, and f64 conversion has fixed bounded scratch work plus
padding. A spec float costs time linear in its precision and magnitude digits. A padded float may be converted twice because padding reuses scratch.
List work is linear in element count plus callback work. At most one Unicode
scalar (four bytes), one ASCII byte, or one float spelling (at most 32 bytes) is
offered per write on a caller-provided writer; wrap a syscall writer in
BufWriter to batch these calls. A string formatter submits a whole text
emission to its cursor. Direct stderr text submits the unconsumed UTF-8 suffix.
Memory is caller scratch plus writer state and bounded recursive call frames.
Formatters are affine because they own Bytes; copying is rejected. Loans and
worker transfers follow the underlying writer’s normal ownership/effect rules.
The embedded module works offline with a relocated matching toolchain on the
supported x86-64 Linux GNU target and adds no external library dependency.
Acknowledgements
- Format specs follow Python’s format specification mini-language.