Skip to content

Add Extract to let binding and Extract to literal refactorings - #20537

Open
xperiandri wants to merge 12 commits into
dotnet:mainfrom
xperiandri:feature/extract-let-binding-refactoring
Open

xperiandri wants to merge 12 commits into
dotnet:mainfrom
xperiandri:feature/extract-let-binding-refactoring

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Offers Ctrl+. on a selected expression to bind it to a let in front of the statement that uses it, or, for a constant in a module-level declaration, to a [<Literal>] above that declaration. A caret inside a constant offers the literal; a caret in the fun … -> header of a parenthesized lambda extracts the lambda.

The value is computed at the start of the enclosing statement, so it can run earlier than where it was written, as with C#'s Introduce Local. The selection must match a whole expression; nothing is offered inside quotations, interpolation holes, while conditions or when guards.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

✅ Release notes checked


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`vsintegration/src` docs/release-notes/.VisualStudio/18.vNext.md


tryGetRefactoringActionsForSpan code (selectionOf code selected) context (new FSharpExtractLetBindingRefactoring())
|> Seq.map _.Title
|> List.ofSeq

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Suggested change
|> List.ofSeq
|> Seq.toList

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in 07b192f.

@xperiandri
xperiandri marked this pull request as ready for review September 14, 2026 19:40
@xperiandri
xperiandri requested a review from a team as a code owner September 14, 2026 19:40
@github-actions github-actions Bot added the ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager label Sep 14, 2026
@github-actions

This comment has been minimized.

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖🕵️ Please shorten the description using this guidance. Focus on the problem and why the change is needed, in simplified technical English. Leave the implementation inventory to the Files tab and retain necessary caveats.

@github-project-automation github-project-automation Bot moved this from New to In Progress in F# Compiler and Tooling Sep 15, 2026
@xperiandri xperiandri changed the title Refactorings to extract a selected expression into a let binding or a literal Add Extract to let binding and Extract to literal refactorings Sep 15, 2026
@xperiandri

Copy link
Copy Markdown
Contributor Author

Covered cases

Offered

  • An expression inside a statement is bound on the line before it: selecting w * h + 1 in printfn "%d" (w * h + 1) → let extracted = w * h + 1 + printfn "%d" (extracted); selecting (w * h + 1) removes the parentheses: printfn "%d" extracted
  • Method-call parentheses stay: selecting (1 + 2) in sb.Append(1 + 2) → sb.Append(extracted)
  • A multi-line pipeline on the right of let total = is bound in front of that let as let extracted = + the indented pipeline, and let total = then holds extracted; also when whole lines are selected with their indentation and line break
  • A body sharing its line with -> moves to its own lines: | Some v -> v * 2 + offset and (fun x -> x + 1) become -> followed by let extracted = … and extracted
  • A right-hand side on the = line keeps its trailing comment: let r = compute a b // slow → let extracted = compute a b + extracted // slow
  • Inside a computation expression the binding goes before the statement: return parse raw |> List.length } → let extracted = parse raw |> List.length + return extracted }
  • The name avoids existing identifiers: extracted1 when extracted is already used
  • CRLF line endings are kept
  • Lines inside a multi-line triple-quoted string (String.Format("""Hello … {0}""", name)) are not re-indented
  • Extract to literal inserts a declaration in front of the module-level let: "Hello %s" → [<Literal>] let ExtractedConstant = "Hello %s"; a negative number g -1 → let ExtractedConstant = -1
  • Which actions appear: for 42 in a module let, both "Extract to let binding" and "Extract to literal"; only the let binding for 42 in member _.M() = 42 and for x + 1
  • With no selection, a caret at the start of, inside (llo %s) or at the end of "Hello %s" gives the literal extraction; a caret inside 42 offers only the literal
  • With no selection, a caret on fun, the parameter, the arrow or right after it in xs |> List.map (fun x -> x + 1) extracts the whole lambda: let extracted = fun x -> x + 1 + xs |> List.map extracted (only the let binding is offered)

