gren-format-lib

How gren-format places your comments

gren-format never changes the text of a comment. What it decides is where each comment sits relative to the code around it — which line it lands on, and how far it is indented.

This document is the whole story for a Gren developer: what the formatter is up against, the one idea that makes it come out right twice running, and then the seven rules that decide every comment in a file, with a “you write / gren-format writes” pair for each case. Every example on this page was produced by running the formatter.

If you are working on the formatter itself — function names, the state machines, the test gates — read The comment algorithm instead. It is this same story in full, at four times the length.

For comment rules that belong to one particular construct (block-comment body re-indentation, doc comments, an effect module’s where block), see the Comments section of the rule reference. For places where gren-format and elm-format disagree about comments, see the divergence catalogue.


Table of contents


Why comments are the hard part

Comments are the hardest thing a formatter deals with, and the reason is structural rather than fiddly: comments are the one part of your source the compiler does not care about, and the only part the formatter cannot afford to get wrong. Move a line of code and it still means the same thing. Move a comment and it now describes something else.

gren-format does not have its own parser. It uses the production Gren compiler’s parser, which matters more than it sounds: a formatter that accepts a slightly different language than the compiler is a bug factory, so we always parse exactly what the compiler parses.

The price is that the compiler’s parser does what every compiler’s parser does. It throws comments away. They are not part of the syntax tree, and there is nowhere in it for them to be.

What comes back instead is a second, separate list — every comment in the file, with its text and its position, and nothing else. For this file:

module Sizes exposing (sizes)


sizes =
    [ 1 -- smallest
    , 2
    , {- then -} 3
    ]

the formatter is handed a tree for sizes = [ 1, 2, 3 ], and, off to one side, this (you can print it for any file with --pre-context):

{ "comments": [
    { "type": "line",  "value": " smallest",
      "start": { "row": 5, "col": 9 }, "end": { "row": 5, "col": 20 } },
    { "type": "block", "value": " then ",
      "start": { "row": 7, "col": 7 }, "end": { "row": 7, "col": 17 } } ] }

That is the entire input. Nothing connects the two halves. The comment does not know it is inside an array; the array does not know a comment exists. All the formatter has is a row and a column, and the rows and columns of the code it is placing them into.

Putting them back together is what the rest of this document is about.

Why not fix it upstream? elm-format builds comments into its own AST, so an expression node physically holds the comments written around it, and it never has this problem. That is a real advantage, and we pay for it here — in exchange for never disagreeing with the compiler about what a program is.


What counts as correct

Three things, and the third is the one that makes this hard.

1. Every comment survives. Same text, same kind, exactly once. gren-format never edits what you wrote inside a comment. (It does re-indent the continuation lines of a multi-line {- … -} so its body hangs under its own {-; that is layout, not text.)

2. It lands where you wrote it. Beside the code it was about — the seven rules below are the full statement.

3. Formatting twice changes nothing. format(format(x)) must equal format(x), byte for byte.

That third property is not a nicety. gren-format runs on save and in CI, and a file that alternates between two spellings produces phantom diffs for ever.

Here is why it is the hard one. The formatter decides where a comment goes by looking at the row and column you wrote it on — that is the only signal it has. But formatting moves code onto different rows. So the second format asks the same question of different evidence, and can perfectly reasonably come to a different answer:

you wrote:              a naive format¹:         …and format² would produce:

v =                     v =                      v =
    fn a b {- c             fn a b                   fn a b
   second -}                {- c                 {- c
                               second -}            second -}

Format¹ reasoned “this comment is on the declaration’s last row” and put it at the call’s indent. But a multi-line comment cannot sit on that row — it brings its own line breaks — so it landed below the declaration instead. Format² now sees a comment written below a declaration, which is a different question with a different answer, and the file has two spellings and no fixed point.

Nearly every comment bug this formatter has ever had is a variation on one sentence: a decision made from a fact that the formatting itself then invalidates.


The idea: decide once

The whole design follows from taking that sentence seriously.

Every comment’s placement is decided exactly once, early, while the rows are still the ones you wrote. The answer is stored on the comment. Nothing downstream recomputes it.

The stored answer is called the comment’s role — how it joins the code around it. There are seven, and between them they cover every position a comment can hold:

role means
TrailsPrevious glue onto the end of the thing before it — x = 1 -- why
LeadsLine stand on its own line, above what follows
LeadsNext it belongs to what comes after an invisible separator (C2)
TrailsHead glue onto a container’s head — a record update’s base name
RidesInline ride mid-line without breaking it — f {- k -} x
LeadsInline glued to the front of a declaration — {- c -} import Qux
Standalone detached: its own line, at column 1

You can see them for any file:

node gren-format/app --lpt MyFile.gren     # every comment leaf carries its "role"

Because the decision is taken once, it is taken against the rows you wrote, which is exactly the evidence the next format will re-derive from the output — as long as each role is chosen so that the row it was decided from is the row it renders on. That is the rule the whole thing turns on, and it is what makes formatting a fixed point rather than an oscillation.

The corollary is a hard internal boundary: no code in the rendering half of the formatter is allowed to look at a source row to decide where a comment goes. It reads the stored role. (This is enforced by a check that runs before the test suite, not merely written down.)


Four questions, asked once

Placement is four questions, asked in order. Here they are against the file from the top of this page, which the formatter leaves exactly as written:

sizes =
    [ 1 -- smallest
    , 2
    , {- then -} 3
    ]

Question 1 — which declaration?

The tree is a list of top-level declarations, each covering a range of source rows. Find the one whose range contains the comment’s row. Both of ours are inside sizes.

If no declaration covers it, the comment detaches — it becomes its own item at column 1. That is not a fallback; it is a deliberate rule, and it is why this happens:

-- you write:                    -- gren-format writes:

b =                              b =
    1                                1
    -- detached below b          -- detached below b

Column 1 is trivially stable — it cannot drift. Any rule that instead claimed such a comment for the code above would have to place it at some indent, and then re-derive the same claim from an indent that formatting has moved. An earlier design did exactly that, and the comment walked leftwards a few columns on every format. elm-format detaches these too.

Question 2 — how deep?

Descend into the declaration, picking at each level the innermost thing that genuinely owns the comment. Both of ours descend into the array.

The interesting half of this question is knowing when to stop. A comment that merely trails something must not be sucked inside it:

v =
    fn a { r | x = 1 } {- note -} b

That comment is about the record, and it sits past the record’s closing } — on the record’s last row, which is the one place where “inside this” and “after this” are both readable. Pulled inside, it would come out as { r | x = 1 {- note -} }, where it now reads as a note about the field. So the formatter keeps it out. Written before the }, it belongs inside, and stays there:

fn a { r | a = 1 {- c -} } b     -- about the field: goes inside
fn a { r | a = 1 } {- c -} b     -- about the record: stays out

Question 3 — which gap?

Between which two things at that level does it sit? Count how many of them end before the comment.

-- smallest lands after the 1. {- then -} lands between the 2 and the 3.

Question 4 — how does it join?

This is where the role is chosen. -- smallest is on the same row as the 1 before it, so it trails it: TrailsPrevious. {- then -} sits in the gap at a comma — and a comma is one of the separators the parser does not record a position for, so the two ways of typing it are literally the same input to the formatter. That case is C2 below, and the answer is LeadsNext: the comment lands after the separator, not before it.

Everything after this point just draws what was decided. The renderer’s only remaining question about a comment is about its text, not its position: can code follow it on the same line? That is the next section.


The two kinds of comment

Almost every difference in how two comments are treated comes down to one mechanical fact: can the code around this comment still fit on one line?

  Can the surrounding code stay on one line?
