Skip to content

Fix type, trivia and Stroustrup type argument layouts for 8.1.0 - #3561

Merged
nojaf merged 16 commits into
fsprojects:mainfrom
nojaf:fix-3554
Oct 21, 2026
Merged

nojaf merged 16 commits into
fsprojects:mainfrom
nojaf:fix-3554

Conversation

@nojaf

@nojaf nojaf commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Fantomas 8.1.0 fixes six layouts where the formatted code did not compile, or changed again on the next format (#3554, #3556 to #3560), and brings back the Stroustrup layout of a single anonymous record type argument (#3069, #2860). Fixing them at the cause, rather than with a special case each, also moves a few layouts of code that was already valid. If your code changed after updating to 8.1.0, this is why.

Why your code changed

Two ideas are behind every change below:

  • Indent only as far as the parser needs, and on a multiple of indent_size. 8.0 added extra indentation in places to keep the compiler happy, and some of it landed on columns like 10 or 14 with an indent size of 4. 8.1.0 measures where the parser needs the next line instead, and stays on the indentation grid.
  • Keep a comment with the code it lines up with. A comment written under the elements of a list or tuple stays with those elements.

The upgrade guide (docs/docs/end-users/UpgradeGuide.md, v8.1) has every change below, and docs/docs/end-users/RuleOfMultitudes.md the reason behind the first idea.

Every example below is real output for the same input, from 8.0.10 and from 8.1.0, with the default settings.

A function type in parentheses

The -> lines go one level in, not two. Since 8.0 (#3225, for #3043), every multiline function type in parentheses got an extra level. It is only needed after an outer ->, where it stays. Elsewhere this is the 7.0.6 layout again. A function type that starts with a tuple keeps the two levels of 8.0: the lines of the tuple take the first level, so the -> lines go one past them. (#3554)

// 8.0.10
type T =
    (LongTypeNameNumberOne
            -> LongTypeNameNumberTwo
            -> LongTypeNameNumberThree
            -> LongTypeNameNumberFour
            -> LongTypeNameNumberFive) list

// 8.1.0
type T =
    (LongTypeNameNumberOne
        -> LongTypeNameNumberTwo
        -> LongTypeNameNumberThree
        -> LongTypeNameNumberFour
        -> LongTypeNameNumberFive) list

A return type annotation

The lines of a return type no longer hang off the column after : . They go one level past the binding, so they land on a multiple of the indent size. With indent_size = 2, : is itself one level wide, so there the lines are measured from the type, as in 8.0.

// 8.0.10
let f
    x
    : (LongTypeNameNumberOne
              -> LongTypeNameNumberTwo
              -> LongTypeNameNumberThree
              -> LongTypeNameNumberFour
              -> LongTypeNameNumberFive)
    =
    failwith ""

// 8.1.0
let f
    x
    : (LongTypeNameNumberOne
        -> LongTypeNameNumberTwo
        -> LongTypeNameNumberThree
        -> LongTypeNameNumberFour
        -> LongTypeNameNumberFive)
    =
    failwith ""

A long struct tuple type

The elements go on lines of their own, one level in, and the ) back on the indent of the line it started on, the way a long list is written. Before, the elements after the first started left of struct (, at a column the parser only accepts in some places. In the middle of a function type, the same shape did not compile (#3556).

// 8.0.10
let f
    x
    : struct (LongTypeNameNumberOne *
      LongTypeNameNumberTwo *
      LongTypeNameNumberThree *
      LongTypeNameNumberFour *
      LongTypeNameNumberFive)
    =
    failwith ""

// 8.1.0
let f
    x
    : struct (
        LongTypeNameNumberOne *
        LongTypeNameNumberTwo *
        LongTypeNameNumberThree *
        LongTypeNameNumberFour *
        LongTypeNameNumberFive
    )
    =
    failwith ""

A tuple type after :>, :?>, : or :?

The lines after the first element go one level in. Before, they went back to the column of the operator line and read as if the type had ended. This is what a tuple after the -> of a function type does as well.

// 8.0.10
let x =
    someValue
    :> (LongTypeNameNumberOne *
    LongTypeNameNumberTwo *
    LongTypeNameNumberThree *
    LongTypeNameNumberFour *
    LongTypeNameNumberFive)

// 8.1.0
let x =
    someValue
    :> (LongTypeNameNumberOne *
        LongTypeNameNumberTwo *
        LongTypeNameNumberThree *
        LongTypeNameNumberFour *
        LongTypeNameNumberFive)

A line comment before a closing bracket

A comment written on the column where the elements of a list start now stays with the elements, also when the closing ] is on that column too. Before, it moved before the ]. A comment on the column of the opening bracket still stays with the brackets.

// Input
let a = [
    (1,2,3); (4,5,6); (7,8,9)
    // some line comment
    ]

// 8.0.10
let a =
    [
        (1, 2, 3)
        (4, 5, 6)
        (7, 8, 9)
    // some line comment
    ]

// 8.1.0
let a =
    [
        (1, 2, 3)
        (4, 5, 6)
        (7, 8, 9)
        // some line comment
    ]

A single anonymous record type argument with Stroustrup

With fsharp_multiline_bracket_style = stroustrup, a type application whose only type argument is an anonymous record keeps <{| and |}> together, the way Stroustrup keeps the bracket of a record at the end of the line before it. Since 6.3.0 the record went on lines of its own, with the closing > on a line by itself (#3069). This is also the layout #2860 asked for, which came back with a space in |} > at the time. With more than one type argument, the arguments still go on lines of their own. aligned and cramped are untouched.

This follows the design @mmabdpr worked out in #3525, ported here in a smaller form and credited as co-author.

// 8.0.10
let asJson (arm: IArmResource) =
    arm.JsonModel
    |> convertTo<
        {|
            kind: string
            properties: {| statisticsEnabled: bool |}
            location: string
            sku: string
        |}
        >

// 8.1.0
let asJson (arm: IArmResource) =
    arm.JsonModel
    |> convertTo<{|
        kind: string
        properties: {| statisticsEnabled: bool |}
        location: string
        sku: string
    |}>

Fixed

Each of these gave code that did not compile, or that changed again on the next format:

These two compiled, but lost the Stroustrup layout:

Closes #3554
Closes #3556
Closes #3557
Closes #3558
Closes #3559
Closes #3560
Closes #3069
Closes #2860

nojaf and others added 15 commits October 21, 2026 09:01
Since fsprojects#3225, every multiline function type in parentheses indented its
`->` lines one extra level, to fix fsprojects#3043. The extra level is only
needed in some places. A parenthesis opens an offside context at the
first token inside it, and a line starting on that column ends it.
After `-> (` or `:> (` at the start of a line, with an indent size of
4, that column is exactly where the `->` lines go. Where the `(` starts
its line, as in `type T = (a -> b) list`, one level is enough, which is
the 7.0.6 layout.

Type.Paren now measures instead. After writing `(` it asks indentPast,
the measure-and-step helper from fsprojects#3446, to start the `->` lines past
the column right after it. That gives the 7.0.6 layout where it parses,
the fsprojects#3043 layout after an outer `->`, and the same layout after `:>`.
It also clears that column with an indent size of 2, where the blanket
extra level put the `->` lines on it.

The column is taken before the trivia after the `(`. A block comment
there moves the first token right, but a line that starts left of the
first token parses, so the `->` lines stay one level in rather than
lining up past the comment.

Closes fsprojects#3554
A return type annotation set the indent to the column after `: `. One
level from there lands between multiples of the indent size: with an
indent size of 4, the `->` lines of `: (a -> b -> ...)`, the `->` of
`: unit -> {| ... |}` and the arguments of `: cmap<...>` went to 10.
The indent now stays the binding's, so a type that indents its own
lines lands on a multiple of it, 8 in all three.

A line starting on the column of the `:` ends the return type. A
multiline tuple does not indent its own lines: the lines after its
first element start at the indent, which is now that column. Where a
tuple starts the lines of the type, alone or inside parentheses, a
postfix application, an array, a struct tuple, global constraints or
the first parameter of a function type, AtColumn keeps them at the
column after `: `, where they were before.
When a function type does not fit on its line, its `->` lines go one
level in. A tuple after one of them that does not fit either breaks
after `*` and starts its next element at the indent, the column of the
`->` lines. The element after a line-ending `*` starts a block at its
own column, so for a tuple in the middle of the function type, the
`->` that follows is read as a new statement and the result does not
parse. Inside parentheses, a postfix application or an array it
parses, but the tuple's lines still line up with the arrows.

A type that a tuple starts after an arrow now goes one level further
in, as the last operand too. The check whether a tuple starts the
lines of a type is the one a return type already made, now shared by
both.

A struct tuple type that did not fit on its line started its next
element at the indent, left of `struct (` and of the element it
continues. It now breaks after `struct (`, puts its elements one level
in and its `)` back on the indent of the line it started on. As it
places its own lines, the check above no longer counts it as a tuple.

A line comment after an arrow was held until the next line break,
which came after the type that follows, so the comment moved onto that
type and left a blank line behind; before a tuple, it moved after the
first element and was not idempotent. It now stays after the arrow,
and the type goes on the next line, one level in.

Closes fsprojects#3556
Discussions about formatting stalled on two things. "Comment" meant
either trivia in the formatted F# or a comment in Fantomas' own source,
and questions came as fragments of code that could not be judged
without the code base open, wrapped beyond reading in the chat.

AGENTS.md now asks agents to say trivia for comments in the F# that
Fantomas reads or writes, and explanatory or doc comment for those in
our source, and to keep the latter accurate as part of every change. A
finding or question about formatting carries a full example: the
input, the result before and after the change, what is wrong, and for a
decision every option, checked with the SDK compilers. A question that
needs code goes into question.fs in the repo root, which the user reads
in the editor; .fantomasignore lists it, so the formatter hook and the
editor leave its examples as written.
When a trivia line comment follows the arrow of a function type, the
type after it already starts on a new line one level past the arrows.
A multiline tuple there took a second level for its later elements,
which only the tuple on the arrow's own line needs: there, the element
after a line-ending `*` would otherwise sit on the column of the next
`->`, which does not parse. After the comment, the later elements now
stay on the indent, under the first one.

The explanatory comments for each branch now show the layout they
produce, inside the branch they explain.

AGENTS.md: a review's findings that come with F# code, nits included,
go into an untracked review.fs, written the same way as question.fs.
.fantomasignore lists it, so the formatter leaves its examples alone.
With indent_size = 2, the blanket extra level from fsprojects#3225 put the `->`
lines of a parenthesized function type after an outer `->` on the
column right after the `(`. A line starting there ends the offside
context the parenthesis opens, so the result did not parse.

Type.Paren now starts those lines past that column whatever the indent
size, since the fix for fsprojects#3554. This adds a case for indent_size = 2,
which no case covered, and the changelog entry.

Closes fsprojects#3557
A type test that did not fit on one line was broken before its
operator, like the other typed expressions, which put `:?` on the
column of the expression. `:>`, `:?>` and `:` may start a line there,
but for the offside rule `:?` is not an infix token, so the parser read
it as the start of a new expression and the result did not compile.
The `:?` line now goes one level in, so it breaks the same way as the
other operators.

A multiline tuple type after any of the four operators put the lines
after its first element at the indent, the column of the operator line.
That compiled, but read as if the type had ended. Where the type starts
with a tuple, it now goes one level in, as it does after the `->` of a
function type, which keeps its lines on a multiple of the indent size
and, in parentheses after `:>` and `:?`, under the first element.

No case covered a type test in an expression, or a tuple after any of
these operators; this adds one of each.

Closes fsprojects#3558
FANTOMAS-SNOBMATCH-001 only spoke for a two armed match on a boolean.
A constant against a wildcard asks the same kind of question, whether
the scrutinee equals that constant, and rewrites as mechanically:
`if scrutinee = constant then ... else ...`, with the scrutinee still
written once. Such a match in the type annotation printer went through
review unreported.

The rule now reports that shape too, with a message of its own. Unit,
two constants without a wildcard, and a constant against a binder stay
unreported. Nothing in the analyzed code had the shape, so no existing
finding comes with it.
A line comment after the `(` of a parenthesized type waits for the next
newline, and the type inside started on the same line, so the comment
landed after the first token of the type and what followed went on the
next line at the indent. For `Map<string, int>` that split the type
name from its arguments and did not compile, a tuple was not
idempotent, and a function type got a blank line under the comment.

When a line comment follows the `(`, the type now starts on the next
line, at the first indent level past the `(`, where the context the
parenthesis opens allows it. The comment stays where it was written.

Closes fsprojects#3559
A long struct tuple type puts its elements on lines of their own and
its `)` on a line by itself. A comment on its own line after the last
element attaches after that element and ends with a newline of its
own; the printer added another before the `)`, so every format added a
blank line. It now unindents the way the list printer does and adds
that newline only when nothing else has.

The comment attached to the `)` instead when it lined up with the first
element of a struct tuple whose elements share a line. The column
search for a line comment follows only the node it directly follows at
each depth, so earlier content on the comment's column was never
reached. When nothing on that path matches but a later child of the
container starts on the column, the comment now follows the
container's last node before it. What opens the container does not
count: its first child, whatever it is (`[`, `<@`, `begin`, `class`,
`task` of `task {`, `foo` of `foo<...>`), and an opening bracket after
it (`{|` of `struct {|`). A comment lined up with those stays before
the closing token, as on main.

The search now reads as two steps: the candidates are the nodes the
comment directly follows, found from the end of the children, and one
match over those on the comment's column picks the outermost that
starts its line, else the innermost, else the first candidate when
later content of the container lines up. Whether a node starts its
line is read from the source text instead of carrying the end line of
the code before it down every depth. The cursor, which never takes
this path, is attached without going through it.

One existing gold changes: a comment on the column of both the first
element of a list and its `]` now goes with the elements, as it
already did when the matching element was the last one.
Since 8.0.8 the `when` of a match clause is a node of its own, so a
comment on its own line between the pattern and `when` attaches before
that keyword. Writing that comment ends the line, and `when` then
started the next one on the column of the `|`, where it ends the clause:
the result did not parse.

With a comment before `when`, the comment, the guard and the rest of
the clause now go one level in. The comment is written before the guard
is measured, so its lines do not make the guard count as multiline and
the arrow stays on the guard's line when the guard fits.

Closes fsprojects#3560
Release the fixes of this branch as 8.1.0, a minor release because two
of its changes alter how valid code is formatted. Neither has an issue
behind it, so their entries link the pull request: a multiline tuple
type after `:?`, `:>`, `:?>` or `:` goes one level in, and a line
comment lined up with the first element of its container stays with
the elements instead of moving before the closing bracket.
8.1 fixes layouts that did not compile or were not idempotent, and
fixing them at the cause also moves a few layouts of code that was
already valid. The upgrade guide gets a v8.1 section, so someone who
sees their code change after upgrading finds out why without reading
the pull request: a function type in parentheses goes one level in
rather than two, a return type lands on a multiple of the indent size,
a long struct tuple type is written like a long list, a tuple type
after a cast or annotation operator goes one level in, and a comment
lined up with the elements of a list stays with them. Each comes with
the output of 8.0 and of 8.1 for the same input.

Several of those choices follow one preference that was written down
nowhere: when more than one layout parses, every line starts on a
multiple of indent_size. The new end-user page, The rule of multitudes,
gives the reasons (both style guides, and alignment that does not
depend on name length), how it works where the parser needs a line
past a column, and the layouts where it does not hold yet. Design
decisions lists it, the upgrade guide links to it, and AGENTS.md
points agents to Design decisions when they choose or recommend a
layout.
ReportingBugs moves one place down to make room.
With Stroustrup, a type application whose only type argument is an
anonymous record printed it as an indented list item, with the closing
`>` on a line of its own and a space before it. It now stays attached,
`convertTo<{|`, fields, `|}>`, for expressions and types alike, as it
was before 6.3.0.

genPrefixApp makes the choice, since it already holds the padding that
keeps a `>` starting a line right of the identifier, where it cannot be
read as the comparison operator. It takes the type arguments as a list
and uses the same rule as every other place that attaches an anonymous
record type: isStroustrupStyleType and canSafelyUseStroustrup, now in
Context.fsi, decided once `<` is written so a comment after it counts.
With trivia before `>`, the list layout stays, so the `>` always
follows `|}` and needs no padding.

This is the design @mmabdpr proposed in fsprojects#3525, after the first version
there was reviewed.

Closes fsprojects#3069
Closes fsprojects#2860

Co-authored-by: Mohammad Mahdi Abdollahpour <git@mohammadmahdi.com>
With fsharp_multiline_bracket_style = stroustrup, a type application
whose only type argument is an anonymous record now keeps `<{|` and
`|}>` together, where 8.0 put the record on lines of its own and the
closing `>` on a line by itself. Users of that setting will see their
code change, so the v8.1 section shows the layout of 8.0 and of 8.1 for
the same input. The section no longer says both ideas in its
introduction are behind every change, as this one follows neither.
@nojaf

nojaf commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Hey @mmabdpr, I'm going to include a minimal fix for #3525 in this PR.
It can go in 8.1.

A function type in parentheses steps its `->` lines past the `(`, but
when it started with a tuple, the tuple's lines fell back to the column
of the `(`. Main put them one level in through the extra level every
parenthesized function type used to get. The parenthesized type now
applies one rule to a function type and to a type after a trivia line
comment: the lines the type indents clear the first token after the
`(`, and the lines at the type's own indent, a leading tuple's or the
first after the comment, clear the `(` itself. indentPast measures from
the current indent, so a tuple an `->` already put a level in gets
nothing more. Other parenthesized types keep their layout.

A return type on its own line keeps the binding's indent and goes one
level in. With an indent size of 2, `: ` is exactly one level wide, so
that level is the column the type starts on: the type arguments of
`: Dictionary<` and the `->` lines of a function type started under the
type name, with the closing `>` right of them. With an indent size of
2, the type now indents from where it starts, as in 8.0; the upgrade
guide says so.

Layouts checked by hand at an indent size of 2 and in nested
parentheses are now cases, and AGENTS.md asks for that from now on.
This was referenced Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment