Skip to content

Conversation

edgarfgp
Copy link
Contributor

@edgarfgp edgarfgp commented Aug 6, 2025

Description

This PR unifies the AST representation for binding expressions by consolidating SynExpr.LetOrUse and SynExpr.LetOrUseBang into a single SynExpr.LetOrUse node. This change simplifies the AST structure and makes it more consistent for tools working with syntax trees.

Key Changes

Removed:

  • SynExpr.LetOrUseBang case has been completely removed

Extended:

  • SynExpr.LetOrUse now includes two additional boolean flags:
    • isFromSource: Indicates whether the binding originates from user-written code (true) vs compiler-generated (false)
    • isComputed: Distinguishes computation expression bindings (let!/use! = true) from regular bindings (let/use = false)

Before and After AST Structure

Before (Two Separate Cases)

// Regular let/use bindings
type SynExpr =
    | LetOrUse of
        isRecursive: bool *
        isUse: bool *
        bindings: SynBinding list *
        body: SynExpr *
        range: range *
        trivia: SynExprLetOrUseTrivia

    // Computation expression bindings (let!/use!)
    | LetOrUseBang of
        bindDebugPoint: DebugPointAtBinding *
        isUse: bool *
        isFromSource: bool *
        pat: SynPat *            // Pattern as direct field
        rhs: SynExpr *           // RHS expression as direct field
        andBangs: SynBinding list *  // Additional and! bindings
        body: SynExpr *
        range: range *
        trivia: SynExprLetOrUseTrivia

After (Unified Single Case)

type SynExpr =
    | LetOrUse of
        isRecursive: bool *
        isUse: bool *
        isFromSource: bool *    // NEW: From LetOrUseBang
        isComputed: bool *      // NEW: Indicates let!/use! (true) vs let/use (false)
        bindings: SynBinding list *  // Now contains ALL bindings (including first let!/use!)
        body: SynExpr *
        range: range *
        trivia: SynExprLetOrUseTrivia

Example 1: Computation Expression with let! and and!

async {
    let! bar = getBar()
    and! baz = getBaz()
    return bar + baz
}

Before AST:

LetOrUseBang
  (Yes (3,4--3,24), false, true,
   Named (SynIdent (bar, None), false, None, (3,9--3,12)),  // pat: "bar" as direct field
   App                                                        // rhs: "getBar()" as direct field
     (NonAtomic, false, Ident getBar,
      Const (Unit, (3,22--3,24)), (3,15--3,24)),
   [SynBinding                                               // andBangs: separate list for "and!"
      (None, Normal, false, false, [], PreXmlDocEmpty,
       SynValData(...),
       Named (SynIdent (baz, None), false, None, (4,9--4,12)),
       None,
       App (NonAtomic, false, Ident getBaz,
            Const (Unit, (4,22--4,24)), (4,15--4,24)),
       ...)],
   body,  // "return bar + baz"
   ...)

After AST:

LetOrUse
  (false, false, true, true,     // isRec=false, isUse=false, isFromSource=true, isComputed=true
   [SynBinding                    // ALL bindings in single list
      (None, Normal, false, false, [], PreXmlDocEmpty,
       SynValData(...),
       Named                      // pat: "bar" now inside SynBinding
         (SynIdent (bar, None), false, None, (3,9--3,12)),
       None,
       App                        // rhs: "getBar()" now inside SynBinding
         (NonAtomic, false, Ident getBar,
          Const (Unit, (3,22--3,24)), (3,15--3,24)),
       (3,4--6,14), Yes (3,4--3,24),
       { LeadingKeyword = Let (3,4--3,8)
         InlineKeyword = None
         EqualsRange = Some (3,13--3,14) });
    SynBinding                    // "and! baz = getBaz()" in same list
      (None, Normal, false, false, [], PreXmlDocEmpty,
       SynValData(...),
       Named (SynIdent (baz, None), false, None, (4,9--4,12)),
       None,
       App (NonAtomic, false, Ident getBaz,
            Const (Unit, (4,22--4,24)), (4,15--4,24)),
       ...)],
   body,  // "return bar + baz"
   ...)

Example 2: Regular let Binding

do
    let x = 1
    printfn "%d" x

Before AST:

LetOrUse
  (false, false,              // isRecursive=false, isUse=false
   [SynBinding
      (None, Normal, false, false, [],
       PreXmlDoc ((3,4), FSharp.Compiler.Xml.XmlDocCollector),
       SynValData(...),
       Named (SynIdent (x, None), false, None, (3,8--3,9)),  // pat: "x"
       None,
       Const (Int32 1, (3,12--3,13)),                         // expr: "1"
       (3,8--3,13), Yes (3,4--3,13),
       { LeadingKeyword = Let (3,4--3,7)
         InlineKeyword = None
         EqualsRange = Some (3,10--3,11) })],
   body,  // "printfn "%d" x"
   ...)

