Pretty printing TODO lists

This tutorial assumes that you already have some familiarity with pretty printing in Glam and with its API.

If you feel like you need to brush up on your pretty printing skills, you can first go through the introductory tutorial and come back to this one.

Goals

This tutorial will show you how to write a pretty printer for TODO lists. While doing so, you’ll learn some of the remaining bits of Glam’s API that were not covered in the introductory tutorial; you’ll also get more familiar with how nesting works.

TODO lists

First of all, let’s have a look at the data structure we’re going to pretty print:

type TodoList =
  List(Task)

type Task {
  Task(status: Status, description: String, subtasks: TodoList)
}

type Status {
  Todo
  Done
  InProgress
}

A TodoList is a list of Tasks; each task has a description, a status and many subtasks. Let’s look at an example of such data structure (any similarity to actual persons me or actual events me writing this tutorial is purely coincidental):

let todo_list = [
  Task(InProgress, "publish Glam v1.1.0", [
    Task(InProgress, "write a tutorial on todo lists", []),
    Task(InProgress, "add `doc.flex_break`", [
      Task(Done, "add the appropriate type variant", []),
      Task(Done, "implement the missing cases", []),
      Task(Todo, "add some tests", []),
    ]),
  ]),
  Task(Todo, "get some sleep", []),
]

We want each list item to be on its own line and to be preceded by a bullet point. Also, each list of subtasks should be indented by two spaces more than its parent.

The TODO list I’ve just shown you would look like this:

- […] publish Glam v1.1.0
  - […] write a tutorial on todo lists
  - […] add `doc.flex_break`
    - [X] add the appropriate type variant
    - [X] implement the missing cases
    - [ ] add some tests
- [ ] get some sleep

A mostly wrong implementation

We need to find a way to turn a TodoList into a Document so that Glam will be able to pretty print it for us. The function we’re looking for looks like this:

fn tasks_to_doc(tasks: TodoList) -> Document {
  todo as "Write an amazing pretty printer"
}

We can turn this problem into something more approachable by first tackling the pretty printing of a single task:

fn task_to_doc(task: Task) -> Document {
  todo as "Another amazing pretty printer"
}

With this definition it’s quite straightforward to implement a mostly correct (more on this later) tasks_to_doc:

fn tasks_to_doc(tasks: TodoList) -> Document {
  list.map(tasks, task_to_doc)
  |> doc.join(with: doc.soft_break)
}

Pretty printing a task

In order to pretty print a single task, we first have to create the appropriate bullet point according to its status:

fn status_to_bullet(status: Status) -> String {
  case status {
    Todo -> "- [ ]"
    Done -> "- [X]"
    InProgress -> "- […]"
  }
}

Now we’re ready to turn a task into a document:

fn task_to_doc(task: Task) -> Document {
  let task_line = status_to_bullet(task.status) <> " " <> task.description
  let task_doc = doc.from_string(task_line)
  
  todo as "Bear with me a little longer..."
}

The document for the task we’ve just created is obtained from a single string: if we joined the pieces with a doc.space, the pretty printer could split the bullet point and the task description on different lines. What we want is to have the task always on the same line as its bullet.

Now we can turn our attention to the list of subtasks; we can handle those with a mutually recursive call to tasks_to_doc:

fn task_to_doc(task: Task) -> Document {
  let task_line = status_to_bullet(task.status) <> " " <> task.description
  let task_doc = doc.from_string(task_line)

  case list.is_empty(tasks.subtasks) {
    True -> task_doc
    False ->
      [task_doc, doc.soft_break, docs_to_task(task.subtasks)]
      |> doc.concat
  }
}

We just have to be careful and remember to not add any space if there’s actually no subtask to display. When there are subtasks, though, we join their document and the task’s one with a doc.soft_break.

Let’s try this out and see how a TODO list is pretty printed:

[
  Task(Todo, "groceries", [
    Task(Todo, "lentils", [])
    Task(Todo, "carrots", [])
  ])
]
|> tasks_to_doc
|> doc.to_string(10_000)
// -> - [ ] groceries- [ ] lentils- [ ] carrots

Looks like everything ended up on a single line. What we wanted was to get a task per line, no matter how wide it is.

The problem is that, when the pretty printer has to deal with a doc.soft_break (or any other kind of break), it only splits it if the group it belongs to doesn’t fit on a single line. If, like in this example, we provide the pretty printer enough space, it will gladly keep everything on a single line!

