Formatting text: a string is a block, and so is a vec of blocks

The guide is written for (std fmt) as it is now: one thing needs no
brackets, several things are a vec, and each is needed only for a
lambda given to a function such as vec-map.

🤖 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 11:56:52 +02:00
parent 21fa7af6dc
commit 5bc42069cd

View file

@ -612,61 +612,38 @@ For one line of output, an interpolated string or @code{println*} is enough. @co
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}
@subsubsection{Blocks}
Layout is made of two types, and nearly every type error in layout code is one of them where the other is wanted:
Layout is made of blocks. A @code{Block} is a piece of output, and every function that lays something out takes its content as a block and gives back a block. These are blocks:
@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]}.
- A string, an int or a char. Written out, or the value of a variable or a call: @code{"tea"}, @code{name}, @code{(string-upcase name)}.
- A vec of blocks: the blocks one after another. @code{["x = " n nl]} is a string, an int and a newline.
- What a function in @code{(std fmt)} gives back: @code{(padded/left 5 "x")}, @code{nl}, @code{nothing}.
- An @code{if}, @code{cond}, @code{match} or @code{let} whose results are blocks.
}
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:
So one thing needs no brackets, and several things are a vec:
@codeblock[#:lang "bjolang"]|{
(def name "Ada")
(: n int)
(def n 3)
(show "Hello\n") ;; Hello
(show (padded/left 6 name)) ;; " Ada"
(show (padded/left 8 ["x = " n])) ;; " x = 3"
(show [(padded/left 5 "x") (padded/left 5 "y") nl]) ;; " x y", then a newline
(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
}|
Read @code{(padded/left 6 name)} as "pad @code{name} to 6 columns", and @code{(padded/left 8 ["x = " n])} as "pad these pieces, together, to 8 columns". A vec in a vec is a block in a block, so extra brackets change nothing.
Nothing is written until @code{show} runs the block. A block is a value: you can bind it, pass it to a function and return it.
@subsubsection{What is not a block}
Anything else is converted first:
@read-table{
@ -675,40 +652,19 @@ Anything else is converted first:
| 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 |
| A @code{(Vec string)} @code{xs} | @code{(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.
@code{BStr}, @code{BInt}, @code{BChar} and @code{BMany} are the cases of @code{Block}. You rarely write them, but like any constructor they are functions, so @code{(vec-map BStr names)} is a vec of blocks.
@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.
@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 it cannot be read 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.
@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)}.
@strong{Where a block is wanted.} A string or a vec is read as a block where the compiler knows that a block is wanted: as an argument, as what a function declared to return a @code{Block} returns, and as the value of a binding whose type is written, such as @code{(: parts Block)}. A @code{def} without a type is what its value is: @code{(def parts ["a" "b"])} is a @code{(Vec string)}, and @code{(show parts)} is refused. Write @code{(: parts Block)} above it.
@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.
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 what it returns is a block.
@codeblock[#:lang "bjolang"]|{
(type (: Item (Record (: name string) (: price double) (: stock int))))
@ -721,12 +677,12 @@ Most layout code is a function from a value of your own to a block. Give it a si
(: 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)])
[(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]))
nl])
(show [(each (vec-map item-line stock))])
(show (vec-map item-line stock))
}|
@codeblock[#:lang "text"]|{
@ -735,56 +691,48 @@ 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:
@code{vec-map} gives a vec of blocks, which is a block. A lambda given to @code{vec-map} is different from a function with a signature: @code{vec-map} does not say what its function returns, so a vec of mixed pieces that the lambda returns, such as a string and an int, is not read as blocks and is refused. Give it to @code{each}, which says that it is one:
@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))])
(show (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:
A lambda given to a function in @code{(std fmt)}, such as @code{joined}, needs no @code{each}: their signatures say that the lambda returns a block.
@code{nothing} is the block that writes nothing, for the branch that has nothing to say:
@codeblock[#:lang "bjolang"]|{
(: greeting (-> string (Option string) Block))
(defun (greeting name title)
(each ["Hello, "
["Hello, "
(match title
((Some t) (each [t " "]))
((Some t) [t " "])
(None nothing))
name "!"]))
name "!"])
(: medal (-> int Block))
(defun (medal place)
(cond ((= place 1) (BStr "gold"))
((= place 2) (BStr "silver"))
(cond ((= place 1) "gold")
((= place 2) "silver")
(else nothing)))
(show [(greeting "Ada" (Some "Dr")) nl (greeting "Bob" None) nl])
;; Hello, Dr Ada!
;; Hello, Bob!
(show [(medal 1) "/" (medal 2) "/" (medal 3) nl]) ;; gold/silver/
}|
A list is written with @code{joined}, which takes a function that makes each element's block and a separator, which is content too:
A list is written with @code{joined}, which takes a function that makes each element's block, and a separator:
@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])
(show [(joined BStr (list "tea" "milk" "honey") ", ") nl]) ;; tea, milk, honey
(show [(joined/last BStr (fun (s) ["and " s])
(list "tea" "milk" "honey") ", ") nl]) ;; tea, milk, and honey
(show [(joined (fun (s) ["* " s]) (list "tea" "milk") nl) nl])
;; * tea
;; * milk
}|
@ -794,13 +742,13 @@ A list is written with @code{joined}, which takes a function that makes each ele
@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 [(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 ]
(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:
@ -835,7 +783,7 @@ The digits are always the invariant culture's, so the machine's locale never sho
@codeblock[#:lang "bjolang"]|{
(show [(with-fmt current-width 20
[(justified ["Bjolang lays text out with blocks, and blocks nest."])])
(justified "Bjolang lays text out with blocks, and blocks nest."))
nl])
;; Bjolang lays text
;; out with blocks, and
@ -846,15 +794,15 @@ The first line starts where the cursor is, so after a label it has less room. Th
@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:
A table is the place where the brackets matter. @code{tabular} takes a vec of columns, and @code{tabular/rows} a vec of rows. Each column or row is a @code{(Vec Block)}, with a block for each cell:
@codeblock[#:lang "bjolang"]|{
(show [(tabular [["tea" nl "gaiwan" nl "kettle"]
(show (tabular [["tea" nl "gaiwan" nl "kettle"]
[3 nl 1240 nl 90]]
#:sep " " #:align [AlignLeft AlignRight])])
#:sep " " #:align [AlignLeft AlignRight]))
(show [(tabular/rows [["tea" 3] ["gaiwan" 1240] ["kettle" 90]]
#:sep " " #:align [AlignLeft AlignRight])])
(show (tabular/rows [["tea" 3] ["gaiwan" 1240] ["kettle" 90]]
#:sep " " #:align [AlignLeft AlignRight]))
}|
Both write the same table:
@ -865,12 +813,12 @@ 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:
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 a vec in the row, 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 StyleAscii)])
(show (tabular/rows [["tea" [3 " cups"]]
["kettle" ["boils in" nl "4 minutes"]]]
#:style StyleAscii))
}|
@codeblock[#:lang "text"]|{
@ -881,7 +829,7 @@ In @code{tabular} a column's cells are separated by @code{nl}. In @code{tabular/
+--------+-----------+
}|
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:
Rows from data come from a function with a signature, for the reason given above: a row of a string and an int has no element type of its own. Here is the high-score ledger from "Files and I/O" as a table:
@codeblock[#:lang "bjolang"]|{
(: score-row (-> (Tuple string int) (Vec Block)))
@ -889,10 +837,10 @@ Rows from data come from a function with a signature, for the reason given above
(def (Tuple name points) s)
[name points])
(show [(tabular/rows (vec-map score-row (read-scores))
(show (tabular/rows (vec-map score-row (read-scores))
#:titles ["Player" "Score"]
#:align [AlignLeft AlignRight]
#:style StyleAscii)])
#:style StyleAscii))
}|
@codeblock[#:lang "text"]|{
@ -910,9 +858,9 @@ A table whose rows are not all known yet, such as results that come one at a tim
@codeblock[#:lang "bjolang"]|{
(def layout (table-layout [8 6] #:sep " " #:align [AlignLeft AlignRight]))
(show [(table-row layout ["Player" "Score"])])
(show (table-row layout ["Player" "Score"]))
(loop (:for (Tuple name points) (read-scores))
(:do (show [(table-row layout [name points])])))
(:do (show (table-row layout [name points]))))
}|
@codeblock[#:lang "text"]|{
@ -949,9 +897,9 @@ The pad character, the right margin, the ellipsis and the number formats are par
@codeblock[#:lang "bjolang"]|{
(show [(with-fmt current-comma 3
[(with-fmt current-comma-sep #\space
[(with-fmt current-decimal-sep #\,
[(numeric 1234567.5)])])])
(with-fmt current-comma-sep #\space
(with-fmt current-decimal-sep #\,
(numeric 1234567.5))))
nl])
;; 1 234 567,5
}|
@ -965,32 +913,27 @@ 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 a double | @code{(numeric x)} |
| Block: ... found the number 1.5 | @code{(numeric 1.5)} |
| Block: ... found a boolean | @code{(if b "yes" "no")} |
| Block: ... found a boolean, or found a bool | @code{(if b "yes" "no")} |
| Block: ... found a (Vec string) | @code{(vec-map BStr xs)}, or @code{(: xs Block)} above @code{(def xs ...)} |
| 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])} |
| Block: ... found a (Vec (Vec ...)), at a lambda that returns a vec | @code{(each [...])} around the vec the lambda returns |
}
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))
(: boxed (-> Block Block))
(defun (boxed b)
(captured (fun (s) ["[" s "]"]) b))
(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.
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-block} runs a block inside one. The module's page describes both.
@subsection{Concurrency}