Skip to content

[Repo Assist] Parenthesise lambda LHS of infix operator in single-line form to preserve semantics (#3274)#3276

Closed
github-actions[bot] wants to merge 2 commits intomainfrom
repo-assist/fix-issue-3274-lambda-infix-parens-c790770fc534a889
Closed

[Repo Assist] Parenthesise lambda LHS of infix operator in single-line form to preserve semantics (#3274)#3276
github-actions[bot] wants to merge 2 commits intomainfrom
repo-assist/fix-issue-3274-lambda-infix-parens-c790770fc534a889

Conversation

@github-actions
Copy link
Contributor

Closes #3274

🤖 This PR was created by Repo Assist, an automated AI assistant. Please review carefully.

Problem

When a lambda appears as the left-hand side of a custom infix operator, and the entire expression fits on one line, Fantomas was emitting it without parentheses — changing the semantics of the code:

Input (4 variations, all with (fun x -> ...) <*| op semantics):

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

Before (wrong — changes meaning):

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

This parses as fun x -> ({| X = x |} <*| op) — the operator is now inside the lambda body.

After (correct):

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

Root Cause

In Expr.InfixApp handling, genShortInfixExpr (the single-line layout) calls genExpr node.LeftHandSide without adding parentheses. When the LHS is a lambda, fun x -> body op rhs is re-parsed as fun x -> (body op rhs), which is semantically different.

For newLineInfixOps (|>, >>, etc.), Fantomas already forces multiline layout when the LHS is a lambda. But for other operators (e.g. <*|, <$>, >>=), the short single-line form was used without guarding the semantics.

Fix

Add parentheses around the LHS expression in genShortInfixExpr when it is a lambda or if/then/else:

// When the LHS is a lambda or if/then/else, it must be parenthesised in the
// single-line form. Without parens, `fun x -> body (op) rhs` would be parsed
// as `fun x -> (body (op) rhs)` rather than `(fun x -> body) (op) rhs`, which
// changes semantics. See #3274.
let genLhsExpr =
    if isLambdaOrIfThenElse node.LeftHandSide then
        sepOpenT +> genExpr node.LeftHandSide +> sepCloseT
    else
        genExpr node.LeftHandSide

If the lambda was already explicitly parenthesised in the source, its Oak representation is Expr.Paren(Expr.Lambda(...)), which does not match isLambdaOrIfThenElse, so no double parens are added.

Test Status

  • ✅ Build: dotnet build fantomas.sln — succeeded
  • ✅ Tests: dotnet test src/Fantomas.Core.Tests/2737 passed, 0 failed, 7 skipped
  • ✅ New regression test added: lambda on LHS of custom infix operator is parenthesised to preserve semantics, 3274

Generated by Repo Assist ·

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/repo-assist.md@346204513ecfa08b81566450d7d599556807389f

When a lambda (or if/then/else) appears as the left-hand side of an
infix operator and the whole expression fits on one line, Fantomas was
emitting it without parentheses:

  Before:  fun x -> {| X = x |} <*| op
  After:   (fun x -> {| X = x |}) <*| op

Without parens, the parser sees the lambda as consuming the entire
right-hand side (including the operator and its argument), which is
semantically different from the original multiline code where the
operator applied to the whole lambda.

This is analogous to the existing special-casing for pipe operators
(isNewLineInfixOp), but applies to all infix operators in the
single-line (short) layout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@nojaf
Copy link
Contributor

nojaf commented Mar 12, 2026

Gonna solve this in a larger refactor to have a more overall consistent story.

@nojaf nojaf closed this Mar 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom operator is applied to anonymous record after formatting

1 participant