gren-format-lib

Adding new Gren syntax to the formatter

This is a guide for teaching gren-format about a new piece of Gren syntax — a new AST node, a new kind of declaration, a new expression. Gren will keep growing, so the formatter has to be easy to extend without breaking the invariants that keep formatting correct, stable, and comment-faithful.

Read this once for the mental model, then keep docs/formatterRules.md (the authoritative, example-driven description of what every rule does) open while you work.

One decision explains most of what follows: gren-format reuses the production Gren compiler’s parser, and that parser throws comments away. Comments come back as a separate list of positions, re-attached to the formatter’s own tree after the fact — and nearly everything below (the Logical Printing Tree, its position caches, the Comments pass, the idempotency fuzzer, the forceVertical-stability rule) exists because re-attaching something by position, after the fact, is harder to get right than never losing it in the first place. Why the architecture is comment-driven compares this approach with that of elm-format, which has a different foundation.

The sections below go roughly in this order: how source text becomes a tree (the pipeline and the modules that build it), what that tree looks like and the rules for building it correctly, how the tree turns back into text, a practical checklist for adding new syntax, and finally a list of the mistakes that are easy to make and expensive to find.

Table of contents


The pipeline in one line

Src.Module + Ctx.Context  ──►  LPT  ──►  Box  ──►  String
                          MakeLogical   MakeRenderBox

Entry point: Formatter.prettyPrint : Src.Module -> Ctx.Context -> Result String String. It calls MakeLogical.makeLogicalPrintingTree (build the LPT) then Render.renderRoot (render it). Every stage returns Result String _; there are no silent fallbacks — an unhandled case is an Err, not a guess.


The modules

All formatter source lives in src/Formatter/:

Formatter.gren                  entry: prettyPrint
Formatter/Strings.gren          tiny string helpers (countNewlines)
Formatter/Results.gren          Result-over-Array combinators shared by both
                                  stages (traverseResult, resultFoldl)
Formatter/Logical.gren          AST + comments → LPT: runs lptFromAst, then the
                                  finishing passes (Comments, SortSymbols, VerticalSpace)
Formatter/Logical/
  MakeLogical.gren                the AST walk: one process* per top-level decl kind
  InsertExpressions.gren          expression → LPT (one insert* per expression form)
  InsertPatterns.gren             pattern → LPT
  InsertTypes.gren                type → LPT (typeWithArgs shared by TType/TTypeQual)
  LiteralFormat.gren              string / char / hex literal escaping
  LPTHelpers.gren                 LPT construction helpers: mkText*/plainAcross/
                                    syntheticParens/authoredBracketList/…
  BinopPrecedence.gren            operator precedence table for binop-chain layout
  LogicalPrintingTree.gren        LPShape / LPNode types, smart constructors, bounds cache
  LPTJson.gren                    --lpt debug serialiser
  Comments.gren                   re-attach parse-context comments by position
  SortSymbols.gren                sort exposing lists + import groups
  VerticalSpace.gren              insert blank lines
Formatter/RenderTree.gren       the stage barrier: lower : LPNode → RenderNode,
                                  the same tree with no source positions on it
Formatter/RenderTree/
  Json.gren                       --rt debug serialiser
Formatter/Render.gren           LPT → String: lowers, maps RootBox children
                                  through MakeRenderBox, joins with "\n"
Formatter/Render/
  MakeRenderBox.gren               RenderTree → Box, one builder per RenderShape constructor
  Box.gren                         elm-format's Box IR (Line/Box, Tab tab-stops, prefix) + renderer
  FlowPolicy.gren                  shared inline/break decision layer for flow sequences
  ElmStructure.gren                faithful port of elm-format's ElmStructure.hs layout combinators
  BoxOps.gren                      low-level Box/Line manipulation (prefixOperator, applyIndent, …)
  NodeClassify.gren                predicates and structural queries over LPT nodes
  CommentBox.gren                  render a comment node (line / block / doc) to a Box
  BinopLayout.gren                 pure layout assembly for binop chains
  FlowAssembly.gren                FlowItem / SoftGlueAlignment types + pure flow-layout helpers
  BackwardPipeline.gren            the whole `<|` pipeline cluster; re-enters the
                                     recursion through a `Renderers` record
