Places where gren-format falls short of ideal — a compiler/parser bug it
inherits, a shape the language no longer allows but the parser still accepts,
or a comment-placement choice forced by a token with no recorded source
position. See the main README for the overview, and
Gren Formatter Rules for what the formatter does when
nothing is going wrong.
as- whose right operand starts at the operator’s own columnwhen branch patternswhere blockexposing doesn’t sort with the first nameexposing list’s closing paren isn’t recorded
letField access directly after a closing paren, a plain record literal, or a
qualified name — (getUser model).name, { x = 1 }.x,
Config.default.timeout — parses and formats correctly. But a
record-update base still hits a narrower case of the same bug:
{ model | x = 0 }.x
is formatted with a space before the dot ({ model | x = 0 } .x). That
space changes the meaning: it applies the accessor function .x to the
record update instead of reading its field, and the formatted file no
longer compiles. The parser reads both spellings as the same expression
(Call(Update, [Accessor])) — the same root cause as
compiler-common#27,
which fixed every other kind of base but not this one.
asf x =
when x is
Just y as whole ->
whole
fails to parse — Expected keyword '->' at as — even though the real Gren
compiler accepts it. The parser accepts as after a bare variable or
wildcard (x as step, _ as step) and after a parenthesized constructor
application ((Just y) as whole), but not after an unparenthesized one. Since
parsing happens before formatting, gren-format refuses the whole file, not
just this pattern — real fallout: core/src/String/Parser/Advanced.gren has
this exact shape and can’t be formatted until it’s fixed. Tracked at
compiler-common#31.
Workaround: add the parens yourself, (Just y) as whole.
When the body of a block construct starts on the row after its keyword, the
parser scopes the body’s indent to that first token’s column and then requires a
strictly greater column to continue. A continuation line at exactly the same
column therefore ends the body early. The real Gren compiler accepts every shape
below, and elm-format agrees with the real compiler on all of them. Tracked at
compiler-common#14,
which also carries a proposed fix; the when half was reported separately as
compiler-common#11.
It fails two different ways. In a let binding or a when branch nothing can
absorb the stranded token, so the file is rejected and gren-format refuses to
format it at all:
v =
let
b =
add 1
2 -- same column as `add` -> "Expected character '='"
in
b
In a lambda body or an if/else branch an enclosing scope can absorb it, so
the parse succeeds with a different AST:
v = \q ->
fn one
two -- same column as `fn`
is parsed as (\q -> fn one) two rather than \q -> fn one two, so two is not
an argument of fn and gets no continuation indent:
-- gren-format -- elm-format
v = v =
\q -> \q ->
fn one fn one
two two
The output still means the same thing to the real compiler as the input did, so
the damage is layout, plus the outright refusal in the let / when cases — in
the two shapes above. At the top level it is worse than that; see the next
section.
Both failure modes above are loud: you either get a parse error or a file that
still means what you wrote. There is a third, and it is neither. When the
stranded token sits at the top level of a declaration’s body, the enclosing
scope that absorbs it is the module — and compiler-common lets a declaration
head start in any column, where both the real Gren compiler and Elm require
column 1
(compiler-common#37,
elm-format comparison #31). So the parser
takes the stray row for the head of a new declaration and reads forward until
it finds an =.
It therefore takes two upstream bugs to get here, and fixing either one is enough to remove this outcome: #14 ends the body early at the stray row, and #37 turns that stranded row into a declaration instead of an error.
init env =
await env <| \a ->
await env <| \b ->
a + b
cleanup env
helper y =
y
cleanup env is at the column of the two chain rows — written as one more step
of the chain. gren-format formats the file without complaint, exit status 0, and
hands back:
init env =
await env <| \a ->
await env <| \b ->
a + b
cleanup env helper y =
y
init’s body has lost its last row, helper is no longer declared at all, and a
declaration of cleanup nobody wrote has appeared — with helper’s body. The
real compiler reads the same input the other way, as two more arguments to the
row above, and reports a type error inside init. Input and output are
therefore different programs, and the input’s error is not the output’s: the
input fails inside init, while the output leaves init alone and declares
cleanup — which, in the module where cleanup really is declared, comes back
as a name clash on it.
No check here can see it. The AST comparison holds the formatter’s parse of the input against its parse of the output, and both are the same misparse; the idempotency check re-formats a file that is, on its own reading, well-formed. Only the real compiler disagrees, and nothing in the pipeline asks it.
Which of the three outcomes you get is decided by what follows the stray row, not by the row itself:
| what follows the stray row | outcome |
|---|---|
a let or when scope closing (in, the next branch) |
parse error — nothing can absorb the token |
| end of file | parse error |
| a declaration with a type annotation | parse error — the : cannot be absorbed into a head |
| a declaration without one | the silent rewrite above |
Most top-level declarations in real code carry an annotation, which is why the loud form is the one usually met. The workaround is #14’s: indent the continuation past the first row of the body.
“Same column” is the shape that gets there without help; the general condition is
a column that falls between the two scopes. The body’s scope is
lineStart — the first non-whitespace column of the row the body’s first term
sits on — while the enclosing scope keeps whatever looser indent it had, and a
token in between is refused by the inner one and then absorbed by the outer one
as an argument of the lambda itself. A multi-line comment is a way to land a
token in that window, because the comment’s own closing row occupies the columns
in front of it:
chosenSink =
sinks
|> keepIf (\{ kind, endpoint } -> not {- ¤1
second row -} {- ¤2 -} (isEmpty endpoint) && kind /= "noop")
(isEmpty endpoint) starts at column 27: past the let binding’s scope (21),
short of the |> row’s lineStart (29). So it is not an argument of not — the
whole thing parses as (\{ kind, endpoint } -> not) (isEmpty endpoint) && …, and
gren make accepts the file, which settles that the real compiler reads it the
other way. Take the comment out and the same layout is a hard parse error rather
than a misparse, so the comment is not incidental to reaching it.
tests/fuzz-idempotency.py’s known_upstream_issue labels a finding
[known: compiler-common#14] on this shape, on two signals: a call whose fn
is a bare lambda / if / when / let (a parenthesized one arrives
wrapped in a parens node, so an unwrapped one cannot have been written), and
that call’s first argument starting on a different row than its fn ends on.
Not seen in practice: a sweep of the 288-file gren-format-preview/pkgs corpus
finds no instance of any of these shapes, because real code indents the
continuation. Workaround: indent it past the first line of the body.
The full write-up, with both --pre-ast dumps and the gren make type error
that pins down the real compiler’s reading, is in
gren-format/parser-same-column-continuation-bug.md, and on
compiler-common#14.
A top-level declaration must begin in column 1. Elm requires it, and so does the
real Gren compiler — chompDecls guards every declaration with
Space.checkFreshLine E.DeclStart, which is literally col == 1.
compiler-common does not: its declarationLoopParser loops Declaration.parser
with no column test at all, so a declaration head that starts anywhere is read as
a declaration. Tracked at
compiler-common#37.
Whether an off-column head is reached is not itself a column rule: the declaration above it absorbs the name as an application argument whenever it can, and what ends that declaration is its body’s own indent scoping. So the shape is fiddly to write on purpose and does not turn up by accident.
It costs two different things depending on whether a comment is involved.
Without a comment, gren-format quietly repairs the file. The names normalize back to column 1, and the output compiles:
-- you write (rejected by gren 0.6.6): -- gren-format writes (compiles):
one = one =
1 1
a = a =
one one
b = b =
2 2
That is benign in itself, but it means gren-format --show exiting 0 is not
evidence that the file you handed it would build. Nothing in the pipeline asks
the real compiler.
With a comment in front of the name, the output is refused too. Because gren-format never moves a comment off the row it was written on (C7), the name stays right of the comment in the output, so the off-column head survives formatting:
module M exposing (foo)
{- lead -} foo =
1
is a fixed point — gren-format formats it to itself, exit 0 — and gren 0.6.6 refuses both the input and that output:
-- SYNTAX PROBLEM --------------------------------------------------- src/M.gren
I got stuck here:
4| {- lead -} foo =
^
This is not the same class as the section
below. There,
compiler-common is deliberately more permissive and the formatted output
becomes correct when the new compiler ships. Here the permissiveness is a bug,
and the fix goes the other way: #37 will make compiler-common reject these
files too, at which point this entry retires rather than becoming the spec.
It also has a second, worse consequence in combination with compiler-common#14 — see At the top level it rewrites the file instead of refusing it, where a stray row of a mis-scoped body becomes a declaration nobody wrote. Fixing either bug removes that outcome.
The elm-format side of the same fact is divergence #31, which is the one catalogue entry that is a bug rather than a decision.
Workaround: start declarations in column 1, and give a comment above a
declaration a row of its own — -- lead ⏎ foo = formats and compiles.
gren-format is built on compiler-common, which is the parser the next
Gren compiler will use. Today’s released compiler is the Haskell one, and the
two do not accept exactly the same language: compiler-common is deliberately
more permissive about indentation. Where they differ, the formatter follows
compiler-common — so it can hand you a file the compiler you are using right
now refuses to build.
A bracketed pattern is where this shows up. Write one broken across rows with its continuation indented, and today’s compiler is happy:
f z =
let
{ next
, count
} =
z
in
next
gren-format moves the , and the } back to the {’s own column, which is
also the column the binding starts at:
f z =
let
{ next
, count
} =
z
in
next
That is the canonical form, and compiler-common parses it — so the formatter’s
own checks all pass: the AST is preserved and the output is a fixed point. The
Haskell compiler stops at the first continuation row, because it requires every
row inside a binding to be indented past the binding’s start column:
-- UNFINISHED RECORD PATTERN ---------------------------------------- src/M.gren
I was partway through parsing a record pattern, but I got stuck here:
6| { next
^
I was expecting to see a closing curly brace next. Try adding a } here?
Elm rejects it too, with the same message — this is a place where Gren is departing from what it inherited, not a bug in either parser.
The same applies to an array pattern, and in a when branch head as well as a
let binding:
g z =
when z is
[ a
, b
] ->
a
Parameter patterns are unaffected — a pattern in an argument position does not start its line, so its continuation still clears the declaration’s column.
If you are on the released compiler, the workaround is to keep such a
pattern on one line; { next, count } = is accepted by both. This limitation
disappears when the compiler-common-based compiler ships, at which point the
formatted output above compiles as written.
- whose right operand starts at the operator’s own column10 - ⏎ ` 3 is read by the parser as the **call** 10 (-3) — the -
becomes a unary negation on the operand below instead of the subtraction
operator. The real Gren compiler reads it as subtraction (a module using it as an
Int compiles, which a call of 10` could not), and elm-format agrees. Tracked at
compiler-common#35,
which carries the AST dumps, the column grid and a one-line proposed fix.
The trigger is the column, and nothing else: the right operand starting one
past the -, on a later row. argOrOperatorLoop decides “no space after the
operator” by comparing operator.end.col == pos.col after the whitespace
parser has run, so it ignores the row. Shifting the operand one column either
way flips the parse, and no operand kind is safe:
10 - -- MISPARSED as 10 (-3): `-` ends at col 9, `3` is at col 9
3
10 - -- ok: `3` is at col 10
3
a - -- MISPARSED: `-` ends at col 8, `b` is at col 8
b
a - -- ok: `b` is at col 9
b
1.5 - -- MISPARSED at the matching column; so are 0x10, (a), fn a
3
10 + -- ok at any column: only `-` has a unary form
3
A blank line between the two rows makes no difference.
Without a comment, the file is silently rewritten. gren-format renders the tree it was handed — a call whose argument is a negation, and a negation is glued to its operand — so the two rows above come out as one:
subtraction = -- input -- gren-format's rendering
10 - 10 -3
3
10 -3 is a call to both parsers (the real compiler rejects it with TOO MANY
ARGS), so parse → format → reparse → AST-compare sees two identical trees, the
check passes, and the file is written. A subtraction the real compiler accepted
is now a call it refuses. Nothing in this repo’s gates can see that, for the
same reason the 2^53 rewrite
below is invisible: the damage is done in the parser, before the formatter
sees a thing (verified 2026-08-27 against the devbox gren@0.6 compiler).
With a comment after the operator, the file is refused instead. The misparse becomes visible in the output: gren-format renders the negation glued to its operand, as it must, and the comment lands between them:
subtraction = -- input -- gren-format's rendering
10 - -- c 10
3 --- c
3
The - is now inside the --, so the output no longer means what the input
did, and gren-format’s AST check catches this and refuses to write the file
(AST MISMATCH AFTER FORMATTING) — nothing is corrupted, but the file cannot be
formatted. The message blames the formatter, which is misleading here; the
render is faithful to the tree it was handed.
Failing is the decision, not an oversight. There is nothing to fix on the
formatter side — a comment between - and its operand does not parse at all in a
genuine negation (v = - -- c ⏎ 3 is a parse error), so a negation node
carrying a leading comment can only arrive through this misparse — and no
workaround is wanted: any rendering faithful to the misparsed tree would rewrite
a subtraction the real compiler accepts. gren-format refuses the file and waits
for compiler-common#35.
Nineteen of fuzz-idempotency.py’s residual findings are this bug — the
fuzzer inserts a -- into the gap after a - in BinaryOps, Records (×2),
BinopParenOperandCommentKind (×2), KitchenComments (×3), KitchenSink,
LambdaPatterns, LetBlankLines, NegateParens, WhenBranchBody,
BinopLayoutByAuthor, BinopMixedPrecedenceBroken, D17PrecedenceSplit,
IfPredicate and IfConditionWhenOperand (×2). Note that none of them is a
shift: --show exits non-zero here because the AST comparison fails, not
because the two formats differ, and the gate counts any non-zero exit as a
finding. Both fuzz-idempotency.py and check-decision-stability.py
name them on sight ([known: compiler-common#35], plus a count in the
summary) so they are not investigated again, and they still count as findings.
All nineteen are registered in tests/idempotency-known-baseline.json, so
fuzz-idempotency.py forgives exactly those and fails on anything else —
including one of them quietly ceasing to reproduce. When the fix ships and the
compiler-common dependency is bumped, they stop being reported, the baseline
empties and the residual drops by nineteen.
Workaround for a file you need formatted today: keep the right operand on the operator’s row, or parenthesize.
36 integers near the top of the exactly-representable range come out of
gren-format as a different number. The file still parses, the AST check
passes, the output is a fixed point — and the program no longer means what it
did:
a = -- input -- after gren-format
9007199254740991 9007199254740992
This is one of two entries here that corrupt rather than refusing or laying
out awkwardly — the other is the comment-free case of the binary - bug above.
Neither is caught by anything, because the corruption happens in the parser,
before the formatter sees a thing. Both parses agree on the
wrong number, so parse → format → reparse → AST-compare compares two identical
wrong trees, and re-formatting the corrupted output reproduces it exactly.
Nothing in this repo’s gates can see it.
Nor can the formatter fix it: by the time InsertExpressions renders the
literal it holds Src.Int 9007199254740992 and the author’s digits are gone.
Both are the same arithmetic slip — a digit added to the accumulator before being normalized, so the intermediate crosses 2^53 (where float64 spacing becomes 2) and rounds, and the later subtraction cannot undo the rounding.
Decimal goes through String.toInt, whose kernel
(gren-lang/core, src/Gren/Kernel/String.js) does
total = 10 * total + code - 0x30, i.e. (10 * total + code) - 0x30. Tracked
at core#134; the fix is one pair
of parentheses. Affected: the 24 odd values in
[9007199254740945, 9007199254740991].
Both fixes are one-line parenthesizations, and neither can be worked around
here — when both have shipped and the core / compiler-common dependencies
are bumped, this whole entry can be deleted.
Hex goes through Compiler.Parse.Number.hexFolder in compiler-common,
which does 16 * acc + charCode - 48 (and 16 * acc + 10 + charCode - 65 for
A-F). Tracked at
compiler-common#36.
Affected: the 27 values in [0x1FFFFFFFFFFFCA, 0x1FFFFFFFFFFFFE] whose last
digit is 1 3 5 7 9 A C E.
Both hex branches are broken, but on opposite parities — the digit branch’s
intermediate is value + 48 and the A-F branch’s is value + 65, and only an
odd intermediate rounds. That is why 0x1FFFFFFFFFFFFF (2^53 - 1) round-trips
correctly while 27 of its neighbours do not. It is the obvious value to probe
with and it certifies a broken path: probe one value from each branch.
The two sets overlap but neither contains the other: 9 values are broken only when written in decimal, 12 only in hex, and 15 are broken in both, so “write it the other way” is not a general workaround. There is no workaround at all for those 15 short of keeping them out of source (compute them, or read them from data) until both fixes ship.
In this repo’s own test suite. tests/src/Test/Formatter/Format.gren pins
intToHex at the 2^53 - 1 boundary:
, hexCase "2^53 - 1 (max exact JS integer)" 9007199254740991 "1FFFFFFFFFFFFF"
Running gren-format over gren-format-lib rewrites that …991 to …992 and
the test then fails, because intToHex correctly reports 20000000000000 for
the number it was actually given. Formatting this repo will keep re-breaking
that line until core#134 ships and the dependency is bumped; repair it by hand
after formatting, and do not “fix” the expectation string to match.
No fuzzer here will ever reach the boundary on its own: gen-random.py draws
decimal literals from 0..99 and hex literals from at most 44 bits, so its
generated integers stop three orders of magnitude short.
when branch patternsA when branch whose record (or array) pattern is too wide to fit on one line
can wrap in a way the Haskell-based Gren compiler rejects:
-- the formatter may produce:
{ aPopped = Just { first = m1, rest = ms1rest }
, bPopped = Just { first = m2, rest = ms2rest }
} ->
The Haskell-based compiler requires every continuation line of a pattern to be indented deeper than the pattern’s first character, and this layout doesn’t satisfy that. Compiling the formatted file may fail with “I was expecting to see a closing curly brace next.” Rejoin the pattern onto one line by hand until this is resolved.
As described in When the formatter can’t tell what you meant, a comment beside =, :, |, or an import’s as always snaps to one
canonical side. Two different intents produce the same output.
An if’s then is one of these tokens, and it is worth naming because the
comment does not merely snap sideways — it changes branch. A comment written
between the condition’s last token and then comes out inside the then
branch’s body:
-- you write: -- gren-format writes:
if a > 0 && b {- x if a > 0 && b then
y -} then {- x
1 y -}
1
else
0 else
0
then has no recorded position, so “the end of the condition” and “the start of
the body” are the same place. Nothing here distinguishes them.
The clearest case is a -- at a , or a |, because the two spellings that
collapse are the two you are most likely to have meant differently. These:
v = v =
{ rec -- c { rec | -- c
| alpha = 1 alpha = 1
} }
reach the formatter as the same three facts — where rec ends, where the comment
is, where alpha starts — because the | between them has no recorded position.
Both therefore format to the first one:
v =
{ rec -- c
| alpha = 1
}
The same is true of [ 1 -- c ⏎ , 2 ] and [ 1, -- c ⏎ 2 ], and of a union
variant’s |. A comment you wrote on a row of its own is a third, genuinely
distinguishable spelling and is left alone:
v =
{ rec
-- c
| alpha = 1
}
The rule is “a -- keeps the row you wrote it on”, and the row above a
line-leading separator belongs to the item (or the record update’s base) above
it. That is uniform across all three separators, which is why it was chosen — but
it costs elm-format parity on the record update, because elm-format has its own
parser, does not have to collapse anything, and renders each of the two spellings
differently. See divergence #22.
A line break inside a declaration’s keyword (e.g. import on one line,
the module name on the next) can cause a blank line to appear between a
comment and that declaration. The root cause is a parser bug that records the
wrong line number for keyword-led declarations:
compiler-common#25.
-- a comment
import
String
formats to:
-- a comment
import String
with a spurious blank line pushed between the comment and the import it was
written directly above — even though writing the same import on one line
(-- a comment / import String) formats with no blank line at all. The
parser records the position of String (the name) rather than import (the
keyword), so the formatter sees a bigger gap between the comment and the
declaration than the author actually left.
where blockAs described in Comments in an effect module’s header,
a comment’s placement near the where { … } block is determined by proximity
to the handler name. Changing the spacing can change where the comment ends up.
This stays as-is until the parser records positions for the missing tokens.
Concretely, the boundary is a two-column slack past the handler name’s end
(Formatter.Logical.Comments.commentInsideTrailingBracket), so a couple of
extra spaces moves a comment out of the block:
-- inside the block (one space after the name):
effect module MyModule where { command = MyCmd {- note -} } exposing (..)
-- outside it (a few more spaces — same comment, same braces):
effect module MyModule where { command = MyCmd {- note -} } exposing (..)
-- formats to:
effect module MyModule where { command = MyCmd } exposing (..) {- note -}
That is a guess, not a reading of the source, and the section below explains why
it has to be one. It is also the one limitation here that can change the
layout of the header rather than only a comment’s home: a multi-line
{- … -} kept inside the block forces the block open across rows, and the same
comment moved out leaves it on one row.
effect module MyModule where { subscription = MySub {- forces the block to wrap
second row -}
} exposing {- tail -}
( a
)
keeps the block open:
effect module MyModule where { subscription = MySub {- forces the block to wrap
second row -}
} exposing {- tail -}
( a
)
while widening that one gap by three spaces collapses it:
effect module MyModule where { subscription = MySub } exposing
{- forces the block to wrap
second row -} {- tail -}
( a
)
Both outputs are stable — each is its own fixed point and each preserves the
AST — so neither the idempotency checks nor the AST comparison objects. Only
tests/fuzz-whitespace.py --mode stretch can see it, and only if a corpus
fixture carries the shape; none does, because such a fixture would fail that
gate for as long as this limitation stands.
The sharpest form of this: a -- comment written inside the braces, on its
own line, does not stay there. It is moved out of the block, below the module
line:
-- you wrote:
effect module MyModule where { command = MyCmd
-- line note
} exposing (..)
-- formats to (the comment is no longer inside the block; it is a top-level
-- comment now, at column 1):
effect module MyModule where { command = MyCmd } exposing (..)
-- line note
The comment survives — nothing is deleted — but it no longer sits beside the handler name it was written next to.
Written past the two-column slack it does not go to column 1 either — it lands
on the header’s own tail, after exposing (..). A comment the author wrote
after exposing (..) then cannot stay on that row, because a -- takes the
rest of its line, so it detaches to column 1: the place the reparse gives it,
and therefore the only placement that is a fixed point.
-- you wrote:
effect module MyModule where { command = MyCmd
, subscription = MySub -- three
} exposing (..) {- four -}
-- formats to:
effect module MyModule where { command = MyCmd, subscription = MySub } exposing (..) -- three
{- four -}
Pinned by HeaderComments/EffectHeaderLineCommentPushesTrailer.
This one cannot be fixed here. The parser records a position for the handler
name and nothing else in the block: not the where, not the braces, not
exposing. So these two files:
effect module MyModule where { command = MyCmd
-- line note
} exposing (..)
effect module MyModule where { command = MyCmd } exposing (..)
-- line note
hand the formatter byte-identical information — same tree, same single
comment at row 2, column 30. There is no fact available to tell them apart, so
they format the same way. A -- comment inside the braces has to be on a line
of its own (it would otherwise comment out the rest of the header), which is
exactly the case that needs the missing } position to place.
A {- … -} is no better off — it only looks better off because the two-column
slack usually guesses right for it. Line these two up so the comment starts at
the same column in both, and they are byte-identical too:
effect module MyModule where { subscription = MySub {- c -} } exposing (..)
effect module MyModule where { subscription = MySub } {- c -} exposing (..)
--pre-ast on the two files produces the same bytes: the comment is at row 1,
column 49 in both, and the } that separates them is recorded nowhere. Whatever
the formatter answers, it answers for both — which is why the answer has to come
from the comment’s column, and why widening a gap changes it.
The fix used for the module’s own exposing ( … ) list does not transfer.
There, MakeLogical.moduleExposingClose retires the same slack guess by
synthesizing a closing position and marking it elastic
(lpnElasticBracketNode), which says “anything that reaches this container is
inside it”. That works because nothing follows the exposing list inside the
declaration. The where { … } block is not last — exposing ( … ) follows it —
and comments genuinely belong out there. Giving the block an elastic close was
tried and measured: it fixes the whitespace flip above and then swallows
comments that were written past the block, including a -- after
exposing (..), which forces the block open across rows:
-- with an elastic where-block close, this fixture
effect module M where { command = CloseCmd, subscription = CloseSub } exposing (..) -- note
-- became
effect module M where { command = CloseCmd
, subscription = CloseSub -- note
} exposing (..)
Three fixtures moved that way (EffectHeaderCloseRowComment,
EffectModuleOpenLineTrailer, EffectModuleFxWhereComment), so the approach is
recorded here as disproven rather than left as an idea to retry.
Fixing this means the parser recording positions for the block’s own tokens.
exposing doesn’t sort with the first nameAs described in Exposed names sort automatically,
a comment on its own line — attached to the first name in an
exposing ( ... ) list — is a special case: the opening ( has no position
in the AST, so the comment is placed as a header-level comment right after
exposing, not as a child of the first name. It renders in that same spot
every time, regardless of which name ends up first after sorting:
module ExposingListSort exposing
( -- describes zebra
zebra
, Kiwi
, apple
, Mango
)
formats to:
module ExposingListSort exposing
-- describes zebra
( Kiwi
, Mango
, apple
, zebra
)
A comment before any other name in the list (not the first) doesn’t have this issue — it travels with its name normally, as shown in Exposed names sort automatically.
exposing list’s closing paren isn’t recordedThe closing ) of a module header’s exposing ( ... ) list has no position in
the AST — the parser records where each exposed name is, but nothing about the
brackets around them. Mostly that costs nothing: when you wrote the list across
rows, the ) is on its own row below the last name, so a comment after that name
is recognised by its row and stays inside the list (see
When the formatter can’t tell what you meant).
One shape does pay for it.
An import’s list doesn’t pay at all — the parser records where an import ends,
) included, so a comment there stays on whichever side of the ) you wrote it,
however much space you left, and however many comments you stack up.
When you wrote the list flat, everything is on one row and the row tells you
nothing. A comment written inside the brackets and one written past them look
alike. The formatter stops trying to tell them apart: it reads any comment after
the list’s last name as belonging to the list, opens the list up, and pins the
comment above the ). Every way of writing it gives the same result —
module FlatClose exposing (apple, zebra) {- both names -}
module FlatClose exposing (apple, zebra) {- both names -}
module FlatClose exposing (apple, zebra {- both names -})
module FlatClose exposing (zebra, apple) {- both names -}
— all four become:
module FlatClose exposing
( apple
, zebra
{- both names -}
)
That is the point. Neither the spacing you left before the comment nor the order you typed the names in changes the output, and the same module written any of those four ways formats to the same bytes.
What you give up is hanging a comment off the last name of a flat list: it is read as the list’s, not that name’s. A comment on any earlier name is unaffected, because a name follows it and there is nothing to confuse it with:
module Mid exposing (apple {- just apple -}, zebra)
stays exactly as written. And if you do want a comment tied to the last name,
write the list vertically — there the ) has a row of its own, which is enough
to tell the two apart:
module Vert exposing
( apple
, zebra -- just zebra
)
keeps the comment on zebra, through the sort and across reformats.
A chain of comments is treated as one unit and pinned together, in order, each on its own line — including a link that spans rows:
module Chain exposing (zebra, apple) {- first link, and it
spans rows -} {- second link -}
becomes:
module Chain exposing
( apple
, zebra
{- first link, and it
spans rows -}
{- second link -}
)
This also settles what used to be an ambiguity about a vertical list: these two files are handed to the formatter as byte-identical ASTs and byte-identical comment positions, so nothing could ever distinguish them —
module Amb exposing module Amb exposing
( apple ( apple
, zebra , zebra
{- first -} ) {- first -}
{- second -} {- second -}
)
— and both now format to the same thing, with both comments inside the list
above the ). Previously the second comment was pushed out of the brackets and
became a free-floating comment above the declarations.
letThe in keyword of a let ... in is another token with no recorded position:
the parsed let remembers only its bindings and its result expression, never
where in sat. So a comment written in the gap between the last binding and the
result can’t be pinned to one side of in — it might be trailing the binding
above, or introducing the result below, and there’s no fact to tell those apart.
gren-format always treats it as introducing the result, placing it on its own
line just below in:
-- you wrote:
x =
let
y =
1 -- a note
in
y
-- formats to (the note moves below in):
x =
let
y =
1
in
-- a note
y
This is the one placement that stays put every time you reformat and never
misplaces a comment you really did write below in. elm-format, whose parser
records the in position, keeps a trailing-binding comment up with the
bindings instead — a divergence covered in
Comparison with elm-format (point 20), with the
full reasoning for why gren-format can’t follow suit.
A multi-line block comment’s body is re-indented from its own structure: the
least-indented content line is placed a few columns in from the {-, and every
other line keeps its position relative to that. So hand-aligned content — an
ASCII diagram, an aligned table — keeps its shape; the block as a whole is
anchored under the comment’s opener rather than pinned to the exact columns you
typed. Because the indentation is derived only from the body (never from the
whitespace around the comment), two inputs that differ only in that surrounding
whitespace format to the same output — it is whitespace-canonical.
This applies to every multi-line block comment, including the form where {-
sits alone on its first line. (An earlier version of gren-format treated that
opener-alone form as a “keep my exact columns” signal and left the body
verbatim; that was a divergence from elm-format, which re-indents the body the
same way described here, so it was removed.)
Since the 24w release, a custom
type’s variant is limited to 0 or 1 argument — type Person = Person String
Int is no longer valid Gren; a multi-field variant must carry a record
instead (Person { name : String, age : Int }). The parser this project is
built on does not enforce that restriction for a chain of bare
constructor-name arguments, so type Person = Person String Int still parses
without error, and gren-format duly formats it. Tracked at
compiler-common#32.
Two of this package’s own fixtures depend on the gap and are reduced to 0/1
argument variants when it closes; gen-random.py already avoids emitting them.
Rule C7 says a comment run keeps the rows you wrote it on, and divergence #30 says that holds in every context. It does not hold for a run written just inside an opening bracket, or between a pipeline step’s operator and its operand. There, the whole run is laid out all-or-nothing: if every member can share a line they are all joined onto one, and if any member cannot, every member gets a row of its own.
-- you write: -- gren-format writes:
a = a =
[ {- p -} [ {- p -} {- q -} 1 <- joined a run you split
{- q -} 1 ]
]
b = b =
[ {- p -} -- q [ {- p -} <- split a run you joined
1 -- q
] 1
]
Both happen to be what elm-format does, so this costs nothing in parity — but it is the rule stated elsewhere, applied where the information to apply it is missing.
It is not fixable in the renderer. Which rows the author used inside a
container’s comment run is not recorded anywhere the renderer can read it: rule
C1 makes one gap one attachment, so Comments.gren gives the whole run one role,
derived from each member’s shape (bracketKindRole). [ {- p -} -- q and
[ {- p -} ⏎ -- q therefore arrive with identical roles (RidesInline,
TrailsPrevious), and so do the two spellings of a two-block run. Any rule
written in glueLeadingCommentRun lays both authorings out the same way and so
breaks C7 on one of them; grouping by commentGluesToPrevious the way a binop
chain’s leadingRunRowBoxes does was measured to move the violation from one
spelling to the other rather than remove it.
Honouring C7 here means recording the authored row upstream, in the role — a
change to the comment model rather than to a layout function, and one that
contradicts the “one gap, one role” reading Comments.gren currently documents
at that slot.
This one isn’t a comment-placement or compiler quirk — it’s an implementation
limit in the formatter itself, found by tests/pathological-nesting.py
(geometric-growth-and-bisect stress testing of nesting depth).
A chain of nested lambdas,
x =
\a -> \a -> \a -> \a -> \a -> {- … repeated hundreds of times … -} a
or a chain of nested unary minus,
x =
-(-(-(-(-(-( {- … repeated hundreds of times … -} 1))))))
crashes with RangeError: Maximum call stack size exceeded once nesting
passes roughly 400 levels for lambdas or 300 levels for unary minus.
Rendering a nested expression recurses once per level through a fairly long
call chain (dispatch → flow assembly → per-item classification → the next
level’s dispatch, and so on) — around 10-15 JS stack frames per level of
Gren source nesting — and Node’s default stack budget runs out before the
parser’s own does (the parser tolerates roughly 500-700+ levels of the same
shapes, since its recursive-descent call chain is shorter per level). The
crash is immediate and clean — no hang, no corrupted output — but a file
past the threshold cannot be formatted at all.
This is not expected to matter in practice: no real Gren source this project has ever swept (published packages, its own sources, hundreds of thousands of generated random modules) has come anywhere near this depth, and code with hundreds of directly nested lambdas or unary minuses is not something anyone writes by hand. Fixing it properly means rewriting the renderer’s recursive core to use an explicit stack instead of the JS call stack (a trampoline) — a large, invasive change judged disproportionate to a depth nothing has ever hit. Nor would we consider changing Node’s own stack size; that is out of scope as a solution.
Deep nesting used to be worse than a depth ceiling: several constructs
rendered the same subtree twice per level, which is exponential rather than
linear, and made record literals unusable well before 25 levels. Those were
formatter bugs and are fixed — every construct now renders each subtree once,
and what is left for all of them is the ordinary stack-depth ceiling described
above. The pattern is worth knowing if you touch the renderer: a suspected
blowup here has always turned out to be a second render, not a slow function.
docs/testing.md
has the measurements and the shapes that were timed.