Formatting text: a guide to std/fmt and its blocks

A section in "Writing programs" on (std fmt): what a block and a vec of
blocks are, what goes in the brackets and how to convert the rest,
blocks from data, padding, numbers, paragraphs, tables, settings, and
what to write when it does not compile. The std/fmt entry in Part III
gives the rule and points to it, and no longer names str*.

🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-opus-5-5)

Co-Authored-By: eca-agent <git@eca.dev>
This commit is contained in:
Linus Björnstam 2026-10-20 10:48:12 +02:00
parent 21d0c9de06
commit aaf92b38c1
2 changed files with 394 additions and 3 deletions

View file

@ -599,6 +599,399 @@ Prefixing an identifier with a comma (e.g. @code{,who}) splices the variable's v
All of these functions return a @code{Result} and execute synchronously, blocking the calling thread until completion. When running inside a bjoroutine, non-blocking asynchronous alternatives are available; refer to the @code{(std run)} module documentation for details.
@subsection{Formatting text}
For one line of output, an interpolated string or @code{println*} is enough. @code{println*} is in @code{(std fmt)}, and takes strings, ints, doubles, chars and bools in one vec:
@codeblock[#:lang "bjolang"]|{
(import (std fmt))
(println #"${who} has ${points} points") ;; ada has 31 points
(println* [who " has " points " points"]) ;; ada has 31 points
}|
When the shape of the output matters, such as columns that line up, padding, a paragraph wrapped to a width or a table, use the layout part of @code{(std fmt)}. This section is a guide to it. The module's page has the reference.
@subsubsection{A block and a vec of blocks}
Layout is made of two types, and nearly every type error in layout code is one of them where the other is wanted:
@read-list{
- A @code{Block} is one piece of output: a string, an int, a char, or a combinator such as @code{(padded/left 5 ["x"])}.
- A @code{(Vec Block)} is pieces of output in order, written as a vec literal: @code{["x = " 7 nl]}.
}
There is one rule. Every combinator takes its content as a @code{(Vec Block)} and gives back one @code{Block}. @code{show}, @code{render} and @code{render->string}, which write the output, take a @code{(Vec Block)}. So the brackets go around content, and a combinator call is one element in them:
@codeblock[#:lang "bjolang"]|{
(show [(padded/left 5 ["x"])]) ;; " x"
(show [(padded/left 5 ["x"]) (padded/left 5 ["y"]) nl]) ;; " x y", then a newline
(show ["Total:" (padded/left 8 [42]) nl]) ;; "Total: 42"
}|
Read @code{(padded/left 5 ["x"])} as "pad this content to 5 columns". The content is a vec even when it is one thing, as the body of a @code{begin} is a body even when it is one form. These are refused:
@codeblock[#:lang "bjolang"]|{
(show (padded/left 5 "x")) ;; "x" is not content, and show wants a vec
(show (padded/left 5 ["x"])) ;; show wants a vec, and this is one block
(show [["x"]]) ;; a vec in a vec: a vec is not a block
}|
More brackets do not help. A vec of vecs is right in one place only: a table, which is a vec of rows or of columns, each of them a @code{(Vec Block)}.
Nothing is written until @code{show} runs the blocks. A block is a value: you can bind it, pass it to a function and return it.
@subsubsection{What goes in the brackets}
A vec literal is read element by element when it stands where a @code{(Vec Block)} is wanted: as an argument of a function that takes one, as the return value of a function declared to return one, or as the value of a binding whose type is written. Each element is one of these:
@read-table{
| Element | Is read as |
|--------------------------------------------------------------------------------+---------------------------------------------|
| A string, an int or a char: a literal, a variable or a call | @code{(BStr s)}, @code{(BInt n)}, @code{(BChar c)} |
| A @code{Block}: @code{nl}, @code{nothing}, a combinator call, a variable of type @code{Block} | itself |
| An @code{if}, @code{cond}, @code{match} or @code{let} whose results are any of these | the result it gives |
}
So variables and calls go in as they are:
@codeblock[#:lang "bjolang"]|{
(def name "Ada")
(: n int)
(def n 3)
(show ["Hello, " name "! You have " n " new messages" #\. nl])
;; Hello, Ada! You have 3 new messages.
(show [(string-upcase name) " " (vec-length [1 2 3]) nl]) ;; ADA 3
(show ["Paid: " (if paid? "yes" "no") nl]) ;; Paid: yes
(show ["Title: " (match title ((Some t) t) (None "-")) nl]) ;; Title: Dr
}|
Anything else is converted first:
@read-table{
| You have | Write |
|-----------------------------------+---------------------------------------------------------------|
| A double @code{x} | @code{(numeric x)}, or @code{(numeric x #:precision 2)} |
| A bool @code{b} | @code{(if b "yes" "no")} |
| A long @code{n} | @code{(long->string n)}, or @code{(bytes n)} for a size |
| A @code{(Vec Block)} @code{bs} | @code{(each bs)} |
| A vec literal | @code{(each [...])}, or its elements in the outer vec |
| A @code{(Vec string)} @code{xs} | @code{(each (vec-map BStr xs))} |
| A @code{(List %a)} @code{xs} | @code{(joined f xs [", "])}, where @code{f} makes the block of one element |
}
@strong{A number bound by @code{def}.} The @code{(: n int)} above is needed. @code{(def n 3)} alone does not say which kind of number @code{n} is, as it could still become a long or a double, so @code{[n]} cannot read it as a block, and the compiler says so at @code{n}. The same holds for @code{(def xs [1 2 3])} and @code{(def total (+ 1 2))}. A number that comes from a typed parameter, a record field or a function such as @code{vec-length} has its type already, and a number written in the vec itself, as in @code{["n = " 3]}, is an int.
@strong{A vec bound by @code{def}.} @code{(def parts ["a" "b"])} is a @code{(Vec string)}, which is not a @code{(Vec Block)}: write its type, and it is read as blocks.
@codeblock[#:lang "bjolang"]|{
(: parts (Vec Block))
(def parts ["a" 1 #\b])
(show parts) ;; a1b
(show ["<" (each parts) ">" nl]) ;; <a1b>
}|
@subsubsection{From one type to the other}
@read-table{
| You have | You want a @code{Block} | You want a @code{(Vec Block)} |
|-------------------------+-------------------------------------+-------------------------------|
| a string @code{s} | @code{(BStr s)} | @code{[s]} |
| an int @code{n} | @code{(BInt n)} | @code{[n]} |
| a block @code{b} | @code{b} | @code{[b]} |
| blocks @code{bs} | @code{(each bs)} | @code{bs} |
| pieces | @code{(each [name ": " n])} | @code{[name ": " n]} |
}
Outside a vec literal nothing is converted: a function declared to return a @code{Block} returns @code{(BStr s)} or @code{(each [s])}, not @code{s}. @code{BStr}, @code{BInt} and @code{BChar} are the cases of @code{Block}, and like any constructor they are functions, so @code{(vec-map BStr names)} is a @code{(Vec Block)}.
@subsubsection{Blocks from your data}
Most layout code is a function from a value of your own to a block. Give it a signature: the signature is what tells the compiler that the literal inside is blocks.
@codeblock[#:lang "bjolang"]|{
(type (: Item (Record (: name string) (: price double) (: stock int))))
(: stock (Vec Item))
(def stock
[(Item (name "Green tea") (price 3.5) (stock 12))
(Item (name "Gaiwan") (price 1240.0) (stock 2))
(Item (name "Kettle") (price 89.9) (stock 0))])
(: item-line (-> Item Block))
(defun (item-line i)
(each [(padded/right 10 [(record-ref i name)])
(padded/left 9 [(numeric (record-ref i price) #:precision 2 #:comma 3)])
(if (= (record-ref i stock) 0) " sold out" nothing)
nl]))
(show [(each (vec-map item-line stock))])
}|
@codeblock[#:lang "text"]|{
Green tea 3.50
Gaiwan 1,240.00
Kettle 89.90 sold out
}|
@code{item-line} returns one block, so its pieces are under @code{each}. @code{vec-map} gives a vec of blocks, and @code{each} makes that one block too. A function can return content instead, a @code{(Vec Block)}, and the caller decides where it goes:
@codeblock[#:lang "bjolang"]|{
(: labelled (-> string int (Vec Block)))
(defun (labelled label n)
[label ": " n nl])
(show (labelled "cups" 3)) ;; cups: 3
(show [">> " (each (labelled "pots" 1))]) ;; >> pots: 1
}|
A lambda has no signature, so a vec literal returned from one is not read as blocks: in @code{(vec-map (fun (i) [(record-ref i name) (record-ref i stock)]) stock)} the vec has a string and an int in it, and is refused. Put the literal under @code{each}, which says that it is content, or write a function with a signature:
@codeblock[#:lang "bjolang"]|{
(show [(each (vec-map (fun (i) (each [(record-ref i name) ": " (record-ref i stock) nl]))
stock))])
;; Green tea: 12
;; Gaiwan: 2
;; Kettle: 0
}|
@code{nothing} is the block that writes nothing, for the branch that has nothing to say. In a vec literal, what an @code{if}, a @code{match} or a @code{cond} gives is read as any element is, so an arm can be a string. In a function declared to return a @code{Block}, nothing is converted, so there the arms are blocks:
@codeblock[#:lang "bjolang"]|{
(: greeting (-> string (Option string) Block))
(defun (greeting name title)
(each ["Hello, "
(match title
((Some t) (each [t " "]))
(None nothing))
name "!"]))
(: medal (-> int Block))
(defun (medal place)
(cond ((= place 1) (BStr "gold"))
((= place 2) (BStr "silver"))
(else nothing)))
(show [(greeting "Ada" (Some "Dr")) nl (greeting "Bob" None) nl])
;; Hello, Dr Ada!
;; Hello, Bob!
}|
A list is written with @code{joined}, which takes a function that makes each element's block and a separator, which is content too:
@codeblock[#:lang "bjolang"]|{
(show [(joined BStr (list "tea" "milk" "honey") [", "]) nl]) ;; tea, milk, honey
(show [(joined/last BStr (fun (s) (each ["and " s]))
(list "tea" "milk" "honey") [", "]) nl]) ;; tea, milk, and honey
(show [(joined (fun (s) (each ["* " s])) (list "tea" "milk") [nl]) nl])
;; * tea
;; * milk
}|
@subsubsection{Padding, cutting and leaders}
@code{padded/left}, @code{padded/right} and @code{padded/both} pad content to a width: aligned right, left, or centred. Padding never cuts. @code{trimmed/left}, @code{trimmed/right} and @code{trimmed/both} cut content to a width, and keep its end, its start or its middle. @code{fitted/left}, @code{fitted/right} and @code{fitted/both} do both, so the content is exactly the width.
@codeblock[#:lang "bjolang"]|{
(show [(padded/right 8 [who]) (padded/left 4 [points]) nl]) ;; ada 31
(show [(with-pad #\0 [(padded/left 4 [42])]) nl]) ;; 0042
(show [(with-pad #\= [(padded/both 20 [" Receipt "])]) nl]) ;; ===== Receipt ======
(show [(trimmed/left 6 ["/home/ada/scores.txt"]) nl]) ;; es.txt
(show [(with-fmt current-ellipsis "…"
[(trimmed/left 12 ["/home/ada/scores.txt"])]) nl]) ;; …/scores.txt
(show ["[" (fitted/both 6 ["ab"]) "]" nl]) ;; [ ab ]
}|
@code{space-to} pads to a column, whatever was written before it, and @code{with-pad} changes what the padding is made of. Together they make a leader:
@codeblock[#:lang "bjolang"]|{
(show [(with-pad #\. ["Tea" (space-to 12) "3.50"]) nl
(with-pad #\. ["Gaiwan" (space-to 12) "1240.00"]) nl])
;; Tea.........3.50
;; Gaiwan......1240.00
}|
@code{nl} is a newline, and @code{fl} a newline unless the cursor is already at the start of a line.
@subsubsection{Numbers}
An int is a block. A double is not, as there is no one way to write one: @code{numeric} says how, with a precision, digits in groups, and the separators. @code{bytes} and @code{percent} are blocks for two common kinds of number.
@codeblock[#:lang "bjolang"]|{
(show [(numeric 1234567.891 #:precision 2 #:comma 3) " "
(numeric 0.5 #:precision 3) " "
(numeric 42.0 #:signed #t) " "
(percent 0.125) " "
(bytes 1500L #:si #t) nl])
;; 1,234,567.89 0.500 +42 12.5% 1.5 kB
}|
The digits are always the invariant culture's, so the machine's locale never shows. The separators are settings, described below.
@subsubsection{Paragraphs}
@code{wrapped} fills content to @code{current-width}, and @code{justified} makes it flush against both margins. Every run of whitespace in the content is a break between words.
@codeblock[#:lang "bjolang"]|{
(show [(with-fmt current-width 20
[(justified ["Bjolang lays text out with blocks, and blocks nest."])])
nl])
;; Bjolang lays text
;; out with blocks, and
;; blocks nest.
}|
The first line starts where the cursor is, so after a label it has less room. The lines after it start at column 0.
@subsubsection{Tables}
A table is the one place where a vec of vecs is right. @code{tabular} takes a vec of columns, and @code{tabular/rows} a vec of rows. Each column or row is a @code{(Vec Block)}, so the inner vecs are read as blocks as any other content is:
@codeblock[#:lang "bjolang"]|{
(show [(tabular [["tea" nl "gaiwan" nl "kettle"]
[3 nl 1240 nl 90]]
#:sep " " #:align [AlignLeft AlignRight])])
(show [(tabular/rows [["tea" 3] ["gaiwan" 1240] ["kettle" 90]]
#:sep " " #:align [AlignLeft AlignRight])])
}|
Both write the same table:
@codeblock[#:lang "text"]|{
tea 3
gaiwan 1240
kettle 90
}|
In @code{tabular} a column's cells are separated by @code{nl}. In @code{tabular/rows} each element of a row is one cell, so a cell of several pieces is under @code{each}, and a newline in a cell gives the row another line:
@codeblock[#:lang "bjolang"]|{
(show [(tabular/rows [["tea" (each [3 " cups"])]
["kettle" (each ["boils in" nl "4 minutes"])]]
#:style StyleLight)])
}|
@codeblock[#:lang "text"]|{
┌────────┬───────────┐
│ tea │ 3 cups │
│ kettle │ boils in │
│ │ 4 minutes │
└────────┴───────────┘
}|
Rows from data come from a function with a signature, for the reason given above. Here is the high-score ledger from "Files and I/O" as a table:
@codeblock[#:lang "bjolang"]|{
(: score-row (-> (Tuple string int) (Vec Block)))
(defun (score-row s)
(def (Tuple name points) s)
[name points])
(show [(tabular/rows (vec-map score-row (read-scores))
#:titles ["Player" "Score"]
#:align [AlignLeft AlignRight]
#:style StyleRounded)])
}|
@codeblock[#:lang "text"]|{
╭────────┬───────╮
│ Player │ Score │
├────────┼───────┤
│ ada │ 31 │
│ bo │ 27 │
╰────────┴───────╯
}|
@code{#:style} takes a @code{TableStyle}: @code{StyleNone}, the default, @code{StyleSimple}, @code{StyleAscii}, @code{StyleLight}, @code{StyleHeavy}, @code{StyleDouble}, @code{StyleRounded}, @code{StyleMarkdown}, or @code{StyleCustom} with pieces of your own. @code{#:align} takes an @code{Align} for each column, from the first: @code{AlignLeft}, @code{AlignRight}, @code{AlignCentre}, or @code{(AlignOn #\.)}, which puts every decimal point of the column in one place. @code{#:widths} and @code{#:overflow} make a table fit @code{current-width}, by cutting or wrapping its cells.
A table whose rows are not all known yet, such as results that come one at a time, is laid out a row at a time with @code{table-layout} and @code{table-row}. The widths are fixed beforehand, so each row is written when it is known:
@codeblock[#:lang "bjolang"]|{
(def layout (table-layout [8 6] #:sep " " #:align [AlignLeft AlignRight]))
(show [(table-row layout ["Player" "Score"])])
(loop (:for (Tuple name points) (read-scores))
(:do (show [(table-row layout [name points])])))
}|
@codeblock[#:lang "text"]|{
Player Score
ada 31
bo 27
}|
A table can also be a quoted literal, which the compiler checks where it is written. A column can say what kind of number it holds:
@codeblock[#:lang "bjolang"]|{
(show-table '(table (style light) header
(column "Player")
(column "Score" (align right))
(column "Share" (percent (precision 0)))
(rows (row "ada" 31 0.46) (row "bo" 27 0.40) (row "cy" 9 0.13))))
}|
@codeblock[#:lang "text"]|{
┌────────┬───────┬───────┐
│ Player │ Score │ Share │
├────────┼───────┼───────┤
│ ada │ 31 │ 46% │
│ bo │ 27 │ 40% │
│ cy │ 9 │ 13% │
└────────┴───────┴───────┘
}|
The rows can be spliced, @code{(rows ,scores)}, from a @code{(Vec (Vec TableCell))}. See @code{Table} on the module's page.
@subsubsection{Settings}
The pad character, the right margin, the ellipsis and the number formats are parameters. @code{with-fmt} binds one around some blocks while they run, and @code{with-pad} is @code{with-fmt} for @code{current-pad}. Settings nest:
@codeblock[#:lang "bjolang"]|{
(show [(with-fmt current-comma 3
[(with-fmt current-comma-sep #\space
[(with-fmt current-decimal-sep #\,
[(numeric 1234567.5)])])])
nl])
;; 1 234 567,5
}|
The settings are @code{current-pad}, @code{current-width}, @code{current-ellipsis}, @code{current-string-width}, @code{current-precision}, @code{current-comma}, @code{current-comma-sep}, @code{current-decimal-sep} and @code{current-signed}. For text with wide characters or colours in it, which a terminal shows in more or fewer columns than it has characters, set @code{current-string-width} to @code{terminal-width}.
@subsubsection{When it does not compile}
The messages for the common mistakes, and what to write instead:
@read-table{
| The compiler says | Write |
|----------------------------------------------------------------------+-----------------------------------------------------|
| expected @code{(Vec fmt/Block)}, found @code{string} | @code{["x"]} for @code{"x"} |
| expected @code{(Vec fmt/Block)}, found @code{fmt/Block} | @code{[b]} for @code{b} |
| expected @code{fmt/Block}, found @code{(Vec ...)} | @code{(each bs)} for @code{bs} |
| They differ inside the Vec: @code{fmt/Block} against @code{string} | @code{(: bs (Vec Block))} above @code{(def bs ...)} |
| Block: expected a string, an int, a char ...; found a vec | @code{(each [...])}, or no inner brackets |
| Block: ... found the number 1.5 | @code{(numeric 1.5)} |
| Block: ... found a boolean | @code{(if b "yes" "no")} |
| Block: 'n' is a number, and nothing says yet whether it is an int ... | @code{(: n int)} above @code{(def n 3)} |
| what @code{f} returns has the wrong type: expected @code{fmt/Block}, found @code{string} | @code{(BStr s)} or @code{(each [s])} |
}
When none of these fits, find the place where a @code{Block} and a @code{(Vec Block)} meet, and check it against the rule: content in brackets, one block out.
@subsubsection{A block of your own}
@code{captured} renders content to a string and gives it to a function, which returns the block to write in its place. It is how a block that needs the whole of its content is made, and how padding and trimming are written:
@codeblock[#:lang "bjolang"]|{
(: boxed (-> (Vec Block) Block))
(defun (boxed bs)
(captured (fun (s) (each ["[" s "]"])) bs))
(show [(boxed ["x = " 7]) nl]) ;; [x = 7]
}|
A block that writes as it goes is a @code{BRun}: a function given the sink to write to and the column the cursor is at, which returns the column it ends at. @code{run-blocks} runs content inside one. The module's page describes both.
@subsection{Concurrency}
Bjolang's concurrency model rests on three foundations: lightweight fibers called *bjoroutines*, communication channels, and structured concurrency scopes ensuring background work is cleanly contained. In addition, Bjolang adopts the Concurrent ML (CML) paradigm, where communication operations—such as sending or receiving on a channel—are first-class values that can be composed and combined before being synchronized.

View file

@ -19,9 +19,7 @@ For more details, see @link["modules/std/array.html"]{@code{(std array)}} and @l
@subsection{std/fmt}
Formatting in Bjolang is divided into two layers: interpolation and layout.
Interpolation provides a simple way to combine mixed scalar types into strings or print them directly using @code{println*} and @code{str*}.
The layout layer provides a composable block algebra for more complex text formatting, such as padding, aligning, wrapping paragraphs, and laying out tables.
Formatting in two layers: interpolation and layout. @code{println*} prints strings, ints, doubles, chars and bools written in one vec. The layout layer is made of blocks: a @code{Block} is one piece of output, and every combinator takes its content as a @code{(Vec Block)}, a vec literal such as @code{["x = " 7 nl]}, and gives back one @code{Block}. With them you pad, cut and align text, wrap and justify paragraphs, format numbers, and lay out tables with borders, a header, and columns that fit the width. The section on formatting text in "Writing programs" is a guide, with what goes in the brackets and what to write when it does not compile.
For more details, see @link["modules/std/fmt.html"]{@code{(std fmt)}}.
@subsection{std/rx}