Formatter/Audit/                not in the pipeline — the two gates that need
                                the formatter's own internals
  DecisionTrace.gren               --decisions: which layout decision moved between two formats
  PredicateAgreement.gren          --audit-predicates: a predicate claiming a break the renderer never emits

Formatter.gren sits alongside Formatter/, Logical.gren alongside Logical/, Render.gren alongside Render/ — the orchestrator of each stage is the file next to the directory, not inside it.

LogicalPrintingTree.gren is the hub every module depends on; its module doc opens with a categorised map of all 28 LPShape constructors. BinopPrecedence is imported by both InsertExpressions (to decide the author’s break tier) and MakeRenderBox (to render it) — they must agree, so the precedence table has one home.


What the formatter consumes

The AST — Compiler.Ast.Source.Module

(defined in compiler-common, shared with the compiler). Top-level shape:

type alias Module =
    { name    : Located String
    , exports : Exposing                       -- Open | Explicit (Array Exposed)
    , docs    : Maybe (Located String)         -- module doc comment
    , imports : Array (Located Import)
    , values  : Array (ModuleDeclaration Value)  -- functions / constants
    , unions  : Array (ModuleDeclaration Union)  -- custom types
    , aliases : Array (ModuleDeclaration Alias)
    , binops  : Array (Located Infix)
    , effects : Effects                          -- NoEffects | Ports … | Manager …
    }

Everything is wrapped in Located ({ start : Position, end : Position, value }) where Position = { row : Int, col : Int }, 1-based. Expressions, patterns and types are their own recursive Src.Expr / Src.Pattern / Src.Type_ trees, each node Located. When you add syntax, the parser team will have added a constructor here; your job starts from that constructor.

Positions are the vital pieces of information. The formatter leans on start/end of every token — not to reproduce them, but to (a) decide source order, (b) re-attach comments, and (c) detect author layout intent. If a new AST node carries a token the parser doesn’t record a position for (a synthesized keyword, a closing bracket), you will have to synthesize a faithful position for it (see below).

The comments — Compiler.Parse.Context

type alias Context = { indent : Int, lineStart : Int
                     , comments : Builder (Located Comment) }
type Comment = Line String | Block String

Comments ride alongside the AST as a flat, source-ordered list of located Line (--) or Block ({- -}) strings. They are re-attached to the LPT after it is built, purely by position (Formatter.Logical.Comments). This is why positions on the LPT nodes must be correct: a comment is placed next to whatever token its (row, col) falls between.


The LPT — Formatter.Logical.LogicalPrintingTree

An LPNode is an LPShape (what layout this node takes, or which leaf it is) plus children, plus a handful of cached subtree bounds. Build nodes only with the smart constructors — lpnLeaf shape, lpnNode shape children, lpnBracketNode shape closePos children — never with raw record syntax: the constructors compute the caches bottom-up, and skipping them yields wrong positions and mis-placed comments. The type is exported opaque, so this is enforced by the compiler — outside LogicalPrintingTree the record simply cannot be spelled. An LPShape is not a Formatter.Render.Box: a shape says what kind of thing this node is, a Box (the render stage’s IR, below) says where its characters land.

Shapes you will reach for

Every example below is real, formatted Gren — run through the actual CLI (--show) and checked for idempotency, not hand-typed. Where two snippets appear together, they’re the same construct rendered two ways, to show what flips the shape.

Leaves (carry text/position, no children):

Layout shapes (have children):

See docs/formatterRules.md for the rendered example of each rule these shapes implement, in user-facing terms rather than internal ones.

OriginalRows and SyntaxType — the top level only