-- like this No — a -- runs to the end of its line, so nothing can follow it there.
{- like this -} (all on one line) Yes — code carries on right after the -}.
{- like this ⏎ ` and this -}` (several lines) No — the comment itself spans lines, so whatever contains it must too.

So a single-line {- -} can ride along in the middle of a line, and the other two cannot. Wherever a rule below says “a comment that can’t share the line”, it means a -- or a multi-line {- … -}.

-- you write, and gren-format keeps (the block comment rides the line):
sizes =
    [ 1, {- one -} 2, 3 ]
-- you write:
sizes =
    [ 1 -- one
    , 2, 3 ]
-- gren-format writes (the `--` ends its line, so the array has to open up):
sizes =
    [ 1 -- one
    , 2
    , 3
    ]

The seven rules at a glance

  1. C1 — A comment belongs to the code you wrote it next to.
  2. C2 — Where the parser doesn’t record the punctuation, the comment lands after it, not before.
  3. C3 — A comment never forces a break.
  4. C4 — A comment changes where the lines fall, and nothing else.
  5. C5 — gren-format adds nothing around a comment.
  6. C6 — A line-leading comment is indented to the code it leads.
  7. C7 — Comments written together stay together; comments written apart stay apart.

The first two are about which piece of code a comment is attached to; the last five are about how the attached comment is laid out. They never trade against each other: attachment is settled first, and the layout works with whatever it is given.

C1 and C2 are in order, not in competition. C1 decides wherever your source gives the formatter enough to see what you meant; C2 is the tie-breaker for the places where it doesn’t. C2 never overrides C1.


C1 — A comment belongs to the code you wrote it next to

Where a comment ends up is decided by where you wrote it, and nothing else. Layout never re-homes a comment onto a different piece of code to make a line fit.

-- you write:
f =
    someFunction
        argOne -- about argOne
        argTwo
-- gren-format writes (unchanged — the note is still on argOne's line):
f =
    someFunction
        argOne -- about argOne
        argTwo

Nothing about argTwo needing its own line, or the call being wide, can pull that comment off argOne.

The same holds inside a list. The comment stays on the item it was written after, not on the one below it:

-- you write, and gren-format keeps:
colors =
    [ "red" -- the warm one
    , "blue"
    ]

And in a nested list, a comment stays inside the list it was written in:

-- you write, and gren-format keeps:
b =
    [ 1
    , [ 2
      -- about three
      , 3
      ]
    ]

Where the source records a bracket, the side you wrote the comment on is kept — just inside an opening bracket stays inside, just past a closing bracket stays outside:

-- you write, and gren-format keeps both:
a =
    [ {- primary -} 1, 2 ]


b =
    fn a [ 1, 2 ] {- c -} last

C2 — When the parser doesn’t record the punctuation, the comment lands after it

Gren’s parser reads punctuation and keywords and then throws most of them away. Of everything that can separate two pieces of an expression, only a binary operator (+, ++, |>, …) and a bracket ((, [, { and their closers) survive with a recorded position. All of these are gone by the time the formatter runs:

=    :    |    ,    ->    if / then / else    when / is    let / in
an import's `as` and alias name

That means x {- c -} = y and x = {- c -} y arrive at the formatter as exactly the same information: where x ends, where the comment is, where y starts. The formatter cannot tell which side you wrote it on. (It also does not look at how wide the whitespace gaps are — formatting must not depend on your spacing.)

So one of the two spellings has to move. gren-format always picks the later side: the comment lands after the punctuation, never before it.

-- you write (either one of these):
point =
    { x {- across -} = 1 }


point2 =
    { x = {- across -} 1 }
-- gren-format writes (both the same):
point =
    { x = {- across -} 1 }


point2 =
    { x = {- across -} 1 }

The same thing at a ,:

-- you write (either one):
a =
    [ 1 {- c -}, 2 ]


b =
    [ 1, {- c -} 2 ]
-- gren-format writes (both the same):
a =
    [ 1, {- c -} 2 ]


b =
    [ 1, {- c -} 2 ]

At a signature’s : and at a type’s ->:

-- you write:
double {- c -} : Int -> Int
double n =
    n


triple : Int {- c -} -> Int
triple n =
    n
-- gren-format writes:
double : {- c -} Int -> Int
double n =
    n


triple : Int -> {- c -} Int
triple n =
    n

At the keywords. when … is:

-- you write:
f sel =
    when sel {- c -} is
        Just w ->
            1

        Nothing ->
            2
-- gren-format writes:
f sel =
    when sel is
        {- c -}
        Just w ->
            1

        Nothing ->
            2

At a branch’s ->:

-- you write:
f x =
    when x is
        Red -> -- a warm one
            1

        Blue ->
            2
-- gren-format writes (the comment leads the branch body):
f x =
    when x is
        Red ->
            -- a warm one
            1

        Blue ->
            2

At then:

-- you write:
f x =
    if x -- why
    then
        1

    else
        2
-- gren-format writes:
f x =
    if x then
        -- why
        1

    else
        2

And at in:

-- you write:
f =
    let
        a =
            1
        -- after the last binding
    in
    a
-- gren-format writes (the comment lands below `in`, leading the result):
f =
    let
        a =
            1
    in
    -- after the last binding
    a

The exception: a -- or a multi-line {- … -} above a ,, a |, or a ->

There is one family of separators where both spellings survive, because the formatter can still tell them apart: the separators that start their line — a ,, a |, and the -> of a signature broken across rows.

The reason is simple. A -- (or a multi-line {- … -}) ends its line, so it is either on the previous item’s line or on a line of its own — two visibly different places. And because these separators lead their line rather than trailing it, a comment written above one strands nothing: it just sits at the separator’s own column.

So both of these are kept exactly as written:

-- you write, and gren-format keeps:
a =
    [ apple -- the red one
    , banana
    ]


b =
    [ apple
    -- about banana
    , banana
    ]

A record update’s | behaves the same way:

-- you write, and gren-format keeps:
a rec =
    { rec -- about the base
        | alpha = 1
    }


b rec =
    { rec
        -- about alpha
        | alpha = 1
    }

And so does a custom type’s |:

-- you write, and gren-format keeps:
type W
    = A -- about A
    | B

And so does a signature’s ->, once the signature is broken across rows:

-- you write, and gren-format keeps:
trailing :
    Int -- the argument
    -> String


ownRow :
    Int
    -- the argument
    -> String

A single-line {- -} has no such pull — it doesn’t end its line, so the two spellings really are identical — and it follows the general rule at every separator, including these:

-- you write (either one of these):
c rec =
    { rec {- inline -} | alpha = 1 }


d rec =
    { rec | {- inline -} alpha = 1 }
-- gren-format writes (both the same):
c rec =
    { rec | {- inline -} alpha = 1 }


d rec =
    { rec | {- inline -} alpha = 1 }

So d’s author gets their text back and c’s does not. A custom type’s | answers the same question the other way — see Three places that don’t take the later side.

Three ways to type it, two possible results

At a , there is a third spelling: after the comma but still on the first item’s line. That puts the comment in the same place as the first spelling, so the formatter can’t distinguish them, and it collapses onto the first:

-- you write:
c =
    [ apple, -- c
      banana
    ]
-- gren-format writes:
c =
    [ apple -- c
    , banana
    ]

So there are three ways to type it and only ever two outcomes. The -> has the same third spelling, and it collapses the same way:

-- you write:
pastArrow : Int -> -- the argument
    String
-- gren-format writes:
pastArrow :
    Int -- the argument
    -> String

Three places that don’t take the later side

Because both spellings collapse into one output, the side C2 picks decides which of the two authors gets their text back unchanged — the one who wrote the comment before the separator, or the one who wrote it after. There is no choice that serves both. C2 serves the author who writes it after; the three places below serve the one who writes it before. The first has a structural reason; the other two are a stated preference.


C3 — A comment never forces a break

Something you wrote on one line stays on one line, as long as every comment inside it can share that line. A single-line {- -} can; a -- and a multi-line {- … -} cannot.

This is about the code’s line. The same sentence applied to a comment’s own line is C7: two comments you wrote on one row can share it, so they keep it.

-- you write, and gren-format keeps all of these on one line:
a =
    f {- k -} x


b =
    [ 1, {- c -} 2 ]


d =
    { x = 0, {- origin -} y = 0 }

This holds for a comment written in front of any kind of item — a value, a record, a call, a lambda:

-- you write, and gren-format keeps:
a =
    [ {- c -} \q -> q + one ]


b =
    [ {- c -} { a = 1 } ]


c =
    [ {- c -} fn one ]

When the comment can’t share the line, the construct opens up — but that is the comment’s own line ending, not a decision the formatter made:

-- you write:
a =
    [ 1 {- spans
           two lines -}, 2 ]
-- gren-format writes:
a =
    [ 1 {- spans
           two lines -}
    , 2
    ]

The comment spans lines, so the array cannot be a one-line array any more; and once a construct is spread across lines, gren-format puts one item per line like it always does. That is the only kind of break a comment ever causes — and the next rule is what says so.


C4 — A comment changes where the lines fall, and nothing else

A comment can force line breaks; that’s C3, and it’s unavoidable. Everything past that — the indentation, the grouping, which piece of code owns which part — has to be what gren-format would have produced with the comment deleted.

The test: write the same code without the comment and compare. If the two disagree about anything other than where the lines fall, that’s a bug.

Grouping is unchanged. A -- in the middle of an operator chain has to break the chain, but the chain breaks at the operator that precedence chooses, never at whichever operator the comment happens to sit next to:

-- you write:
a =
    one + two -- c
        * three
-- gren-format writes (broken at the `+`, which binds loosest):
a =
    one
        + two -- c
          * three
-- and with the comment deleted, the same expression:
b =
    one + two * three

Both forms read as one + (two * three). Breaking after two instead — which is where the comment sits — would read as (one + two) * three, a grouping gren-format never produces.

Indentation is unchanged. Here is the same expression with and without a comment leading it:

-- you write, and gren-format keeps:
withComment =
    gn <|
        {- c -}
        { x = 1
        , y = 2
        }


withoutComment =
    gn <|
        { x = 1
        , y = 2
        }

The record is at the same column both times. The comment adds a line; it does not add a level of indentation.

Ownership is unchanged. A comment that splits a function from its argument leaves the argument indented under the function exactly as any other broken call would:

-- you write, and gren-format keeps:
a =
    gn -- c
        arg


b =
    gn arg

C5 — gren-format adds nothing around a comment

No blank line above or below a comment because of the comment. No floating a comment out onto a line of its own to give it room.

-- you write, and gren-format keeps (no blank line inserted above the comment):
a =
    [ 1
    -- about two
    , 2
    ]

(elm-format does insert a blank line here — see the divergence catalogue.)

A comment written on an operator’s line stays on that line — whether you put it before the operator or after it — rather than being given a line of its own:

-- you write, and gren-format keeps:
a =
    head
        {- c -} ++ rest


b =
    items
        {- c -} |> fn


c =
    fn <| -- c
        one

Two things that look like counter-examples and aren’t:


C6 — A line-leading comment is indented to the code it leads

A line-leading comment — one with nothing before it on its row — is indented to match the code below it, not to a column of its own and not to the column you happened to type it at. What comes after it on the row is not part of the test: a single-line {- -} that starts a row and is followed by code is line-leading too, and takes the same indent.

Precisely: to the column where the line it leads begins. In a call, a let, or a when body, that’s just the code’s column:

-- you write, and gren-format keeps:
a =
    someFunction
        argOne
        -- about argTwo
        argTwo

In a list, a record update, or a custom type, the line below begins with its , or | prefix — so that’s where the comment goes, two columns to the left of the item text:

-- you write, and gren-format keeps all three:
b =
    [ 1
    , [ 2
      -- about three
      , 3
      ]
    ]


c rec =
    { rec
        -- about alpha
        | alpha = 1
    }


type U
    = A
    -- about B
    | B

This is normalizing, not preserving. Line the comment up with the item text instead, and gren-format pulls it back to the prefix column:

-- you write:
a =
    [ apple
      -- about banana
    , banana
    ]
-- gren-format writes:
a =
    [ apple
    -- about banana
    , banana
    ]

A comment on its own line below a top-level declaration follows the same rule: everything at the top level begins at column 1, so the comment moves to column 1 — whether it trails the declaration above or introduces the one below. What your original indentation still decides is which declaration the comment belongs to — written indented under the code above, it stays with that code and a blank line separates it from what follows:

-- you write:
total =
    alpha
        ++ beta
        {- trails the chain -}
next =
    1
-- gren-format writes (comment stays with `total`, at column 1):
total =
    alpha
        ++ beta
{- trails the chain -}


next =
    1

The full treatment of that case, including the version where the comment introduces the next declaration instead, is in A comment on its own line below a declaration.


C7 — Comments written together stay together; comments written apart stay apart

gren-format never joins two comments you wrote on separate rows, and never splits two you wrote on the same row. Where C3 is about the code’s line, this is about the comments’ own.

Two or more comments in one gap are a run (there is a section on runs below), and the same question decides their rows: gren-format never moves a member of a run between rows. Written on one row they stay on one row; written on separate rows they stay apart. It holds in every position — a lambda body, an else branch, a record field, a call’s argument list, a let binding, a when branch — so you never have to know which one you are in.

One position is an exception: a run written just inside an opening bracket, or between a pipeline step’s operator and its operand, is laid out all-or-nothing instead. The authored row isn’t recorded there, so neither direction can be honoured — see Known limitations.

-- you write:                        -- gren-format:
\q ->                                \q ->
    {- a -} {- b -} { x = 1              {- a -} {- b -}
    , y = 2 }                            { x = 1
                                         , y = 2
                                         }

\q ->                                \q ->
    {- a -}                              {- a -}
    {- b -}                              {- b -}
    { x = 1                              { x = 1
    , y = 2 }                            , y = 2
                                         }

A -- counts as a member like any other: {- a -} {- b -} -- c written on one row stays on that row, with the code below it.

This is a deliberate divergence from elm-format, which decides a run’s rows from the context around it instead — stacking one-per-row after a lambda’s -> and a declaration’s =, keeping them together in an else branch or a call argument, and joining a run you did split when it sits inside a paren. See divergence #30.

The two directions are not the same rule twice. Keeping a one-row run together is C3 and C5 already — every member can share the line, and floating one out onto a row of its own to give it room is exactly what C5 forbids. Keeping a split run apart is the part neither covers: C3 says a comment never forces a break, not that a break you made survives, and C5 forbids adding, while joining two rows takes one away. C7 states both halves as one rule so there is a single sentence to check against.


Comments in runs

Two or more comments in the same gap are a run, and a run is a unit. C7 above is the rule about a run’s rows; four more govern the rest, and each is there because the run as a whole has a property no single member does.

The reference row grows through the run. When the formatter asks “what row does the thing before me end on”, the answer counts any comments already written onto that row. A multi-line comment closes several rows below where it opened, and what you wrote after its -} sits beside it — not on a line of its own:

fruit =
    [ Apple
    , Mango {- mango's
               comment -} -- and mango's trailing line comment
    , Pear
    ]

A run crosses a separator together, or not at all. C2 moves a single-line {- -} across an unrecorded separator, while a -- and a multi-line comment both stay with the item above. So a mixed run in one gap would tear in half if each member were asked separately. It is asked of the whole run instead. These two differ in nothing but which comments are in the gap, and that alone decides where both of them land:

-- both members could cross, so the run crosses:
v =
    [ 1
    , {- a -} {- b -} 2
    ]


-- one member cannot, so neither does:
w =
    [ 1 {- a -} -- b
    , 2
    ]

Once a run breaks, it stays broken. If any member cannot share a line, every member gets its own row:

c =
    [ {- 1 a
         1 b -}
      {- x -}
      1
    ]

A run moves as a unit. When a run is detached, sorted along with a name in an exposing list, or given a blank line above it, it is the run that moves — never just its first member.

Put together, here is a run of six comments of all three kinds in one gap. Every member keeps the row you wrote it on (C7), each multi-line body is re-indented under its own {-, and the file is a fixed point:

v =
    fn a {- 1 -} {- 2
                    over two rows -} {- 3 -} {- 4
                                                again -} {- 5 -} -- 6

Why this works for any number of comments

A fair question about the rules above is: those are five rules about runs, so what happens at seven members, or twelve, or with kinds nobody has tried in that order?

Nothing new — and that is a property of how the rules are written rather than a claim about how much testing they have had.

Most of them are rules about a boundary: a comment and the one thing immediately before it. None looks two comments back. So picture a run as a chain:

code │ A │ B │ C │ code
     ↑   ↑   ↑   ↑
     each of these rules is a question about ONE of these

If that is all they read, then what a run does is decided by which boundaries it contains — and its length is not an input at all. There are three comment shapes, so there are exactly nine possible comment-to-comment boundaries, and a run of any size is built out of those nine.

A longer run can therefore reach something new in only two ways: by containing a boundary a shorter one could not, or by putting a member between two boundaries at once. The first is exhausted as soon as all nine have appeared. The second only matters if some rule looks at both sides of a member — and none does.

The other rules — crossing a separator, riding a line, moving as a unit — do read the whole run, but they read it as a single yes-or-no question asked of every member: can they all cross? can they all ride? Asking “does this hold for everything in the list” does not care how long the list is either, so those are length-independent for a different reason rather than an exception.

That is a claim that can be checked rather than asserted, and it is: the test suite sweeps comment runs across the whole corpus, varying both how many members they have and which kinds, in every gap of every fixture. It behaves the way the argument says it must — the sweeps that first reach a new boundary find real bugs, and the ones beyond that find nothing that the earlier ones had not. The argument in full, with the numbers, is The comment algorithm §8 and §10.


Where the rules run out

A few placements are genuinely undecidable from what the parser hands over, and it is better to know which they are than to be surprised by them. Each is describe, along with an example, in Known limitations.

A comment after the last let binding ends up below the in.

-- you write:                       -- gren-format writes:

f =                                 f =
    let                                 let
        a = 1                               a =
        -- after the last binding               1
    in                                  in
    a                                   -- after the last binding
                                        a

in has no recorded position, so “before in” and “after in” are literally the same input. Keeping the comment with the bindings looks right and is not stable — it oscillates. Below is the only choice that is both stable and defensible. elm-format keeps it with the bindings, so this is a difference you may notice.

A -- inside an effect module’s where { … } block can escape the block. The parser hands back byte-identical information for both layouts, so there is nothing to decide from.

A comment after the last name of a flat, one-line exposing ( … ) list is read as the list’s rather than that name’s, because the closing ) has no recorded position to measure against. Write the list across several lines and the two become tellable apart again.

A run of comments just inside an opening bracket loses the rows you gave it (C7’s one exception, above): one gap is one attachment, so the run carries a single role derived from its members’ shapes, and nothing records which of them you wrote together.

Where gren-format and elm-format place a comment differently — and there are several such places, all deliberate — the full list is in Comparison with elm-format.


Trying it yourself

node gren-format/app --lpt MyFile.gren          # every comment, with the role it got
node gren-format/app --pre-context MyFile.gren  # what the parser handed over
node gren-format/app --show MyFile.gren         # format, re-parse, check the meaning
                                                # is unchanged, format again, check
                                                # nothing moved

(gren-format/app is the standalone CLI, built with ./build.sh in that directory.)

--show is the useful one: a zero exit status means the file formatted without crashing, kept its meaning, and is a fixed point.


See also