Not offered

  • With no selection, a caret in the lambda body (x + 1), + 1)), on the opening (fun parenthesis, or on a lambda without parentheses let f = fun x -> x + 1
  • With no selection, a caret on a non-constant g x, on 42 in member _.M() = 42, or on 1 in the interpolation $"{a + 1}"
  • A selection that is not a whole expression: w * h + in w * h + 1, or 2 + 3 in 1 + 2 + 3
  • A then branch sharing its line with else: x + 1 in if c then x + 1 else y
  • Selections inside a while condition i < n, a when guard v > 0, an interpolation $"{a + b}", or the right operand of &&: s.Length > 0
  • return id in a computation expression, the argument tuple a, b of sb.Insert(a, b), or a lone identifier x in x.ToString() + ""
  • An empty selection outside any constant or parenthesized lambda (let f x = x + 1)

@T-Gro T-Gro added the vsintegration-only Changes only Visual Studio integration, plus optional docs, release notes, or tests label Sep 21, 2026
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 🕵️ Re-reviewed all recent changes as of c2d5181; 2 findings

| SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse), SyntaxNode.SynExpr expr when isSame letOrUse.Body expr ->
ValueSome(Anchor.Statement expr.Range.Start)
| SyntaxNode.SynExpr(SynExpr.For(doBody = body) | SynExpr.ForEach(bodyExpr = body) | SynExpr.While(doExpr = body) | SynExpr.TryWith(
tryExpr = body) | SynExpr.TryFinally(tryExpr = body) | SynExpr.ComputationExpr(expr = body)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 🕵️ [P1] Keep extraction inside the finally block. Selecting stream.Dispose() inserts let extracted = stream.Dispose() before the try, so this working function throws ObjectDisposedException at WriteByte. Moving cleanup across the finally boundary is different from evaluating a value earlier within its enclosing statement. Give finallyExpr its own anchor, as tryExpr already has, or do not offer extraction there.

open System.IO

let f () =
    use stream = new MemoryStream()
    try
        stream.WriteByte(1uy)
    finally
        stream.Dispose() // Select this expression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 07b192f: finallyExpr has its own anchor, so the binding goes inside the finally block. Test: Expression in a finally block is bound inside the block.

|> String.concat ""

[
TextChange(TextSpan(line.Start, 0), $"{attributeLines}{padding indent}{declaration}{lineBreak}{lineBreak}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 🕵️ [P2] Keep the literal inside its enclosing module. Selecting 42 here inserts [<Literal>] let ExtractedConstant = 42 between namespace N and module M, so the valid original becomes a namespace-level value and fails with FS0201, "Namespaces cannot contain values." Inserting at the physical line start escapes the inline module. Split its body correctly or decline this action when the module's let is not line-leading.

namespace N
module M = let f () = 42 // Extract 42 to literal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 07b192f: the literal is not offered when the module-level let does not start its line. Test: Literal is not offered where the declaration does not start its line.

@xperiandri
xperiandri force-pushed the feature/extract-let-binding-refactoring branch from c2d5181 to 90f8221 Compare October 10, 2026 22:28

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The diff is still way too big - apply the NoBloat instructions and CodeCompaction skill.

Especially the massive code comments would benefit not just with drastic reduction, but ideally with well named functions/arguments/bindings/types making the intention clear.

If a well named piece of code just tells what the comment says - delete the comment.

  • This diff adds 38 new code comment lines and 3462 new code comment chars.
  • ExtractLetBinding.fs:78-93 reaches at least 3 logical nesting levels.
  • Tests would benefit from deduplicated setup or code-as-test reuse: ExtractLetBindingTests.fs contains 21 copied [<Fact>] methods with repeated module M boilerplate that should be parameterized.

xperiandri and others added 10 commits October 15, 2026 01:38
…g or a literal

The selected expression is bound to a new name in front of the statement that uses it: above a statement that starts its line, or, when the body of a binding, match clause, branch or lambda shares the line with its keyword, on new lines under that keyword. A constant inside a module-level declaration can instead become a [<Literal>] in front of that declaration. The shared selection and placement logic lives in Refactor/RefactoringHelpers.fs, and the refactoring test framework gains helpers that run a provider on a selected span.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
C# offers Introduce Constant without a selection. With a caret alone only the
literal is offered; a let binding still needs a selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A caret between 'fun' and the end of '->' acts as a selection of the
parenthesized lambda; in the body a caret offers nothing new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e of its section

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on's way

The name collector now also records the heads of assignment targets, so the
new local cannot shadow a value the selection's statement assigns to. Lines
inside multi-line strings of inactive #if branches are found by lexing each
inactive branch as the code it is when taken, and are not re-indented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to literal

- Annotate an extracted lambda or function expression with the type its
  context gave it, unless that type has a variable inference introduced.
- Do not extract from inside assert, the operand of nameof, or a
  parenthesized name, address-of or named argument.
- Bind an expression of a finally block inside that block.
- Indent a body moved under its keyword from the let, match, try, if or
  loop that starts inside the line.
- Offer the literal for decimal constants and not for native integers.
- Qualify the generated Literal attribute where another LiteralAttribute
  hides the FSharp.Core one.
- Do not offer the literal where the module-level let does not start its
  line.
- Reserve every identifier of a long pattern when picking a new name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ected inside its parentheses

The checker records the type of the parenthesized expression, so a lambda
selected without the parentheses around it takes the type from them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feature/extract-let-binding-refactoring branch from d3742a6 to 2920e42 Compare October 15, 2026 00:46
xperiandri and others added 2 commits October 15, 2026 03:09
Replace the explanatory comments with names, keep the document state of an extraction and the registration of its code actions in one record and one function, flatten the provider and the declaration builders to two levels of nesting, and scan the rest of a line for closing brackets without copying it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
One theory per kind of assertion with a scenario name per row. The module wrapper, the selection and caret spans, the refactored text and the offered titles move to the refactoring test framework, where the Extract to function tests can use them too. Every case is kept; tests that asserted several things became one row per assertion, so 64 cases became 74.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@xperiandri

Copy link
Copy Markdown
Contributor Author

Compacted in 034e6f2 (production code) and 1711de9 (tests): the F# lines this PR adds against main went from 1731 to 1498, and the comment lines from 38 (3578 chars) to 3 (320 chars: two license headers and one note that the public tokenizer enum does not name the interpolated string states); everything else is now said by names such as Extraction, offsideColumnOf, ElifBranch and hasOrphanedComputationConstruct.
ComputeRefactoringsAsync and tryDeclareInFront went from 5 nested levels to 2, no function in ExtractLetBinding.fs or RefactoringHelpers.fs is nested deeper than 2 now, and the provider and the declaration builders share one Extraction record and one registerRefactoring instead of threading seven arguments around.
The 21 [<Fact>] and 10 [<Theory>] methods (64 cases) are now 6 [<Theory>] methods over 74 named rows (a test that asserted several things became one row per assertion), with the module M wrapper in one helper, fileOf, next to the selection, caret, text and title helpers in RefactorTestFramework.fs.
ExtractLetBindingTests passes 74/74 (64/64 before), the AddReturnTypeTests control 22/22, and FSharp.Editor builds with --optimize+.

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖🕵️ AI review — verify independently.


lines

let usedNames (parseTree: ParsedInput) =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖🕵️ Extracted binding shadows an import used in an inactive branch; enabling ALT changes 13 to 6.

module M
open Dependency
let f () =
    let n = (1 + 2)
#if ALT
    n + extracted
#else
    n
#endif

xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 19, 2026
This branch had only the two copies without the PR link. They give way to the
bullet the PR keeps (7bf3578 of dotnet#20537).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 19, 2026
Port of the dotnet#20537 compaction (feature/extract-let-binding-refactoring
2920e42..1711de9) to all-fixes:

- Compact the Extract to let binding code and name what its comments said
- Fold the Extract to let binding tests into parameterized theories

Extract to function (dotnet#20538) shares RefactoringHelpers.fs, so its callers
follow the renames here, as in the pull request's merge of this head.

The refactoring test framework carries the helpers of both the Extract and the
tuple/record test suites on all-fixes. The caret helper of the Extract suites,
which returns a TextSpan and reads a `|` in the marker, is therefore named
caretSpanAt; the existing caretAt of the conversion suites keeps returning the
index of the marker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 19, 2026
Port of the dotnet#20538 compaction (feature/extract-function-refactoring
838b68d..b4cccb8) to all-fixes. The part of that range which merges the
compacted Extract to let binding (dotnet#20537) came with the previous commit; this
one carries what is specific to Extract to function:

- Compact the Extract to function code and name what its comments said
- Fold the Extract to function tests into parameterized theories

The tests use caretSpanAt, the name the Extract suites' caret helper has in the
shared refactoring test framework on all-fixes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 19, 2026
This branch had only the two copies without the PR link. They give way to the
bullet the PR keeps (7bf3578 of dotnet#20537).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 19, 2026
Port of the dotnet#20537 compaction (feature/extract-let-binding-refactoring
2920e42..1711de9) to all-fixes:

- Compact the Extract to let binding code and name what its comments said
- Fold the Extract to let binding tests into parameterized theories

Extract to function (dotnet#20538) shares RefactoringHelpers.fs, so its callers
follow the renames here, as in the pull request's merge of this head.

The refactoring test framework carries the helpers of both the Extract and the
tuple/record test suites on all-fixes. The caret helper of the Extract suites,
which returns a TextSpan and reads a `|` in the marker, is therefore named
caretSpanAt; the existing caretAt of the conversion suites keeps returning the
index of the marker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 19, 2026
Port of the dotnet#20538 compaction (feature/extract-function-refactoring
838b68d..b4cccb8) to all-fixes. The part of that range which merges the
compacted Extract to let binding (dotnet#20537) came with the previous commit; this
one carries what is specific to Extract to function:

- Compact the Extract to function code and name what its comments said
- Fold the Extract to function tests into parameterized theories

The tests use caretSpanAt, the name the Extract suites' caret helper has in the
shared refactoring test framework on all-fixes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 23, 2026
This branch had only the two copies without the PR link. They give way to the
bullet the PR keeps (7bf3578 of dotnet#20537).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 23, 2026
Port of the dotnet#20537 compaction (feature/extract-let-binding-refactoring
2920e42..1711de9) to all-fixes:

- Compact the Extract to let binding code and name what its comments said
- Fold the Extract to let binding tests into parameterized theories

Extract to function (dotnet#20538) shares RefactoringHelpers.fs, so its callers
follow the renames here, as in the pull request's merge of this head.

The refactoring test framework carries the helpers of both the Extract and the
tuple/record test suites on all-fixes. The caret helper of the Extract suites,
which returns a TextSpan and reads a `|` in the marker, is therefore named
caretSpanAt; the existing caretAt of the conversion suites keeps returning the
index of the marker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 23, 2026
Port of the dotnet#20538 compaction (feature/extract-function-refactoring
838b68d..b4cccb8) to all-fixes. The part of that range which merges the
compacted Extract to let binding (dotnet#20537) came with the previous commit; this
one carries what is specific to Extract to function:

- Compact the Extract to function code and name what its comments said
- Fold the Extract to function tests into parameterized theories

The tests use caretSpanAt, the name the Extract suites' caret helper has in the
shared refactoring test framework on all-fixes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 23, 2026
This branch had only the two copies without the PR link. They give way to the
bullet the PR keeps (7bf3578 of dotnet#20537).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 23, 2026
Port of the dotnet#20537 compaction (feature/extract-let-binding-refactoring
2920e42..1711de9) to all-fixes:

- Compact the Extract to let binding code and name what its comments said
- Fold the Extract to let binding tests into parameterized theories

Extract to function (dotnet#20538) shares RefactoringHelpers.fs, so its callers
follow the renames here, as in the pull request's merge of this head.

The refactoring test framework carries the helpers of both the Extract and the
tuple/record test suites on all-fixes. The caret helper of the Extract suites,
which returns a TextSpan and reads a `|` in the marker, is therefore named
caretSpanAt; the existing caretAt of the conversion suites keeps returning the
index of the marker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Oct 23, 2026
Port of the dotnet#20538 compaction (feature/extract-function-refactoring
838b68d..b4cccb8) to all-fixes. The part of that range which merges the
compacted Extract to let binding (dotnet#20537) came with the previous commit; this
one carries what is specific to Extract to function:

- Compact the Extract to function code and name what its comments said
- Fold the Extract to function tests into parameterized theories

The tests use caretSpanAt, the name the Extract suites' caret helper has in the
shared refactoring test framework on all-fixes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Labels

⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager vsintegration-only Changes only Visual Studio integration, plus optional docs, release notes, or tests

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

4 participants