Each top-level declaration becomes exactly one OriginalRows { first, last, stype } node directly under RootBox, where stype : SyntaxType tags the kind (StModule, StImport, StFunctionSignature, StTypeUnion, …) and first/last are its source-row range. Comments and blank lines are then added as sibling OriginalRows nodes. The row range drives two things: source ordering (MakeLogical.sortOriginalRows) and blank-line decisions (Formatter.Logical.VerticalSpace). Get first/last right or comments/blanks land in the wrong place — first should be the declaration’s leading keyword row.

The cached bounds (why lpnNode matters)

Every node caches firstPos, lastPos, minRow, maxRow, lastBracketEnd, bracketEndExact, bracketEndElastic, bracketStart, and hasComment. Formatter.Logical.Comments uses these to answer “what’s the first/last positioned token here?” and “where does the rightmost bracket close?” in O(1). lpnNode fills them from selfShapeBounds shape merged with the children; lpnBracketNode additionally records an exact closing-bracket position, and lpnElasticBracketNode records a derived one that grows as comments are placed inside it (see step 3 below). SynthesizedText contributes nothing to these caches — that is deliberate, so a generated -> never attracts a comment.


Author layout — the forceVertical flag

The formatter has no page width. Whether a construct stays on one line or breaks across lines is determined at LPT-build time from the author’s source positions, not at render time from a column budget.

The mechanism: some shapes carry { forceVertical : Bool }. Set it True when the author’s source has a line break inside that construct; set it False for flat intent. MakeRenderBox then picks between an ordinary flow (buildFlowBox) and a hard-breaking one (buildFlowBoxBroken) based on that flag.

One example, end to end. These two files differ by one newline — before the second argument — plus some stray spaces:

main =
    update model (Just newValue)
main =
    update    model
         (Just     newValue)

insertCall computes forceVertical = itemsSpanRows (fn :: args) for each. In the first, the whole call sits on one row, so the flag is False. In the second, (Just newValue) starts a row after model, so it is True — visible as "forceVertical": true on the call’s AcrossOrVertical node under node ../gren-format/app --lpt. The two then render as:

main =
    update model (Just newValue)
main =
    update model
        (Just newValue)

Note what survived and what did not. The second file’s extra spaces are gone — they carry no structural meaning — but its newline is preserved, because that one is the author’s layout decision. And the first stays flat no matter how long it grows; there is no width at which the renderer breaks it for you.

Where to detect multiline intent (in InsertExpressions.gren):

For new constructs: check if any structural item is on a different row than its predecessor. If yes → forceVertical = True; the renderer does the rest.


Formatter.Render.Box — the backend

Formatter.Render.Box (Box.gren) is a faithful port of elm-format’s own Box.hs. Two types, and these are all of their constructors — no page-width machinery anywhere in them, and nothing that defers a break to render time:

type Line = Text String | Row (Array Line) | Space | Tab
          | NoTrim String       -- text whose trailing spaces are significant
          | LineComment String  -- a `--`, which runs to end of row
type Box  = SingleLine Line
          | Stack { first : Line, second : Line, rest : Array Line }  -- 2+ lines

elm-format’s Box has a third constructor, MustBreak, as its ---comment mechanism. This port does not: the same fact rides the comment’s own Line leaf as LineComment, which is what lets it survive prefix/indent/row composition. B.endsOpen and B.asJoinable are where it is read.

A Box is never “maybe one line, maybe more” — it already is one or the other, decided by whoever built it. Tab isn’t “+4 spaces”; it’s a real tab stop (advance to the next multiple of 4), and prefix glues a string onto line 1 while padding the other lines by its exact character width — the same two primitives elm-format uses to make e.g. a Stack-shaped record update line up correctly no matter what column it starts rendering at. freezeTabs rewrites a box’s Tabs to literal spaces so it can be prefix-glued somewhere the tab-stop arithmetic would otherwise re-snap incorrectly.

Key functions, mirroring Box.hs. Each one is small enough to show what it does directly — build the left side, and B.render turns it into the string on the right:

