gren-format-lib

How the formatter works

A guided tour of how gren-format turns your source file into its formatted version, at a conceptual level. For the rules themselves — what each construct looks like before and after — see Gren Formatter Rules.

Table of contents


Step 1: building the Logical Printing Tree

The first step walks over your code’s structure and builds a Logical Printing Tree: one entry for every piece of your program (a function, an expression, a list, a comment, a blank line, and so on), arranged in the same shape as your code.

Each entry in this tree isn’t the final text yet — it’s a layout decision. Some examples of the kinds of decisions recorded here:

Where those decisions come from matters: the formatter mostly follows your original line breaks. If you wrote a list across several lines, the Logical Printing Tree records “spread this out”; if you wrote it on one line, it records “keep this together.” The tree is really a map of those choices, ready to be turned into text later.

Where comments and blank lines fit in

Comments and blank lines aren’t part of your code’s structure, so they arrive separately, each tagged with the line and column where you wrote it.

Once the rest of the Logical Printing Tree is built from your code alone, this step goes back through and puts each comment and blank line in place — finding the spot in the tree that sits at that same line and column, and inserting it next to the code it was originally written beside. A comment on the same line as some code attaches to that code; a comment on its own line becomes its own entry, positioned between whatever came before and after it in your file. The same idea applies to blank lines: the formatter notices where you left gaps and preserves them as their own entries in the tree.

The result is a Logical Printing Tree that has everything: code, comments, and blank lines, all in the right order and all carrying their layout decisions.

Example

Comments are what make this genuinely hard: they carry meaning for a human reader, but the parser doesn’t attach them to any particular piece of code — they just sit in a separate list, tagged with a position. Take this file where spacing is messy and non-standard:

module Sample exposing (greet)


import String


-- Greets someone by name
greet:String->String
greet name =
  "Hello, "  ++    name

(The parser doesn’t care whether : and -> have surrounding spaces — greet:String->String parses exactly like greet : String -> String; whitespace around most tokens is not meaningful.)

The parser splits this into an AST — which never mentions the comment at all — and a Context that holds only the comment, tagged with the row and column where it starts:

Module "Sample"
├── exports: [ greet ]
├── imports: [ String  (4:1–4:14) ]
└── values
    └── greet(name)                                (8:1–10:24)
        ├── signature: String -> String             (8:7–8:21)
        └── body: Binop "++"                        (10:3–10:24)
            ├── left:  String "Hello, "
            └── right: Var name

Context
└── comments: [ Line "-- Greets someone by name"  (7:1–7:26) ]

Building the Logical Printing Tree means walking that AST first, then going back and re-inserting the comment at row 7 — right where it was written, directly above the signature it sits beside:

RootBox
├── OriginalRows[module]       "module Sample exposing (greet)"
├── EmptyLine
├── OriginalRows[import]       "import String"
├── EmptyLine
├── EmptyLine
├── OriginalRows[lineComment]  "-- Greets someone by name"
├── OriginalRows[funcSig]      "greet : String -> String"
└── OriginalRows[funcDecl]
    ├── AcrossOrVertical        "greet name ="
    └── BodyBlock
        └── Binop "++"
            ├── "Hello, "
            └── OpAndRhs  "++ name"

Notice there’s no EmptyLine between the comment, the signature, and the function itself — all three stay glued together as one declaration unit. (See Blank lines around comments for the general rule.)


Between the steps: throwing the positions away

Step 1 is where every layout decision gets made, and it makes them by looking at how you wrote the code — which line each piece started on, whether you put a list on one line or several, where each comment sat relative to what it describes. By the end of Step 1 all of that has been turned into answers and recorded in the tree.

So before Step 2 runs, the formatter makes a copy of the tree with your line and column numbers removed, and Step 2 onward only ever sees the copy.

