Pretty Printer
In this case study, we implement a (naive) pretty printer library that performs backtracking to find the first layout that matches the screen width. We build on the work by
Linear, bounded, functional pretty-printing S. Doaitse Swierstra and Olaf Chitil. JFP 2009
but adapt it to the setting of effect handlers. Furthermore, the library presented here is neither linear (it uses simple backtracking), bounded (backtracking is arbitrary), nor functional (combinators are imperative style).
module examples/casestudies/prettyprinter
import examples/casestudies/parser // just needed for the example (Tree)
import string
Similar to the parser case study, the pretty printing library is based on a non-deterministic description of layouts. In particular, to fill the horizontal space as best as possible, we first try to pretty print horizontally and fall back to vertical mode, if not enough space is available. For example, the following text overflows when trying to layout horizontally:
// -------
// hello world
Treating the space as a potential line break, we can try again, this time vertically.
// -------
// hello
// world
Pretty printing also often includes ways to group parts of a document. If a group does not fit horizontally, all of its elements will be layouted vertically:
// --------------
// hello (this (is too) long)
Even though the first two words of the group would fit, the document will be pretty printed as
// -------------
// hello (this
// is too
// long)
Layout as Effects
The layout configuration is modeled by the following data types and effects:
type Direction { Horizontal(); Vertical() }
effect Indent(): Int
effect DefaultIndent(): Int
effect Flow(): Direction
The effect Flow
represents the current layouting direction. Also the indentation of the
document depends on the context and is therefore modeled as an effect.
Computing the layout of a document to be pretty printed uses the above three effects:
effect Layout = { Indent, DefaultIndent, Flow }
Output: A Stream of Layout Elements
Before we look at examples on how to use the Layout
effect, we introduce yet another effect to
emit the layouted documents:
interface Emit {
def emitText(content: String): Unit
def emitNewline(): Unit
}
def text(content: String) = do emitText(content)
def newline() = do emitNewline()
This way, the resulting document is represented as a stream of text
and newline
events.
Pretty Printing Combinators
Using the text emitter and the layout configuration, we can express simple functions that express spacing between layout elements.
def space() =
text(" ")
def spaces(n: Int) =
if (n > 0) text(" ".repeat(n)) else ()
We can also express a function that, depending on the current flow of directions prints either the supplied text or a newline:
def lineOr(replace: String) = do Flow() match {
case Horizontal() =>
text(replace)
case Vertical() =>
newline()
text(" ".repeat(do Indent()))
}
In the case of a newline, it also outputs indentation
def line() =
lineOr(" ")
def linebreak() =
lineOr("")
We purposefully distinguish between potential linebreaks that are horizontally rendered as a space (line
)
and those that are not rendered horizontally (linebreak
).
Indentation
Indentation can be configured by locally handling Layout
and thereby changing the indentation:
// Uses `n` as the indentation in the given document
def in[R](n: Int) { doc: => R / Layout }: R / Layout =
try { doc() }
with Indent { () => resume(n) }
// Adds `j` to the indentation in the current document
def nest[R](j: Int) { doc: => R / Layout }: R / Layout =
(do Indent() + j).in { doc() }
// Uses the default indentation to nest a document
def nested[R] { doc: => R / Layout }: R / Layout =
nest(do DefaultIndent()) { doc() }
Fixing Local Layout Choices: Grouping as Handlers
Layout combinators often include a way to group components. This way all components of a group are all layouted horizontally or vertically. Similarly, we can implement handlers that locally fix the direction:
def in[R](dir: Direction) { doc: => R / Layout }: R / Layout =
try { doc() }
with Flow { () => resume(dir) }
def horizontal { p: => Unit / Layout }: Unit / Layout =
Horizontal().in { p() }
def vertical { p: => Unit / Layout }: Unit / Layout =
Vertical().in { p() }
This way, we can locally determine whether all children of a group should be layouted either
horizontally or vertically. However, often we want to mark choice points and then search for a solution
by trying out the different choices until a layout fits the screen width. To express these
choices, we thus add the following LayoutChoice
effect:
interface LayoutChoice {
def choice(): Direction
def fail[A](): A
}
The LayoutChoice
effect is very similar to the Nondet
effect in the parser case study.
We define the following effect alias for pretty printing documents that depend on layout choices:
effect Pretty = { Emit, Layout, LayoutChoice }
Using layout choices, we can express the maybe most important pretty printing combinator:
def group { p: => Unit / Layout } =
do choice().in { p() }
The group
combinator expresses that depending on the result of choice
we either layout all children
horizontally or vertically.
Using group, we can for instance express variants of line
and linebreak
:
def softline() =
group { line() }
def softbreak() =
group { linebreak() }
Regardless of the enclosing group, a softline
inserts a linebreak, if the remainder does not fit into the available space.
Examples
Using the combinators we can express examples from the paper by Swierstra and Chitil.
def example1(l: List[Int]) = {
text("[");
l.foreach { n =>
text(show(n));
text(",");
line()
};
text("]")
}
This layouts a list by potentially adding a linebreak after each comma. Pretty printing the
list [1, 2, 3, 4]
at width 5
gives:
// -----
// [1,
// 2, 3,
// 4, ]
For simplicity, we keep the trailing comma.
The following example represents the document (Hi you)!!!
def example2() = {
group { text("Hi"); line(); text("you") };
text("!!!")
}
which pretty printed at width 6
yields:
// ------
// Hi
// you!!!
The next example illustrates that revising the layout decision for the parent group can also lead to a revision of the layout decision of the child group (due to indentation).
def example3() = {
group {
text("this");
nest(9) {
line();
group { text("takes"); line(); text("four") }
};
line();
text("lines")
}
}
Pretty printing results in:
// ---------------
// this
// takes
// four
// lines
Implementing the Pretty Printer
So far, we have seen how the different effects can be used to describe combinators. We are now ready
to actually implement pretty printing by providing the necessary handlers. We start by implementing
a handler for LayoutChoice
that searches for the first successful layout:
def searchLayout[R] { p : => R / LayoutChoice }: Option[R] =
try { Some(p()) }
with LayoutChoice {
def fail[A]() = None()
def choice() = resume(Horizontal()).orElse { resume(Vertical()) }
}
The handler is essentially the same as the backtracking implementation of the parser case study but
using optional values to indicate success or failure of a layouting attempt. If layouting horizontally fails
for a particular choice-point, we resume a second time with Vertical
.
Handling the output emitter is straightforward. Here, we simply store all emitted elements in a string:
def writer { p: => Unit / Emit } = {
var out = "";
try { p(); out } with Emit {
def emitText(t) = { out = out ++ t; resume(()) }
def emitNewline() = { out = out ++ "\n"; resume(()) }
}
}
We can implement a handler for Layout
by intercepting emit effects and keeping track of the current
column position.
def printer(width: Int, defaultIndent: Int) { prog: => Unit / { Emit, Layout } } : Unit / { Emit, LayoutChoice } = {
// the position in the current line
var pos: Int = 0;
try { prog() }
// we allow flow to be flexible on the top-level
with Flow { () => resume(do choice()) }
// indentation starts at 0
with Indent { () => resume(0) }
// simply handle the default indentation with a constant
with DefaultIndent { () => resume(defaultIndent) }
Maybe most interestingly, here we update the current position and invoke the effect operation fail
, if
the document exceeds the width. This will potentially cause backtracking and revision of a preceeding layout decision.
If the current text still fits the line, we simply re-emit it.
with Emit {
def emitText(t) = {
pos = pos + t.length;
if (pos > width) { do fail() }
else { text(t); resume(()) }
}
def emitNewline() = { pos = 0; newline(); resume(()) }
}
}
Finally, we can compose the different handlers to a single pretty printing handler:
def pretty(width: Int) { doc: => Unit / Pretty }: String = {
val result = searchLayout { writer { printer(width, 2) { doc() } } };
result.getOrElse { "Cannot print document, since it would overflow." }
}
For simplicity, we output a string “Cannot print …” if the document does not fit the line width
and we cannot find a layout. The order of handlers is important: The handler searchLayout
,
which performs backtracking needs to be the outermost one. This way the mutable variable in writer
which contains the currently printed document is reset to its previous state.
Using the Pretty Printing Library
We can of course define some additional combinators to describe documents:
def parens { p: => Unit }: Unit / Pretty = {
text("("); p(); text(")")
}
def braces { p: => Unit }: Unit / Pretty = {
text("{"); p(); text("}")
}
and write a pretty printer for the example Tree
from the parser case study:
def toDoc(t: Tree): Unit / Pretty = t match {
case Lit(value) => text(show(value))
case Var(name) => text(name)
case Let(name, binding, body) =>
text("let"); space(); text(name); space(); text("=");
group {
nested { line(); toDoc(binding) };
line();
text("in")
};
group { nested { line(); toDoc(body) } }
case App(name, arg) =>
text(name); parens {
group { nested {
linebreak();
toDoc(arg)
}; linebreak() }
}
}
We can first use the parser from the parser case study to obtain a parse tree, which we then pretty print:
def parseAndPrint(text: String, width: Int): String =
parse(text) { parseExpr() } match {
case parser::Success(tree) => pretty(width) { toDoc(tree) }
case parser::Failure(text) => text
}
For example, we obtain
def example4() = parseAndPrint("let x = (let y = 2 in 1) in 42", 10)
// ----------
// let x =
// let y =
// 2
// in 1
// in 42
Additional Examples
def main() = {
println("-----");
println(pretty(5) { example1([1,2,3,4]) });
println("----------");
println(pretty(10) { example1([1,2,3,4,5,6,7,8,9,1,2,3,4]) });
println("----------")
println(example4())
def example4b() = {
text("def"); space(); text("foo"); parens {
group {
nest(2) {
linebreak();
group { text("x"); text(":"); space(); text("Int"); text(",") };
line();
group { text("y"); text(":"); space(); text("String") }
};
linebreak()
}
}
}
def example3b() = {
example4b();
space();
braces {
group {
nest(2) {
line();
text("var"); space(); text("z"); space(); text("="); space(); text("42"); text(";")
};
line()
}
}
}
def example6() = {
group {
text("this");
nest(9) {
line();
group { text("takes"); line(); text("many"); line(); text("f") }
};
line();
text("l")
}
}
def example7() = {
group {
text("this");
line();
text("will");
nest(9) {
line();
group { text("take"); line(); text("many") }
};
line();
text("lines")
}
}
def helloWorld() = {
text("hello")
line()
text("world")
}
println("------------------------------");
println(pretty(30) { example4b() });
println("--------------------");
println(pretty(20) { example4b() });
println("----------");
println(pretty(50) { example3b() });
println(pretty(15) { example3b() });
println("------");
println(pretty(6) { example2() });
println("---------------");
println(pretty(15) { example3() });
println("--------------");
println(pretty(14) { example6() });
println("--------------");
println(pretty(14) { example7() })
}