There is a quick and dirty solution: just trick the pretty printer and use 0 as the maximum width. Then, the pretty printer will always split every single group it runs into:

[
  Task(Todo, "first", []),
  Task(Todo, "second", []),
]
|> tasks_to_doc
|> doc.to_string(0)
// ->
// - [ ] first
// - [ ] second

While this works for such a simple example, the problem with this approach is that it breaks down quite easily for more complex documents: imagine you had only a portion of the document that you wanted to always break; by setting the line width to 0 you’re going to always break every single group.

Forcing a break

There’s a better way to force the pretty printer to always break a given break: doc.force_break. This function takes a document as input and forces the pretty printer to break the doc.break it is made of:

let example = 
  ["first", "second"]
  |> list.map(doc.from_string)
  |> doc.join(with: doc.space)
  |> doc.force_break

doc.to_string(example, 10_000)
// ->
// first
// second

As you can see, despite having plenty of room to fit both "first" and "second" on a single line, the pretty printer was forced to break the space.

There’s a detail to pay attention to: doc.force_break does not work on groups! The pretty printer will always try to first fit a group onto a single line, ignoring any doc.force_break wrapping it:

let example =
  ["first", "second"]
  |> list.map(doc.from_string)
  |> doc.join(with: doc.space)
  |> doc.group
  |> doc.force_break

doc.to_string(example, 10_000)
// -> first second

Simply wrapping everything in a doc.group before calling doc.force_break renders it useless: the pretty printer will just ignore it and treat the group as it usually would.

Fixing the TODO list pretty printer

We can take advantage of doc.force_break to ask the pretty printer to split the items on newlines, no matter the line width. The required change to get a correct implementation of tasks_to_doc is minimal:

fn tasks_to_doc(tasks: TodoList) -> Document {
  list.map(tasks, task_to_doc)
  |> doc.join(with: doc.soft_break)
  |> doc.force_break
}

Simply wrapping the resulting Document in a doc.force_break is enough to make sure the pretty printer always ends up splitting the doc.soft_break we used to join the tasks.

Let’s have a look at the pretty printed list now:

[
  Task(Todo, "groceries", [
    Task(Todo, "lentils", [])
    Task(Todo, "carrots", [])
  ])
]
|> tasks_to_doc
|> doc.to_string(10_000)
// ->
// - [ ] groceries
// - [ ] lentils
// - [ ] carrots

Almost perfect! We just need to indent the subtasks to make this look as we wanted.

Nesting subtasks

As we’ve already seen in the introductory tutorial, we can use doc.nest to increase the padding added after each newline inserted by the pretty printer.

Once again the code change to make our function work as intended is minimal:

fn task_to_doc(task: Task) -> Document {
  let task_line = status_to_bullet(task.status) <> " " <> task.description
  let task_doc = doc.from_string(task_line)

  case list.is_empty(tasks.subtasks) {
    True -> task_doc
    False ->
      [task_doc, doc.soft_break, docs_to_task(task.subtasks)]
      |> doc.concat
      |> doc.nest(by: 2)
      // ^-- we just needed to add this single line
  }
}

One nice thing about doc.nest is that it increases the nesting level of the document. This means that, any other nesting that comes before it is automatically increased by the same amount. That’s why this small change is enough to deal with deeply nested task lists and each sublist is going to be nested at the right level.

If we now call tasks_to_doc on the TODO list I showed you at the beginning of the tutorial what we get is this nice-looking list:

tasks_to_doc(todo_list)
|> doc.to_string(10_000)
// ->
// - […] publish Glam v1.1.0
//   - […] write a tutorial on todo lists
//   - […] add `doc.flex_break`
//     - [X] add the appropriate type variant
//     - [X] implement the missing cases
//     - [ ] add some tests
// - [ ] get some sleep

Recap

Our final implementation is just a bit less than 30 lines of pretty printing but we got quite a nice value out of it:

If you want to take a look at the full implementation code, you can find it here.

What to do next?

If you want to test your skills on a real-world example, you can have a look at the JSON pretty printing tutorial, I highly recommend you try it: it’s really rewarding and you’ll never look at online JSON formatters in the same way!

There’s also another tutorial on pretty printing lovely error messages you can look at. It’s one of the tutorials I had the most fun writing, I hope you find it interesting as well!

Search Document