There is no Group, no nl/breakDoc, and nothing to “render flat and see if it fits.” The flat-vs-vertical decision is made once, upstream of this module, when an LPT shape is built with forceVertical = True/False; the two layers above Box.gren just materialize that decision:

When you add a new shape type, add an arm to renderNodeBox’s when shape is … dispatch returning Result String Box. Reuse an existing shape if one fits — a new LPShape constructor requires new arms in every when shape is in MakeRenderBox, plus selfShapeBounds and LPTJson’s serialiser in the logical stage, plus a mirrored constructor in RenderTree’s RenderShape with arms in lowerShape and RenderTree/Json.gren. All of those are total over their shape type on purpose, so the compiler lists them for you — but it is four files, not one.

Why Box, and not a pretty-printer

Box is a port of elm-format’s own Box.hs, and that is the point: it is the IR elm-format renders through, so the two formatters’ render-time behaviour can be described in the same terms — which is what the comments section below does.

The obvious alternative is a Wadler/Prettier-style pretty-printer — the family, including JavaScript’s Prettier, that lays code out by searching for the best line breaks within a page-width budget. This formatter’s first iteration was exactly that: it rendered through gilramir/gren-pretty-expressive, a Gren implementation of the Pretty Expressive Printer — hand it a page width and a cost model and it searches every possible layout for the cheapest one. A hand-written Doc IR replaced that, and Box replaced the Doc; both are gone. A cost-based optimizer answers “where should the breaks go?”, and gren-format has already answered that before rendering starts: your line breaks are your layout decisions, recorded as forceVertical. Running a search over a decision already made is dead weight, and worse, it is a second opinion that can disagree with the first.

That is the throughline for anything you add here. Each layer this renderer has shed was one that re-decided something already decided elsewhere, and the same test applies to a new one: if a box has to work out what the author already told us, the answer belongs upstream, not in the renderer.


Adding a new construct — the checklist

Most new syntax is “build some shapes in a flow,” and the existing comment and blank-line machinery just works. Before the general checklist, here’s what that looks like end to end for one example — hypothetical and simplified for teaching, but shaped exactly like real work you’d do. Imagine Gren grows an unless expression, unless cond then body, formatted like a single-branch if with no else:

The checklist below generalizes each of those steps into the general case; use the example above to see what each step concretely produces, then come back to the checklist itself as the reference for your next addition. Go in this order.

1. Find the AST node

Locate the new constructor in compiler-common’s Compiler.Ast.Source and note every token it holds and, crucially, every token it doesn’t (keywords and brackets the parser consumes without recording a position).

2. Convert AST → LPT

Add/extend the right converter:

Use the shared helpers in Formatter.Logical.LPTHelpers: mkTextFromLocString (a real token at its Located position), mkText pos str (text at an explicit position), mkZeroWidthText pos str (a synthesized token anchored at a real position but contributing zero width — see below), and Formatter.ResultsresultFoldl. For the two most common container shapes there are smart constructors that fill in the default flags for you: plainAcross children (an AcrossOrVertical flow — a head-and-its-parts) and syntheticParens children (a formatter-synthesized ParenBlock with no author position). Prefer them over spelling out the shape record; reach for the raw shape only when you need a non-default flag (forceVertical = True, checkContentVertical = True, …).

3. Get positions right (the difficult part)

4. Detect author layout intent

If the new construct is one where the user might write it flat on one line or broken across rows, detect which they chose and set forceVertical accordingly. Check whether the construct’s items (arguments, conditions, fields) span more than one row, using the positions from the AST. See the Author layout section for the pattern.

5. Comments — usually nothing to do

Formatter.Logical.Comments re-attaches every comment by position and classifies its CommentRole (TrailsPrevious / LeadsLine / LeadsNext / TrailsHead / RidesInline / LeadsInline / Standalone) once, from the pristine parse rows; the renderer reads that role and never re-derives placement from rows. See CommentRole’s docstring in Formatter.Logical.LogicalPrintingTree for the whole model, docs/commentHandling.md for the behaviour it implements, and Comments.gren, “Adding support for a new construct”, for the required reading. The short version:

6. Render it — MakeRenderBox.renderNodeBox

Add an arm to the renderNodeBox when shape is … dispatch (and to the parallel flow dispatches in FlowPolicy/ElmStructure if your shape appears there) returning a Result String Box built from Formatter.Render.Box primitives. Reuse an existing shape if one fits — prefer AcrossOrVertical, AllAcrossOrAllVertical, IndentedBlock etc. over inventing a new one. Only add a new LPShape constructor when no existing shape expresses the breaking behaviour you need; a new constructor means new arms in every when shape is in MakeRenderBox, plus selfShapeBounds and LPTJson in the logical stage, plus a mirrored RenderShape constructor with arms in RenderTree.lowerShape and RenderTree/Json.gren. Every one of those matches is total, so none of them can be forgotten silently.

7. Blank lines (top-level only)

If you added a top-level SyntaxType, check Formatter.Logical.VerticalSpace: is your declaration a “function group” start (2 blank lines before) or an ordinary declaration (1)? Adjust computeGroupStarts if needed.


Things to worry about

These mistakes are easy to make and expensive to find, because most of them pass a first read of the diff cleanly. They surface later — as a fuzz-idempotency.py gap, as a reformat that quietly reindents someone’s comment, or as a bug report that a file changed on the second run of gren-format, not the first.

Construction and positions

Idempotency and canonicalization

Debugging mindset


How to test

Inspect what the formatter is doing (run from gren-format-lib/):

node ../gren-format/app --show   src/F.gren   # formatted output to stdout
node ../gren-format/app --pre-ast  src/F.gren # parsed AST + comment context as JSON
node ../gren-format/app --lpt    src/F.gren   # the Logical Printing Tree as JSON
node ../gren-format/app --rt     src/F.gren   # the same tree the renderer gets: no positions, plus lower's flags
node ../gren-format/app --post-ast src/F.gren # format, verify ASTs match, print formatted AST
node ../gren-format/app --box    src/F.gren   # the Box tree, one entry per top-level decl
node ../gren-format/app --decisions src/F.gren # which layout decisions moved between two formats

--lpt is your best friend for a placement bug: it shows exactly where a comment attached and what each node’s row range is.

The effectful suite is the main gate. Each assertPrettyIn runs three checks:

cd gren-format-lib/tests && ./run-tests.sh
  1. formattingformat(<name>.dirty.gren) is byte-equal to <name>.formatted.gren.
  2. AST equivalence — re-parsing the output yields a semantically equal Module (catches formatting that changes meaning).
  3. idempotency — re-formatting the .formatted file changes neither the Module nor the comment/blank-line Context (formatting is a fixed point).

Add a test by writing both testfiles/<SuiteDir>/<Name>.dirty.gren (deliberately messy input) and <Name>.formatted.gren (the canonical output) under the appropriate suite’s directory, then an assertPrettyIn fsPerm "<SuiteDir>" "description" "<Name>" line in tests/src/Test/Formatter/Format.gren. Generate the .formatted with:

node ../../gren-format/app --show <Name>.dirty.gren > testfiles/<SuiteDir>/<Name>.formatted.gren

Read it to confirm it is actually canonical before trusting it.

The standing gates guard the cross-cutting properties. run-tests.sh runs one of them itself, before it builds: check-divergence-index.py (the divergence catalogue and its fixture suite stay 1:1). The rest are run by hand and need a fresh build of gren-format/apprebuild it first (cd ../gren-format && ./build.sh), since every one of them shells out to the built binary and a stale one tests the wrong code.

The two that matter most for new syntax:

cd gren-format-lib/tests

python3 fuzz-idempotency.py -j 12
# Inserts a {- ¤ -} marker in every inter-token gap, formats twice,
# requires byte-identical output. The safety net for comment-shift bugs.