That may look like extra work for nothing, and it is worth saying why it is not. The moment Step 2 starts producing output, that output has different line numbers from the ones you wrote — the formatter is moving things. Any rule down there that peeked back at your original line numbers would be reading a fact that has already stopped being true, and would give a different answer the second time the formatter ran over its own output. That is precisely the bug that makes a formatter unstable: run it twice, get two different files.

Taking the positions off the copy means such a rule cannot be written by accident. There is nothing left to peek at, so the compiler rejects the attempt rather than a reviewer having to catch it. The handful of genuine questions about your original layout that Step 2 does need — “did you write these two things on the same line?”, “did you break this type signature at an arrow?” — are answered once, during the copy, and travel along as plain yes/no answers.


Step 2: turning the Logical Printing Tree into a render plan

The Logical Printing Tree says what could happen (“these items can go on one line or several”). The next step turns each of those decisions into something much more concrete: a small set of building blocks that say exactly what to print — a piece of text, a line break, or “indent everything from here by one more level.”

This step doesn’t do any guessing or searching for the “best” way to lay things out. Because the Logical Printing Tree already recorded each decision (based on how you originally wrote the code), this step just follows those decisions directly. That’s why the same input always produces the same output, and why there’s no “line width” setting to configure — the formatter isn’t trying to fit your code into 80 columns or any other target, it’s reproducing the shape you already chose.

Example

Continuing the same example, the Logical Printing Tree from Step 1 becomes this render plan — one entry per root item, each a small tree of concrete building blocks. A Stack is a box that is already committed to printing as 2 or more actual lines; everything else (Seq, and the bare pieces inside it — text, Space, Tab) stays on the current line:

[0] Seq[ "module", Space, "Sample", Space, "exposing", Space, "(", "greet", ")" ]

[1] ""

[2] Seq[ "import", Space, "String" ]

[3] ""
[4] ""

[5] "-- Greets someone by name"

[6] Seq[ "greet", Space, ":", Space, "String", Space, "->", Space, "String" ]

[7] Stack
    ├── Seq[ "greet", Space, "name", Space, "=" ]
    └── Seq[ Tab, "\"Hello, \"", Space, "++", Space, "name" ]

The comment (entry [5]) is just a bare piece of text sitting between two blank lines (entries [3]/[4], each a bare empty string) and the signature — nothing left to decide about it. The whole signature (entry [6]) is one Seq with an ordinary Space between every token; there’s no separate “could this become a newline?” node the way there was in Step 1, because a signature you wrote on one line is already settled at this stage (see Type signatures). The function (entry [7]) is where a real decision shows up: it’s a Stack, meaning it will print as 2 lines no matter what, because you wrote = and the body on separate rows. The Tab at the start of the second line is what becomes the body’s indentation once Step 3 renders it — not a fixed “4 spaces” but a jump to the next indent stop, the same primitive elm-format itself uses. Step 3 doesn’t choose between staying flat or breaking; it just executes whichever this tree already committed to.


Step 3: turning the render plan into text

The last step is the simplest: walk over the render plan from the previous step and produce the actual characters of the formatted file — inserting real newlines, real spaces, and the right amount of indentation at each level. What comes out the other end is the finished, formatted source file.

Example

Rendering the plan from Step 2 produces the finished file:

module Sample exposing (greet)

import String


-- Greets someone by name
greet : String -> String
greet name =
    "Hello, " ++ name

The two blank lines around module/import collapsed to one, : and -> each got a surrounding space, the four spaces around ++ collapsed to one, the 2-space body indent became 4, and the comment landed exactly where it started — still glued to the signature, with no blank line between them.


Why this design?

The formatter’s guiding idea is: your line breaks are your layout decisions. Rather than trying to choose the “best” way to arrange your code, it honors how you already wrote it and simply makes that consistent everywhere. This keeps the whole process predictable — running the formatter twice in a row always produces the same result, and a change to one part of a file never surprises you by reshuffling an unrelated part.


Where to go next