Skip to content

RFC: How should Fantomas handle lambda expressions that would change semantics on a single line? #3279

@nojaf

Description

@nojaf

RFC: How should Fantomas handle lambda expressions that would change semantics on a single line?

Background: how Fantomas works

Fantomas is not a "find-and-replace" tool that tweaks whitespace in your source file. It works more like a Word document: Fantomas re-types your entire source code from scratch according to its rules. It parses your code into an abstract syntax tree (AST), and then prints it back out following the configured style guide. The original text is gone — only the structure and semantics are preserved.

This is a powerful model because it guarantees complete consistency. But it also means Fantomas carries a heavy responsibility: the re-printed code must have the same meaning as the original. If Fantomas changes the structure while collapsing code to a single line, it's a bug.

The problem: fun extends to the end

In F#, a fun lambda captures everything to the right as its body. This is a language-level rule, not a formatting preference:

// These two are NOT the same:
fun x -> x + 1 |> g       // means: fun x -> (x + 1 |> g)
(fun x -> x + 1) |> g     // means: apply g to the lambda

This creates a specific class of bug in Fantomas. When multiline code gets collapsed onto a single line, the lambda body can swallow tokens that were originally separate expressions. The same applies to if/then/else expressions.

Here are the known instances of this problem:

1. Infix operator with lambda on the left-hand side (#3274)

// Input (multiline, unambiguous):
let a =
    fun x -> {| X = x |}
    <*| op

// Collapsed to single line (WRONG — changes semantics):
let a = fun x -> {| X = x |} <*| op
// Parser sees: fun x -> ({| X = x |} <*| op)

2. Record with lambda in a non-last field (#3246)

// Input (multiline, unambiguous):
let test () : Rec =
    {
        A = 1
        B = fun x -> x + 1
        C = 3
    }

// Collapsed to single line (WRONG — changes semantics):
let test () : Rec = { A = 1; B = fun x -> x + 1; C = 3 }
// Parser sees: { A = 1; B = fun x -> (x + 1; C = 3) }

3. Tuple with lambda in a non-last position (#3278)

// Input (multiline, unambiguous):
let x =
    [
        1, fun () -> 1
        1, fun () -> 1
    ]

// Collapsed to single line (WRONG — changes semantics):
let x = [ 1, fun () -> 1; 1, fun () -> 1 ]
// Parser sees the first lambda swallowing everything after it

The core tension

Fantomas needs to handle the case where collapsing to a single line would make a lambda (or if/then/else) swallow subsequent tokens. There are exactly two valid approaches, each with a clear trade-off.

Option A: Add parentheses

Collapse to a single line, but wrap the lambda in parentheses to delimit its body:

// Record:
let test () : Rec = { A = 1; B = (fun x -> x + 1); C = 3 }

// Infix:
let a = (fun x -> {| X = x |}) <*| op

// Tuple:
let x = [ (1, fun () -> 1); (1, fun () -> 1) ]

Pro: More compact output. Expressions that fit on one line stay on one line.
Con: Introduces parentheses that were not in the original source. Fantomas is changing your code style to make the single-line form safe.

Option B: Stay multiline

If collapsing would create an ambiguous lambda, don't collapse — keep the multiline layout:

// Record:
let test () : Rec =
    {
        A = 1
        B = fun x -> x + 1
        C = 3
    }

// Infix:
let a =
    fun x -> {| X = x |}
    <*| op

// Tuple:
let x =
    [
        1, fun () -> 1
        1, fun () -> 1
    ]

Pro: Never introduces syntax that wasn't in the original source. The multiline form is already unambiguous.
Con: Some short expressions won't collapse to a single line even when they technically could (with parens).

Current state

Fantomas currently uses a mix of both approaches, which is inconsistent:

Context Current approach
Pipe operators (|>, >>) with lambda LHS Stay multiline
Chained infix apps (Expr.SameInfixApps) Add parens
Record non-last fields Add parens
Tuple non-last elements Add parens
Other infix operators with lambda LHS Not yet handled (bug)

Important: this is not about preserving the original source

To be clear: neither option means "keep what the user wrote." That is not how Fantomas works. Fantomas re-types your entire source from scratch — it does not selectively preserve parts of the original formatting. When working with multiple people on the same code base, you should not be able to distinguish who wrote the code. Formatting is an acceptance criterion, not a personal preference.

Both options produce deterministic, consistent output. The question is strictly about which deterministic output Fantomas should produce when a lambda would be ambiguous on a single line.

What we're asking

This is not a general formatting style discussion — we're not debating how code should look. This is a correctness constraint imposed by the F# language: fun extends to the end of the enclosing expression, and Fantomas must account for that.

The question is purely: when Fantomas must choose between adding parentheses or staying multiline to preserve semantics, which should it do?

We want to pick one consistent approach and apply it everywhere. Since we're currently in an alpha release cycle, we have the opportunity to align all code paths.

Timeline: This decision is blocking the v8 stable release. We need to settle this promptly — if there is no clear community consensus within two weeks, the maintainers will make the call.

Please vote by reacting on the corresponding comment below: 🚀 for add parentheses, 🎉 for stay multiline.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions