pretty-fast-pretty-printer

A linear time pretty printing library, ported from brownplt/pretty-fast-pretty-printer

pretty
printing
pretty-printer
formatting
layout
moon add marianoguerra/pretty-fast-pretty-printer@0.2.1
Download zip
Version
0.2.1
License
MIT
Last updated
12 days ago
Downloads
18
README

#pretty-fast-pretty-printer

A linear-time pretty printing library for MoonBit.

This is a port of Brown PLT's JavaScript pretty-fast-pretty-printer. The layout algorithm and the observable output are the same; the API has been reshaped to fit MoonBit.

#Installing

moon add marianoguerra/pretty-fast-pretty-printer

The package name contains dashes, so it cannot be referenced under its default alias. Give it one in the moon.pkg of each package that uses it:

import { "marianoguerra/pretty-fast-pretty-printer" @pp, }

and then reach for it as @pp.txt("..."), @pp.vert([...]), and so on. The examples below drop the prefix for readability, since they run inside this module's own tests.

#What it is for

Pretty printing is printing source code nicely. Walking an AST and emitting text is easy; the hard part is deciding where to break long lines, and keeping indentation correct once you do. Doing that by hand is tedious, and it is very easy to write code that takes exponential time.

With this library you build a Doc — a description of all the ways your program may be laid out — and then ask for the one that fits a given width:

///|
test {
let cond = txt("x > 0")
let body = txt("print(x)")
let doc = vert([
horz([txt("while ("), cond, txt(") {")]),
horz([txt(" "), body]),
txt("}"),
])
inspect(
doc.display_string(width=80),
content=(
#|while (x > 0) {
#| print(x)
#|}
),
)
}

Choosing the layout takes a single left-to-right pass over the document, so printing is linear in the size of the document no matter how deeply the choices nest.

#The four combinators

Almost everything can be built from four of them.

txt(s) prints s. It is the only way plain text enters a document, and s may not contain a newline (use txt_lines or vert for that).

vert([...]) stacks documents vertically, indenting each one to the column the first one started at.

///|
test {
inspect(
vert([txt("Hello,"), txt("world!")]).display_string(),
content=(
#|Hello,
#|world!
),
)
}

horz([...]) joins documents horizontally: the next one starts where the previous one ended, and lines up with the last line of the previous one.

///|
test {
let doc = horz([txt("BEGIN "), vert([txt("first line"), txt("second line")])])
inspect(
doc.display_string(),
content=(
#|BEGIN first line
#| second line
),
)
}

if_flat(flat, broken, reserve=0) chooses. It uses flat if flat contains no line breaks and fits in the remaining width; otherwise it uses broken.

///|
test {
let doc = if_flat(
txt("[1, 2, 3]"),
vert([txt("[1,"), txt(" 2,"), txt(" 3]")]),
)
inspect(doc.display_string(width=20), content="[1, 2, 3]")
inspect(
doc.display_string(width=5),
content=(
#|[1,
#| 2,
#| 3]
),
)
}

The trick that makes this fast: every Doc caches the width it would occupy if rendered flat, computed once when it is built. if_flat compares that cached number against the space left on the line, so it never renders a branch it throws away. doc.flat_width() reads that cached number: Some(n) if the document can be flattened, None if it cannot.

Deciding in one left-to-right pass means if_flat cannot see what comes after it. When you know — because you are about to append two closing parens, say — reserve tells it:

///|
test {
let flat = txt("[1, 2]")
let broken = vert([txt("[1,"), txt(" 2]")])
// The group fits in seven columns, so it stays flat and the `))` overflows.
inspect(
horz([if_flat(flat, broken), txt("))")]).display_string(width=7),
content="[1, 2]))",
)
// Told that two more columns follow, it breaks instead.
inspect(
horz([if_flat(flat, broken, reserve=2), txt("))")]).display_string(width=7),
content=(
#|[1,
#| 2]))
),
)
}

#The rest of the API

concat([...]) is like horz, but keeps the indentation fixed instead of following the last line. You will almost always want horz.

nest(n, doc) indents every line doc breaks onto by n further columns, leaving the line it starts on alone. It is how you indent a document you did not build yourself: prefixing spaces with horz would indent the first line too.

///|
test {
let given = vert([txt("first line"), txt("second line")])
inspect(
nest(2, given).display_string(),
content=(
#|first line
#| second line
),
)
inspect(
horz([txt(" "), given]).display_string(),
content=(
#| first line
#| second line
),
)
}

full_line(doc) says nothing may follow doc on its line. It works by declaring doc un-flattenable, so any enclosing if_flat rejects the branch containing it.

horz2, vert2 and concat2 are the two-document forms, for folding without allocating a throwaway array at every step.

display(width=...) returns one string per line; display_string(width=...) returns them joined by newlines, and is the cheaper of the two. write_to(buf,width=...) appends into a StringBuilder you already have. All default to a width of 80.

empty is the empty document.

#Capping indentation

All three take an optional max_indent: the furthest column the printer may indent a line to. Left unset, indentation is unbounded — horz indents to wherever its left operand ended, so a deeply nested document drifts right until there is no width left for its contents.

///|
test {
let doc = horz([txt("(let ((x "), vert([txt("first"), txt("second")])])
assert_eq(doc.display(), ["(let ((x first", " second"])
assert_eq(doc.display(max_indent=4), ["(let ((x first", " second"])
}

The usual reason to want this is to guarantee every line a minimum of usable columns: display(width=80, max_indent=70) always leaves ten for content. It has to be given at render time — a Doc has no columns until it is rendered, so this is not something a document can say about itself.

The cap only moves lines the printer starts; text already on a line has settled the column the rest of that line continues from. Indentation levels past the cap collapse onto it, so nesting deeper than the cap stops being visible — that is the trade. In exchange, a line pulled leftwards has more columns free, so groups on it that would have been broken can fit flat.

#Measuring text that is not plain ASCII

txt measures text in UTF-16 code units. That is exact for ASCII, and wrong whenever a string's width on screen differs from its encoded length — double-width characters, or escape sequences that occupy no columns at all. txt_as(text, width) states the width instead of guessing it:

///|
test {
// A CJK character is one UTF-16 unit but occupies two columns.
assert_eq(txt_as("世界", 4).flat_width(), Some(4))
// An ANSI colour escape is five UTF-16 units and occupies none, so a
// syntax-highlighted document lays out exactly like the plain one.
let red = txt_as("\u{1b}[31m", 0)
let reset = txt_as("\u{1b}[0m", 0)
assert_eq(horz([red, txt("hello"), reset]).flat_width(), Some(5))
}

#Text whose layout is already decided

txt_raw(s) writes s through verbatim: newlines in it start a new line at column 0 and nothing is re-indented. Use it for block comments, here-documents and multi-line string literals — text that came from elsewhere and must survive unchanged. Contrast txt_lines(s), which lines continuations up with where the text started:

///|
test {
inspect(
horz([txt("x = "), txt_lines("a\nb")]).display_string(),
content=(
#|x = a
#| b
),
)
inspect(
horz([txt("x = "), txt_raw("a\nb")]).display_string(),
content=(
#|x = a
#|b
),
)
}

A txt_raw containing a newline has no flat width, so like vert it can never be chosen as the flat branch of an if_flat.

#Building documents from templates

The JavaScript original uses a tagged template literal. MoonBit has no such thing, so pretty takes the pieces explicitly: literal text is cut at every newline, the lines are joined with vert, and each line's pieces with horz.

///|
test {
let c = txt("a == b")
let t = txt("a << 2")
let e = txt("a + b")
let doc = pretty([
Str("if ("),
Val(c),
Str(") {\n "),
Val(t),
Str("\n} else {\n "),
Val(e),
Str("\n}"),
])
inspect(
doc.display_string(),
content=(
#|if (a == b) {
#| a << 2
#|} else {
#| a + b
#|}
),
)
}

txt_lines(s) is the one-piece shorthand: a txt that accepts newlines.

#Utility constructors

wrap(words, sep=..., vert_sep=...) does word wrapping. Each gap is an independent choice, so it fills lines greedily.

///|
test {
let words = ["This", "is", "a", "sentence", "with", "eight", "words"].map(txt)
inspect(
wrap(words).display_string(width=20),
content=(
#|This is a sentence
#|with eight words
),
)
}

sep_by(items, sep=..., vert_sep=...) is all-or-nothing: either everything fits on one line joined by sep, or every item goes on its own line.

///|
test {
let doc = sep_by(["alpha", "beta", "gamma"].map(txt), sep=", ", vert_sep=",")
inspect(doc.display_string(width=40), content="alpha, beta, gamma")
inspect(
doc.display_string(width=10),
content=(
#|alpha,
#|beta,
#|gamma
),
)
}

Note that vert_sep is written at the current column before the line breaks, so it can overhang the width by its own length. This matches the original.

parens(doc), standard_sexpr(func, args), lambda_like_sexpr(keyword, defn,body) and begin_like_sexpr(keyword, bodies) cover common s-expression layouts:

///|
test {
let doc = begin_like_sexpr(txt("begin"), [
standard_sexpr(txt("+"), [txt("1"), txt("2")]),
standard_sexpr(txt("display"), [txt("a-fairly-long-variable-name")]),
])
inspect(
doc.display_string(width=30),
content=(
#|(begin
#| (+ 1 2)
#| (display
#| a-fairly-long-variable-name))
),
)
}

#Differences from the JavaScript version

  • No variadic functions, so horz, vert and concat take an Array[Doc] rather than a spread of arguments. The horzArray / vertArray / concatArray variants are therefore unnecessary and do not exist.
  • No implicit coercion. JavaScript accepts a bare string (or any object with a .pretty() method) anywhere a document is expected; here you write txt("..."). Give your own types a to_doc method and call it.
  • The pretty`...` tagged template becomes pretty([Str(...), Val(...)]).
  • txt panics on embedded newlines instead of throwing, and concat([]) returns empty instead of raising.
  • parens is public here; upstream keeps it internal.
  • txt measures widths in UTF-16 code units, as in JavaScript, which is exact for ASCII source code. Unlike upstream, txt_as lets you override that when it is not.
  • nest, txt_as, txt_raw, flat_width, write_to, if_flat's reserve and the horz2 / vert2 / concat2 forms have no upstream equivalent.

#Running it

moon test # unit tests, including the ported upstream suite moon run cmd/main # a small s-expression printer, shown at three widths

#License

MIT, matching the original JavaScript library (© 2019 Brown University PLT).

#
Doc

A pretty printing document: a description of all the ways a piece of text may be laid out. Build one with the combinators in this package, then call [Doc::display] to pick a layout that fits a given width.

The layout is chosen in a single left-to-right pass, so printing is linear in the size of the document.

#
Doc::display

fn Doc::display(self : Doc, width? : Int, max_indent? : Int) -> Array[String]

Pretty print this document within the given width, returning one string per line. Lines are never padded on the right, but continuation lines carry their indentation as leading spaces.

width is a target, not a guarantee: a document containing text longer than width will overflow, because there is nothing else to be done with it.

test {
let doc = vert([txt("one"), horz([txt(" "), txt("two")])])
assert_eq(doc.display(), ["one", " two"])
}

max_indent caps indentation; see [Doc::write_to].

#
Doc::display_string

fn Doc::display_string(self : Doc, width? : Int, max_indent? : Int) -> String

Like [Doc::display], but as a single string with the lines joined by "\n". This is the cheaper of the two: it is what the renderer produces directly, and [Doc::display] splits it.

test {
inspect(
vert([txt("one"), txt("two")]).display_string(),
content=(
#|one
#|two
),
)
}

max_indent caps indentation; see [Doc::write_to].

#
Doc::flat_width

fn Doc::flat_width(self : Doc) -> Int?

The width this document occupies when rendered flat, i.e. all on one line, or None when it cannot be rendered flat at all because it contains a [vert], a [full_line] or a [txt_raw] with a newline in it.

This is precomputed at construction time rather than measured on demand, so asking is free. It answers the two questions a caller assembling documents keeps needing — "can this be flattened?" and "how wide is it flat?" — which is what [if_flat] decides from:

test {
assert_eq(horz([txt("ab"), txt("cd")]).flat_width(), Some(4))
assert_eq(vert([txt("ab"), txt("cd")]).flat_width(), None)
}

#
Doc::write_to

fn Doc::write_to(self : Doc, buf : StringBuilder, width? : Int, max_indent? : Int) -> Unit

Pretty print this document into an existing [StringBuilder], appending to whatever is already there.

Use this to assemble output without a string per document:

test {
let buf = StringBuilder::new()
buf.write_string("> ")
vert([txt("one"), txt("two")]).write_to(buf)
inspect(
buf.to_string(),
content=(
#|> one
#|two
),
)
}

Note that the document is laid out from column 0 regardless of what the buffer already contains — a StringBuilder has no notion of a column. Wrap the document in a [horz] or [nest] if you need it indented.

Capping indentation

max_indent is the furthest column the printer may indent a line to. Left unset, indentation is unbounded: [horz] indents to wherever its left operand ended, so deeply nested documents drift right until the width left for their contents is gone.

test {
let doc = horz([txt("(let ((x "), vert([txt("first"), txt("second")])])
assert_eq(doc.display(), ["(let ((x first", " second"])
assert_eq(doc.display(max_indent=4), ["(let ((x first", " second"])
}

The usual reason to want this is to guarantee every line a minimum of usable columns — display(width=80, max_indent=70) leaves ten for content, no matter how deep the document goes. It cannot be expressed as part of the document, because a Doc has no columns until it is rendered.

Two things to know about what the cap does:

  • It only moves lines the printer starts. Text already written to a line has fixed the column the rest of that line continues from, so a long first line still runs as far right as its contents take it.
  • Indentation levels past the cap collapse onto it, so nesting that would have been visible in the output no longer is. That is the trade the cap makes: the layout comes out less indented than the document asked for.

The cap also feeds back into the layout. A line pulled leftwards has more columns free, so groups on it that would have been broken can fit flat:

test {
let group = if_flat(
txt("[1, 2, 3]"),
vert([txt("[1,"), txt(" 2,"), txt(" 3]")]),
)
let doc = horz([txt(" "), vert([txt("xs ="), group])])
// At width 16 the group starts in column 8 and does not fit.
assert_eq(doc.display(width=16), [
" xs =", " [1,", " 2,", " 3]",
])
// Capped at column 2 it does.
assert_eq(doc.display(width=16, max_indent=2), [" xs =", " [1, 2, 3]"])
}

#
Piece

pub(all) enum Piece {
Str(String)
Val(Doc)
} derive(
Debug
)

One piece of a [pretty] template: either literal text (which may contain newlines) or an embedded document.

#
begin_like_sexpr

fn begin_like_sexpr(keyword : Doc, bodies : Array[Doc]) -> Doc

begin_like_sexpr(keyword, bodies) always breaks, rendering as

(keyword bodies ... bodies)

test {
let doc = begin_like_sexpr(txt("begin"), ["1", "2", "3"].map(txt))
inspect(
doc.display_string(),
content=(
#|(begin
#| 1
#| 2
#| 3)
),
)
}

#
concat

