Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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]:

MethodForm
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.

MethodOptions
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 nAs 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