python3 fuzz-whitespace.py -j 12 --mode indent   # modes: stretch (default) | indent
# Perturbs incoming whitespace and requires byte-identical output
# (canonicalization — same meaning, same output, regardless of incoming spaces).

Run both after any change that touches comments, positions, or vertical space — especially after adding a comment-bearing fixture, which can itself surface a latent gap. A new construct that holds comments should get at least one comment-bearing fixture so the fuzzers exercise it.

fuzz-idempotency.py fails on an unlabelled finding, not on any finding: a finding whose cause is a known upstream parser bug is registered in tests/idempotency-known-baseline.json and forgiven, so a real regression can’t hide among the upstream ones. Re-register with --update-known-baseline only after a deliberate change. When it does flag a gap, `repro.py

` rebuilds that one case from the label, and `check-decision-stability.py` names *which* layout decision moved. Those aren't the only gates — there are also a construct × context matrix (`matrix-syntax.py`), random-module property testing (`gen-random.py` / `fuzzrun.py`), a predicate audit, and `fuzz-project.py`, the only gate that exercises the modes that write files. **[`docs/testing.md`](/gren-format-lib/testing.html)** is the full index: what each one guards against and how to run it. --- ## Why the architecture is comment-driven — contrasted with elm-format Almost everything above (position caches, the separate `Comments` pass, the idempotency fuzzer, the `forceVertical`-stability rule) exists because of one upstream decision: **gren-format reuses the production Gren compiler's parser (`compiler-common`), and that parser discards comments.** elm-format made the opposite choice, and comparing the two is the fastest way to understand why this codebase looks the way it does. Note this is a *parser*-level divergence, not a render-level one: our [render IR](#why-box-and-not-a-pretty-printer) is elm-format's `Box`/`Line` types, ported rather than reinvented (with one deliberate change: no `MustBreak` constructor — see above). So everything below about how elm-format's `Box` renders a comment once it's in the tree (`SingleLine` for an inline `{- -}`, the `Tab`/`prefix` indentation mechanism, and the "a `--` ends its line" fact, which we spell `LineComment`) describes our renderer too. What's still genuinely different — and what the rest of this section is really about — is how a comment *gets into* that tree in the first place: elm-format's parser puts it there directly, in a typed slot; ours puts it there afterward, by matching source positions. ### elm-format: comments live inside the AST elm-format ships its own parser, purpose-built for formatting, whose AST is *comment-carrying*. Comments are first-class nodes in typed structural slots: ```haskell data Comment = BlockComment [String] | LineComment String | ... Commented c a -- a value 'a' plus its comments (the "C" ctor) data CommentType = BeforeTerm | AfterTerm | Inside | BeforeSeparator | AfterSeparator type C2 l1 l2 = Commented (Comments, Comments) -- e.g. pre + post slots type C1Eol l = Commented (Comments, Maybe String) -- + an end-of-line comment ``` The list types (`Sequence`, `OpenCommentedList`, `ExposedCommentedList`) carry comment slots on **every element and every separator**. The parser fills these slots as it consumes source, so a comment's attachment is decided *grammatically* — `{- x -}` is `AfterTerm` on `a` because the parser read it in that grammatical position. There is no position arithmetic anywhere. Rendering then treats a slotted comment as just another `Box` (elm-format's render IR — a bottom-up `SingleLine | Stack | MustBreak` of lines) and runs it through the *same* `spaceSepOrStack` combinators as everything else: `formatComment (LineComment …)` is a `MustBreak` box (a `--` inherently ends its line); a one-line `BlockComment` is a `SingleLine` box (so `{- x -}` can stay inline); an end-of-line comment on a single-line box becomes `MustBreak`, which is exactly how a trailing comment forces its enclosing structure open — straight from the slot, no inference. **There is no separate comment pass.** Comments ride the tree from parse to render. That same bottom-up `Box` model is also where elm-format's join-vs-stack decisions live: `allSingles children` asks "is every part still one line?", and author newlines enter as parser flags (`FASplitFirst`/`FAJoinFirst`, `ForceMultiline`, `Multiline`). Indentation is a tab-stop (`Tab` rounds to the next multiple of 4) plus `prefix` (pads continuation lines by the exact character width of the prefix) — the same mechanism this port uses, since `Box.gren` is that code rather than a reimplementation of it. ### gren-format: comments are re-attached by position Our AST (`Compiler.Ast.Source`) has no comment slots. Comments arrive as a flat, source-ordered side list (`Compiler.Parse.Context`, `Located (Line | Block)`) and are re-attached to the already-built LPT **geometrically** by `Formatter.Logical.Comments`: a comment at `(row, col)` is placed next to whichever token its position falls between. Everything that looks like incidental bookkeeping in this codebase is forced by that one fact: - **The LPT carries source positions on every node** (`firstPos`/`lastPos`/ `minRow`/`maxRow`, computed by the smart constructors) — `Box` itself still carries none, on either side; the positions live one layer up, in the LPT, precisely because *something* upstream of `Box` has to be able to *locate* a comment before rendering, and elm-format's AST slots make that unnecessary. - **Attachment must survive a reformat.** We format, our output is re-parsed, and comments are re-attached from their *new* positions. If a comment lands in a different relative gap the second time, the output shifts — the "comment moved on reparse" bug class. `fuzz-idempotency.py` (a `{- ¤ -}` in every inter-token gap, format twice, demand byte-equality) is the safety net specifically for this. elm-format gets comment-idempotency for free: the comment never leaves its slot. - **`forceVertical` stability** (the rule in *Things to worry about*) is the same hazard one level up — our author-layout signal is recomputed from positions each pass, so it must be invariant under reformatting; elm-format's equivalent is a parser flag baked into the tree once. - **Render-time comment logic re-derives elm's typed slots from geometry.** `renderFlowItem`'s `SingleLineComment`/`BlockComment` handling, `peelTrailingCommentNodes`, `peelLeadingInlineComments`, `shapeKeepsTrailingCommentOutside`, and the `prevElided` zero-width-token hazard all exist to recover "trailing-same-line vs standalone-own-line vs end-of-line" — the distinctions elm-format reads directly off `BeforeTerm` / `AfterTerm` / `C0Eol`. Our version keys off `loc.start.row == acc.prevRow` and friends. ### The tradeoff in one line elm-format **owns its parser**, so comments are grammatical and idempotency is automatic — but it must track the Elm language itself. gren-format **borrows the compiler's parser**, so it can never diverge from what Gren actually accepts — but it pays for that by reconstructing comment attachment from positions, which is what the LPT, the `Comments` pass, and the fuzzers are all in service of. When you add a construct that can hold comments, you are extending *our* half of that tradeoff: get the positions right (step 3 of the checklist above) and give it a comment-bearing fixture so the fuzzers exercise the reconstruction. --- ## Where to read more - `docs/formatterRules.md` — authoritative, example-by-example description of every rule (`README.md` has the short version plus an example). - `Logical/LogicalPrintingTree.gren` — the module doc's categorised shape table, then every shape's own doc comment and the caching invariants. - `Logical/Comments.gren` module doc — the comment-attachment algorithm and its "Adding support for a new construct" section; the body is banner-sectioned into phase 1 (top-level slot) and phase 2 (inner descent). - `Logical/BinopPrecedence.gren` — the operator precedence table and why its `binopMinPrecedence` seam is shared by `InsertExpressions` and `MakeRenderBox`. - `Formatter.Render.Box` (`Render/Box.gren`) — the `Line`/`Box` types and renderer; small enough to read in full. - `Formatter.Render.MakeRenderBox` (`Render/MakeRenderBox.gren`) — the per-`LPShape` dispatch that builds `Box` values. - `Formatter.Render.FlowPolicy` — the flow-item join decision layer (`decide`); read its module doc before adding a new kind of flow item.