After AST:

LetOrUse
  (false, false, true, false,  // isRec=false, isUse=false, isFromSource=true, isComputed=false
   [SynBinding                 // Same binding structure, just with new flags
      (None, Normal, false, false, [],
       PreXmlDoc ((3,4), FSharp.Compiler.Xml.XmlDocCollector),
       SynValData(...),
       Named (SynIdent (x, None), false, None, (3,8--3,9)),  // pat: "x"
       None,
       Const (Int32 1, (3,12--3,13)),                         // expr: "1"
       (3,8--3,13), Yes (3,4--3,13),
       { LeadingKeyword = Let (3,4--3,7)
         InlineKeyword = None
         EqualsRange = Some (3,10--3,11) })],
   body,  // "printfn "%d" x"
   ...)

Example 3: use! in Computation Expression

async {
    use! resource = getResource()
    return resource.Value
}

Before AST:

LetOrUseBang
  (Yes (2,4--2,30), true, true,   // isUse=true for use!
   Named (SynIdent (resource, None), false, None, (2,9--2,17)),  // Direct pat field
   App (NonAtomic, false, Ident getResource,                      // Direct rhs field
        Const (Unit, (2,28--2,30)), (2,17--2,30)),
   [],  // No and! bindings
   body,  // "return resource.Value"
   ...)

After AST:

LetOrUse
  (false, true, true, true,      // isRec=false, isUse=true, isFromSource=true, isComputed=true
   [SynBinding                    // Pattern and RHS now wrapped in SynBinding
      (None, Normal, false, false, [], PreXmlDocEmpty,
       SynValData(...),
       Named (SynIdent (resource, None), false, None, (2,9--2,17)),
       None,
       App (NonAtomic, false, Ident getResource,
            Const (Unit, (2,28--2,30)), (2,17--2,30)),
       (2,4--2,30), Yes (2,4--2,30),
       { LeadingKeyword = Use (2,4--2,7)
         InlineKeyword = None
         EqualsRange = Some (2,18--2,19) })],
   body,  // "return resource.Value"
   ...)

Complete AST Flags Mapping

Source Code AST Node (isRec, isUse, isFromSource, isComputed) Notes
let x = 42 LetOrUse (false, false, true, false) Regular let binding
let rec f x = ... LetOrUse (true, false, true, false) Recursive let binding
use file = ... LetOrUse (false, true, true, false) Regular use binding
let! x = asyncOp() LetOrUse (false, false, true, true) Computation expression let!
use! r = getResource() LetOrUse (false, true, true, true) Computation expression use!
let! a = opA() and! b = opB() LetOrUse (false, false, true, true) Multiple bindings in list
Compiler-generated let LetOrUse (false, false, false, false) isFromSource=false
Compiler-generated let! LetOrUse (false, false, false, true) During CE transformation

Understanding the Boolean Flags

isFromSource

  • true: User-written code that appears in the source file
  • false: Compiler-generated during transformations (pattern match compilation, CE desugaring, etc.)
  • Important for: error reporting, debugging, IDE features, code formatting

isComputed

  • true: Computation expression binding (let! or use!)
  • false: Regular binding (let or use)
  • Determines how the binding is processed during compilation

Migration Guidance for Ecosystem AST Users

1. Pattern Matching Updates

Before:

match expr with
| SynExpr.LetOrUse(isRec, isUse, bindings, body, range, trivia) ->
    // Handle regular let/use
| SynExpr.LetOrUseBang(spBind, isUse, isFromSource, pat, rhs, andBangs, body, range, trivia) ->
    // Handle let!/use!

After:

match expr with
| SynExpr.LetOrUse(isRec, isUse, isFromSource, isComputed, bindings, body, range, trivia) ->
    if isComputed then
        // This is a let!/use! expression
        match bindings with
        | firstBinding :: andBangs ->
            match firstBinding with
            | SynBinding(headPat = pat; expr = rhs) ->
                // pat and rhs extracted from first binding
                // andBangs contains the and! bindings
        | [] -> // error case
    else
        // This is a regular let/use expression

2. Construction Updates

Before:

// Creating a let! expression
SynExpr.LetOrUseBang(
    bindDebugPoint,
    false,  // isUse
    true,   // isFromSource
    pat,
    rhsExpr,
    andBangs,
    bodyExpr,
    range,
    trivia
)

After:

// Creating a let! expression
let firstBinding = SynBinding(
    accessibility = None,
    kind = SynBindingKind.Normal,
    isInline = false,
    isMutable = false,
    attributes = [],
    xmlDoc = PreXmlDoc.Empty,
    valData = SynInfo.emptySynValData,
    headPat = pat,           // Pattern moved here
    returnInfo = None,
    expr = rhsExpr,          // RHS moved here
    range = range,
    debugPoint = bindDebugPoint,  // Debug point moved here
    trivia = bindingTrivia
)
SynExpr.LetOrUse(
    false,  // isRecursive
    false,  // isUse
    true,   // isFromSource
    true,   // isComputed (indicates let!)
    firstBinding :: andBangs,  // All bindings in single list
    bodyExpr,
    range,
    trivia
)

3. Common Migration Patterns

Checking for computation expressions:

// Before
match expr with
| SynExpr.LetOrUseBang _ -> true
| _ -> false

// After
match expr with
| SynExpr.LetOrUse(isComputed = true) -> true
| _ -> false

Extracting pattern and expression from let!:

// Before
| SynExpr.LetOrUseBang(_, _, _, pat, rhs, _, _, _, _) ->
    processBinding pat rhs

// After
| SynExpr.LetOrUse(isComputed = true; bindings = binding :: _) ->
    match binding with
    | SynBinding(headPat = pat; expr = rhs) ->
        processBinding pat rhs
    | _ -> // error

Processing and! bindings:

// Before
| SynExpr.LetOrUseBang(_, _, _, firstPat, firstRhs, andBangs, _, _, _) ->
    processFirst firstPat firstRhs
    for andBang in andBangs do
        processAndBang andBang

// After
| SynExpr.LetOrUse(isComputed = true; bindings = bindings) ->
    match bindings with
    | first :: rest ->
        processBinding first
        for andBang in rest do
            processAndBang andBang
    | [] -> // error

Breaking Changes

This is a breaking change for any code that pattern matches on or constructs SynExpr values. All tools and libraries that work with the AST will need to be updated, including:

  • Code formatters (Fantomas)
  • Analyzers
  • Refactoring tools
  • IDE features
  • Custom compiler extensions

Checklist

  • Test cases added
  • Release notes entry updated

edgarfgp and others added 30 commits April 23, 2025 15:27
Ensure that regular let/use bindings (isComputed=false) are matched before let!/use! bindings (isComputed=true) to prevent computation expressions from being treated as regular bindings.
@edgarfgp edgarfgp requested a review from a team as a code owner August 8, 2025 07:37
Copy link
Member

@T-Gro T-Gro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this changes the public API, I think this deserves some form of impact analysis for this change, and some guidance around the change.

https://github.com/search?q=LetOrUseBang+language%3AF%23&type=code&l=F%23

@github-project-automation github-project-automation bot moved this from New to In Progress in F# Compiler and Tooling Aug 8, 2025
@edgarfgp edgarfgp force-pushed the unify-let-or-use-2 branch from 8ddf826 to 596202b Compare August 8, 2025 12:16
@edgarfgp edgarfgp force-pushed the unify-let-or-use-2 branch from 596202b to b1ed90b Compare August 8, 2025 12:37
@edgarfgp edgarfgp closed this Aug 10, 2025
@edgarfgp edgarfgp reopened this Aug 10, 2025
Copy link
Member

@T-Gro T-Gro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider writing a footnote on migration towards AST users.
Can be a footnote inside the release-notes file, pointed to from the main release notes entry.

@T-Gro T-Gro enabled auto-merge (squash) August 11, 2025 09:40
# Conflicts:
#	tests/ILVerify/ilverify_FSharp.Compiler.Service_Debug_netstandard2.0.bsl
#	tests/ILVerify/ilverify_FSharp.Compiler.Service_Release_netstandard2.0.bsl
auto-merge was automatically disabled August 11, 2025 10:11

Head branch was pushed to by a user without write access

@edgarfgp
Copy link
Contributor Author

Please consider writing a footnote on migration towards AST users. Can be a footnote inside the release-notes file, pointed to from the main release notes entry.

Done :)

@T-Gro T-Gro merged commit d82b8df into dotnet:main Aug 11, 2025
36 checks passed
let isUse = ($1 = "use")

mkLetExpression(true, mKeyword, None, m, $8, None, Some(pat, $4, $7, mEquals, isUse)) }
mkLetExpression(true, None, m, $8, None, Some(pat, $4, $7, mKeyword, mEquals, isUse)) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edgarfgp It seems the separate rules should not be needed anymore for the computed variants of the bindings too. Could you try unifying them with the the normal ones?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing!!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

3 participants