fn concat(docs : Array[Doc]) -> Doc

concat([doc1, doc2, ...]) naively concatenates documents from left to right. It is like [horz], except the indentation level stays fixed instead of following the last line of the previous document.

test {
let doc = concat([
txt("BEGIN "),
vert([txt("first line"), txt("second line")]),
])
inspect(
doc.display_string(),
content=(
#|BEGIN first line
#|second line
),
)
}

You should almost always prefer [horz]. An empty array yields [empty].

#
concat2

fn concat2(doc1 : Doc, doc2 : Doc) -> Doc

concat2(doc1, doc2) is [concat] on two documents. See [horz2].

test {
let docs = ["a", "b", "c"].map(txt)
inspect(docs.fold(init=empty, concat2).display_string(), content="abc")
}

#
empty

let empty : Doc

The empty document. Equivalent to txt("").

#
full_line

fn full_line(doc : Doc) -> Doc

full_line(doc) ensures that nothing is placed after doc on the same line, if at all possible.

It does this by declaring that doc has no flat width, so any enclosing [if_flat] will reject the branch containing it.

test {
let plain = horz([if_flat(txt("a"), vert([txt("a"), txt("A")])), txt("b")])
inspect(plain.display_string(width=20), content="ab")
let forced = horz([
if_flat(full_line(txt("a")), vert([txt("a"), txt("A")])),
txt("b"),
])
inspect(
forced.display_string(width=20),
content=(
#|a
#|Ab
),
)
}

#
horz

fn horz(docs : Array[Doc]) -> Doc

horz([doc1, doc2, ...]) horizontally concatenates documents: each document begins where the previous one left off, and is indented to line up with the last line of the previous one.

test {
let doc = horz([txt("BEGIN "), vert([txt("first line"), txt("second line")])])
inspect(
doc.display_string(),
content=(
#|BEGIN first line
#| second line
),
)
}

Horizontal concatenation is associative, so horz([x, y, z]), horz([x, horz([y, z])]) and horz([horz([x, y]), z]) all agree. An empty array yields [empty].

#
horz2

fn horz2(doc1 : Doc, doc2 : Doc) -> Doc

horz2(doc1, doc2) is [horz] on two documents, without building an array.

The array forms are the ones to reach for when you have an array. This is for folding: docs.fold(init=empty, horz2) allocates nothing per step, where horz([acc, doc]) allocates a throwaway pair every time.

test {
let docs = ["a", "b", "c"].map(txt)
inspect(docs.fold(init=empty, horz2).display_string(), content="abc")
}

#
if_flat

fn if_flat(flat : Doc, broken : Doc, reserve? : Int) -> Doc

if_flat(flat, broken) chooses between two layouts. It uses flat if and only if:

  1. flat can be rendered flat, i.e. it contains no [vert] and no [full_line]; and
  2. rendered flat, it fits on the current line without exceeding the width.

Otherwise it uses broken.

reserve is the number of columns to keep free for whatever follows the choice. The algorithm decides in a single left-to-right pass, so it cannot see the trailing context itself; when the caller knows that, say, two closing parens come next, reserve=2 says so:

test {
let flat = txt("[1, 2]")
let broken = vert([txt("[1,"), txt(" 2]")])
// The group fits in seven columns, so it stays flat -- and then the `))`
// that follows overflows.
let doc = horz([if_flat(flat, broken), txt("))")])
inspect(doc.display_string(width=7), content="[1, 2]))")
// Told that two more columns follow, it breaks, and the result fits.
let doc = horz([if_flat(flat, broken, reserve=2), txt("))")])
inspect(
doc.display_string(width=7),
content=(
#|[1,
#| 2]))
),
)
}

test {
let doc = if_flat(
txt("[1, 2, 3]"),
vert([txt("[1,"), txt(" 2,"), txt(" 3]")]),
)
inspect(doc.display_string(width=20), content="[1, 2, 3]")
inspect(
doc.display_string(width=5),
content=(
#|[1,
#| 2,
#| 3]
),
)
}

#
lambda_like_sexpr

fn lambda_like_sexpr(keyword : Doc, defn : Doc, body : Doc) -> Doc

lambda_like_sexpr(keyword, defn, body) renders as

(keyword defn body)

or, when that does not fit, as

(keyword defn body)

test {
let doc = lambda_like_sexpr(
txt("lambda"),
txt("(number)"),
txt("(* number number)"),
)
inspect(doc.display_string(), content="(lambda (number) (* number number))")
inspect(
doc.display_string(width=25),
content=(
#|(lambda (number)
#| (* number number))
),
)
}

#
nest

fn nest(n : Int, doc : Doc) -> Doc

nest(n, doc) renders doc with n more columns of indentation: every line doc breaks onto is indented n further, while the line it starts on is left alone.

test {
let doc = vert([txt("first line"), txt("second line")])
inspect(
nest(2, doc).display_string(),
content=(
#|first line
#| second line
),
)
}

This is the only way to indent a document you did not build yourself. Prefixing spaces with [horz] indents the first line too, which is a different thing:

test {
let doc = vert([txt("first line"), txt("second line")])
inspect(
horz([txt(" "), doc]).display_string(),
content=(
#| first line
#| second line
),
)
}

Note that [horz] re-indents its right operand to the column its left operand ended at, which overrides any indentation established outside it. So an enclosing nest has no effect on the right operand of a horz, though a nest applied inside that operand still does. Nesting always takes effect under [concat] and [vert].

Nesting does not change how wide a document is when flat, so it never changes an [if_flat] decision.

#
parens

fn parens(center : Doc) -> Doc

parens(center) wraps center in parentheses, keeping the closing paren glued to the end of the last line.

test {
inspect(parens(txt("a b")).display_string(), content="(a b)")
}

#
pretty

fn pretty(pieces : Array[Piece]) -> Doc

Builds a document from a sequence of literal-text and document pieces, the way a string template would: the pieces are cut into lines at every newline in the literal text, the lines are joined with [vert], and the pieces of each line are joined with [horz].

This is the MoonBit stand-in for the JavaScript library's tagged pretty`...` template.

test {
let c = txt("a == b")
let t = txt("a << 2")
let e = txt("a + b")
let doc = pretty([
Str("if ("),
Val(c),
Str(") {\n "),
Val(t),
Str("\n} else {\n "),
Val(e),
Str("\n}"),
])
inspect(
doc.display_string(),
content=(
#|if (a == b) {
#| a << 2
#|} else {
#| a + b
#|}
),
)
}

Because every line is a [horz], an embedded document that spans several lines is indented to line up with where it started.

#
sep_by

fn sep_by(items : Array[Doc], sep? : String, vert_sep? : String) -> Doc

sep_by(items) displays either

items[0] sep items[1] sep ... items[n]

if the whole thing fits on one line, or

items[0] vert_sep items[1] vert_sep ... items[n]

otherwise. Unlike [wrap], the choice is all-or-nothing.

Neither sep nor vert_sep may contain a newline.

test {
let items = ["alpha", "beta", "gamma"].map(txt)
let doc = sep_by(items, sep=", ", vert_sep=",")
inspect(doc.display_string(width=40), content="alpha, beta, gamma")
inspect(
doc.display_string(width=10),
content=(
#|alpha,
#|beta,
#|gamma
),
)
}

#
standard_sexpr

fn standard_sexpr(func : Doc, args : Array[Doc]) -> Doc

standard_sexpr(func, args) renders as

(func args ... args)

or, when that does not fit, as

(func args ... args)

test {
let doc = standard_sexpr(txt("function"), [txt("very-long-argument")])
inspect(doc.display_string(), content="(function very-long-argument)")
inspect(
doc.display_string(width=20),
content=(
#|(function
#| very-long-argument)
),
)
}

#
txt

fn txt(text : String) -> Doc

txt(s) simply displays s.

All other combinators take Docs, so this is how plain text enters a document. Use [txt_lines] when the text may contain newlines.

test {
inspect(txt("Hello, world").display_string(), content="Hello, world")
}

The text is assumed to occupy one column per UTF-16 code unit. When that is wrong — CJK, emoji, terminal escape sequences — use [txt_as].

Panics

Panics if text contains a newline. A Doc describes layout, and layout is this library's job; embed line breaks with [vert] or [txt_lines], or pass the text through untouched with [txt_raw].

#
txt_as

fn txt_as(text : String, width : Int) -> Doc

txt_as(text, width) displays text, telling the layout algorithm that it occupies width columns.

[txt] measures text in UTF-16 code units, which is exact for ASCII and wrong for everything whose screen width differs from its encoded length. The two cases that matter in practice are double-width characters and zero-width escape sequences:

test {
// A CJK character is one UTF-16 unit wide but occupies two columns.
let cjk = txt_as("世界", 4)
assert_eq(cjk.flat_width(), Some(4))
// An ANSI colour escape is five UTF-16 units and occupies none.
let red = txt_as("\u{1b}[31m", 0)
let reset = txt_as("\u{1b}[0m", 0)
assert_eq(horz([red, txt("hello"), reset]).flat_width(), Some(5))
}

Because the width is what every layout decision is made from, a coloured document lays out exactly like the same document without colour.

Panics

Panics if text contains a newline, for the same reason [txt] does.

#
txt_lines

fn txt_lines(text : String) -> Doc

Like [txt], but accepts newlines: the text is split into lines which are joined with [vert].

test {
inspect(
horz([txt("> "), txt_lines("one\ntwo")]).display_string(),
content=(
#|> one
#| two
),
)
}

#
txt_raw

fn txt_raw(text : String) -> Doc

txt_raw(text) writes text through verbatim. Newlines in it start a new line at column 0, and nothing is re-indented.

This is the escape hatch for text whose layout is already decided and must not be touched — block comments, here-documents, multi-line string literals. [txt_lines] re-indents continuation lines to line up with where the text started; txt_raw does not:

test {
assert_eq(horz([txt("x = "), txt_lines("a\nb")]).display(), ["x = a", " b"])
assert_eq(horz([txt("x = "), txt_raw("a\nb")]).display(), ["x = a", "b"])
}

Text containing a newline has no flat width, so — like [vert] and [full_line] — it can never be chosen as the flat branch of an [if_flat]. Text without one behaves exactly like [txt].

#
vert

fn vert(docs : Array[Doc]) -> Doc

vert([doc1, doc2, ...]) vertically concatenates documents: it joins them with newlines, indenting every document to the column the first one started at.

test {
inspect(
vert([txt("Hello,"), txt("world!")]).display_string(),
content=(
#|Hello,
#|world!
),
)
}

Vertical concatenation is associative. An empty array yields [empty].

#
vert2

fn vert2(doc1 : Doc, doc2 : Doc) -> Doc

vert2(doc1, doc2) is [vert] on two documents. See [horz2].

test {
inspect(
vert2(txt("a"), txt("b")).display_string(),
content=(
#|a
#|b
),
)
}

#
wrap

fn wrap(words : Array[Doc], sep? : String, vert_sep? : String) -> Doc

wrap(words) does word wrapping: it joins the words with sep when they fit on the same line, and with vert_sep followed by a newline when they don't.

For plain word wrapping use the defaults; for wrapping a comma-separated list use sep=", " and vert_sep=",".

Neither sep nor vert_sep may contain a newline.

test {
let words = ["This", "is", "a", "sentence", "with", "eight", "words"].map(txt)
inspect(
wrap(words).display_string(width=20),
content=(
#|This is a sentence
#|with eight words
),
)
}