From 1d0aaa645ac8ce41414b5b44066c2bd14aba9153 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 01:32:25 +0200 Subject: [PATCH 01/31] Add refactorings that extract a selected expression into a let binding 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 [] 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) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../src/FSharp.Editor/FSharp.Editor.fsproj | 2 + .../src/FSharp.Editor/FSharp.Editor.resx | 5 + .../Refactor/ExtractLetBinding.fs | 84 +++ .../Refactor/RefactoringHelpers.fs | 507 ++++++++++++++++++ .../Telemetry/TelemetryReporter.fs | 3 + .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 9 + .../xlf/FSharp.Editor.zh-Hans.xlf | 9 + .../xlf/FSharp.Editor.zh-Hant.xlf | 9 + .../FSharp.Editor.Tests.fsproj | 1 + .../Refactors/ExtractLetBindingTests.fs | 170 ++++++ .../Refactors/RefactorTestFramework.fs | 27 + 22 files changed, 917 insertions(+) create mode 100644 vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs create mode 100644 vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 2357fbcdff4..b89e38849a4 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -6,6 +6,7 @@ * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) +* **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index f5d6a8e0b04..7c41a67c593 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -105,10 +105,12 @@ + + diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx index 8d1a5854269..df31b5b5295 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx @@ -372,5 +372,10 @@ Use live (unsaved) buffers for analysis {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + + + Extract to literal \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs new file mode 100644 index 00000000000..947e5c4871a --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System.Composition + +open Microsoft.CodeAnalysis.CodeActions +open Microsoft.CodeAnalysis.CodeRefactorings +open Microsoft.CodeAnalysis.Formatting +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor.Telemetry + +open FSharp.Compiler.Syntax + +open RefactoringHelpers +open CancellableTasks + +[] +type internal FSharpExtractLetBindingRefactoring [] () = + inherit CodeRefactoringProvider() + + static let register + (context: CodeRefactoringContext) + (sourceText: SourceText) + (title: string) + (kind: string) + (changes: TextChange list) + = + let changedDocument = + cancellableTask { + TelemetryReporter.ReportSingleEvent( + TelemetryEvents.RefactoringActivated, + [| "name", box (nameof FSharpExtractLetBindingRefactoring); "kind", box kind |] + ) + + return context.Document.WithText(sourceText.WithChanges changes) + } + + context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + + override _.ComputeRefactoringsAsync context = + cancellableTask { + let document = context.Document + + if not (context.Span.IsEmpty || document.IsFSharpSignatureFile) then + let! cancellationToken = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync cancellationToken + let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractLetBindingRefactoring) + + match tryExtractionTarget sourceText parseResults.ParseTree context.Span with + | ValueNone -> () + | ValueSome target -> + let! options = document.GetOptionsAsync cancellationToken + + let indentSize = + options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) + + let names = usedNames parseResults.ParseTree + let literalLines = linesInsideLiterals parseResults.ParseTree + + match anchorsOf target.Expr target.Path with + | anchor :: _ -> + let name = uniqueName "extracted" names + + match tryDeclareInFront sourceText target anchor $"let {name}" name indentSize literalLines with + | ValueSome changes -> register context sourceText (SR.ExtractToLetBinding()) "let" changes + | ValueNone -> () + | [] -> () + + let constant = + match target.Expr with + | SynExpr.Paren(expr = inner) -> inner + | expr -> expr + + if isLiteralConstant constant then + let name = uniqueName "ExtractedConstant" names + + match + tryDeclareInFrontOfModuleLet sourceText target [ "[]" ] $"let {name}" name indentSize literalLines + with + | ValueSome changes -> register context sourceText (SR.ExtractToLiteral()) "literal" changes + | ValueNone -> () + } + |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs new file mode 100644 index 00000000000..f69c579a364 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -0,0 +1,507 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module internal Microsoft.VisualStudio.FSharp.Editor.RefactoringHelpers + +open System +open System.Collections.Generic +open System.Text + +open Microsoft.CodeAnalysis.Text + +open FSharp.Compiler.Syntax +open FSharp.Compiler.SyntaxTrivia +open FSharp.Compiler.Text + +/// A place in front of which a declaration used by the selected expression can be inserted. +[] +type Anchor = + /// An expression that is a statement of its block. + | Statement of start: pos + /// The body of a binding, match clause, branch or lambda, after the keyword ending at keywordEnd. + | Clause of keywordEnd: pos * body: range + +[] +type ExtractionTarget = + { + Expr: SynExpr + Path: SyntaxVisitorPath + /// The selection without its parentheses when the selected expression is parenthesized. + Content: TextSpan + /// The text the new name replaces; the parentheses of a method call stay. + Replaced: TextSpan + } + +let private isSame (expr: SynExpr) (other: SynExpr) = obj.ReferenceEquals(expr, other) + +let private hasName (name: string) (ident: Ident) = + String.Equals(ident.idText, name, StringComparison.Ordinal) + +let textSpanOf (sourceText: SourceText) (m: range) = + RoslynHelpers.FSharpRangeToTextSpan(sourceText, m) + +let positionOf (sourceText: SourceText) (offset: int) = + let linePosition = sourceText.Lines.GetLinePosition offset + Position.mkPos (Line.fromZ linePosition.Line) linePosition.Character + +let leadingSpaces (sourceText: SourceText) (line: TextLine) = + let mutable position = line.Start + + while position < line.End && sourceText[position] = ' ' do + position <- position + 1 + + position - line.Start + +let lineBreakOf (sourceText: SourceText) = + sourceText.Lines + |> Seq.tryFind (fun line -> line.EndIncludingLineBreak > line.End) + |> Option.map (fun line -> sourceText.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak))) + |> Option.defaultValue Environment.NewLine + +let isLineLeading (sourceText: SourceText) (position: pos) = + let line = sourceText.Lines[Line.toZ position.Line] + leadingSpaces sourceText line = position.Column + +/// Whether only closing brackets and a line comment follow the position on its line. +let restOfLineIsClosers (sourceText: SourceText) (position: pos) = + let line = sourceText.Lines[Line.toZ position.Line] + + let rest = + sourceText.ToString(TextSpan.FromBounds(line.Start + position.Column, line.End)) + + let code = + match rest.IndexOf("//", StringComparison.Ordinal) with + | -1 -> rest + | comment -> rest.Substring(0, comment) + + code + |> Seq.forall (fun c -> Char.IsWhiteSpace c || c = ')' || c = ']' || c = '}' || c = '|') + +let linesInsideLiterals (parseTree: ParsedInput) = + (HashSet(), parseTree) + ||> ParsedInput.fold (fun lines _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Const(range = m)) + | SyntaxNode.SynExpr(SynExpr.InterpolatedString(range = m)) + | SyntaxNode.SynPat(SynPat.Const(range = m)) -> + for line in m.StartLine + 1 .. m.EndLine do + lines.Add(Line.toZ line) |> ignore + | _ -> () + + lines) + +let usedNames (parseTree: ParsedInput) = + (HashSet(StringComparer.Ordinal), parseTree) + ||> ParsedInput.fold (fun names _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Ident ident) + | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _))) + | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) + | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = ident :: _))) -> names.Add ident.idText |> ignore + | _ -> () + + names) + +let uniqueName (baseName: string) (used: HashSet) = + Seq.initInfinite (fun i -> if i = 0 then baseName else $"{baseName}{i}") + |> Seq.find (used.Contains >> not) + +/// The text of the span with every line after the first moved by as many columns as the first line moves to reach +/// column. Lines inside multi-line string literals are kept as they are; a line that would need a negative indentation +/// makes the whole text unavailable. +let tryIndentedText (sourceText: SourceText) (span: TextSpan) (column: int) (literalLines: HashSet) = + let lines = sourceText.Lines + let first = lines.GetLineFromPosition span.Start + let last = lines.GetLineFromPosition span.End + let shift = column - (span.Start - first.Start) + + let text = + StringBuilder(sourceText.ToString(TextSpan.FromBounds(span.Start, min first.End span.End))) + + let rec append lineNumber = + if lineNumber > last.LineNumber then + ValueSome(text.ToString()) + else + let previous = lines[lineNumber - 1] + let line = lines[lineNumber] + + let content = + sourceText.ToString(TextSpan.FromBounds(line.Start, min line.End span.End)) + + let moved = + match literalLines.Contains lineNumber with + | true -> ValueSome content + | false when String.IsNullOrWhiteSpace content -> ValueSome content + | false when shift >= 0 -> ValueSome(String(' ', shift) + content) + | false when leadingSpaces sourceText line >= -shift -> ValueSome(content.Substring(-shift)) + | false -> ValueNone + + match moved with + | ValueSome moved -> + text.Append(sourceText.ToString(TextSpan.FromBounds(previous.End, previous.EndIncludingLineBreak))).Append(moved) + |> ignore + + append (lineNumber + 1) + | ValueNone -> ValueNone + + append (first.LineNumber + 1) + +let private trimmed (sourceText: SourceText) (span: TextSpan) = + let mutable start = span.Start + let mutable finish = span.End + + while start < finish && Char.IsWhiteSpace sourceText[start] do + start <- start + 1 + + while finish > start && Char.IsWhiteSpace sourceText[finish - 1] do + finish <- finish - 1 + + TextSpan.FromBounds(start, finish) + +let private directiveLine (directive: ConditionalDirectiveTrivia) = + match directive with + | ConditionalDirectiveTrivia.If(range = m) + | ConditionalDirectiveTrivia.Elif(range = m) + | ConditionalDirectiveTrivia.Else(range = m) + | ConditionalDirectiveTrivia.EndIf(range = m) -> m.StartLine + +let private isOperator (name: string) (expr: SynExpr) = + match expr with + | SynExpr.LongIdent(longDotId = SynLongIdent(id = [ operator ])) -> hasName name operator + | _ -> false + +let private isCall (path: SyntaxVisitorPath) = + match path with + | SyntaxNode.SynExpr(SynExpr.App(flag = ExprAtomicFlag.Atomic) | SynExpr.New _) :: _ -> true + | _ -> false + +let private isMethodArgument (path: SyntaxVisitorPath) = + match path with + | SyntaxNode.SynExpr(SynExpr.Paren _) :: call + | SyntaxNode.SynExpr(SynExpr.Tuple _) :: SyntaxNode.SynExpr(SynExpr.Paren _) :: call -> isCall call + | _ -> false + +let private isExtractableShape (expr: SynExpr) (path: SyntaxVisitorPath) = + match expr with + | SynExpr.App(isInfix = true) + | SynExpr.Ident _ + | SynExpr.LongIdent(longDotId = SynLongIdent(id = [ _ ])) + | SynExpr.Const(SynConst.Unit, _) + | SynExpr.Paren(rightParenRange = None) + | SynExpr.ComputationExpr _ + | SynExpr.YieldOrReturn _ + | SynExpr.YieldOrReturnFrom _ + | SynExpr.DoBang _ + | SynExpr.MatchBang _ + | SynExpr.WhileBang _ + | SynExpr.ImplicitZero _ + | SynExpr.SequentialOrImplicitYield _ + | SynExpr.JoinIn _ + | SynExpr.ArbitraryAfterError _ + | SynExpr.FromParseError _ + | SynExpr.DiscardAfterMissingQualificationAfterDot _ + | SynExpr.Typar _ + | SynExpr.TraitCall _ + | SynExpr.IndexRange _ + | SynExpr.IndexFromEnd _ + | SynExpr.Fixed _ + | SynExpr.AddressOf _ + | SynExpr.Do _ + | SynExpr.Dynamic _ + | SynExpr.LongIdentSet _ + | SynExpr.Set _ + | SynExpr.DotSet _ + | SynExpr.DotIndexedSet _ + | SynExpr.NamedIndexedPropertySet _ + | SynExpr.DotNamedIndexedPropertySet _ -> false + | SynExpr.LetOrUse letOrUse -> not letOrUse.IsBang + | SynExpr.Tuple _ -> not (isMethodArgument path) + | SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true; funcExpr = equals; argExpr = SynExpr.Ident _)) when + isOperator "op_Equality" equals + -> + not (isMethodArgument path) + | _ -> true + +let private isInExcludedContext (expr: SynExpr) (path: SyntaxVisitorPath) = + let rec loop (child: SyntaxNode) (path: SyntaxVisitorPath) = + match path, child with + | [], _ -> false + | SyntaxNode.SynExpr(SynExpr.InterpolatedString _ | SynExpr.Quote _ | SynExpr.Lazy _) :: _, _ -> true + | SyntaxNode.SynExpr(SynExpr.While(whileExpr = condition) | SynExpr.WhileBang(whileExpr = condition)) :: _, SyntaxNode.SynExpr expr when + isSame condition expr + -> + true + | SyntaxNode.SynMatchClause(SynMatchClause(whenExpr = Some guard)) :: _, SyntaxNode.SynExpr expr when isSame guard expr -> true + | SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true; funcExpr = operator); argExpr = right)) :: _, + SyntaxNode.SynExpr expr when + isSame right expr + && (isOperator "op_BooleanAnd" operator || isOperator "op_BooleanOr" operator) + -> + true + | parent :: rest, _ -> loop parent rest + + loop (SyntaxNode.SynExpr expr) path + +/// Whether the expression contains a computation expression construct outside any computation expression of its own. +let private hasStatementOnlyConstruct (expr: SynExpr) = + let containers = ResizeArray() + let statements = ResizeArray() + + [ SyntaxNode.SynExpr expr ] + |> SyntaxNodes.fold + (fun () _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.ComputationExpr(range = m) | SynExpr.ArrayOrListComputed(range = m)) -> containers.Add m + | SyntaxNode.SynExpr(SynExpr.YieldOrReturn(range = m) | SynExpr.YieldOrReturnFrom(range = m) | SynExpr.DoBang(range = m) | SynExpr.MatchBang( + range = m) | SynExpr.WhileBang(range = m) | SynExpr.JoinIn(range = m)) -> statements.Add m + | SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse) when letOrUse.IsBang -> statements.Add letOrUse.Range + | _ -> ()) + () + + statements + |> Seq.exists (fun statement -> + not ( + containers + |> Seq.exists (fun container -> Range.rangeContainsRange container statement) + )) + +let tryExtractionTarget (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = + let span = trimmed sourceText selection + + match parseTree with + | ParsedInput.ImplFile file when not span.IsEmpty -> + let start = positionOf sourceText span.Start + let finish = positionOf sourceText span.End + + let crossesDirective = + file.Trivia.ConditionalDirectives + |> List.exists (fun directive -> + let line = directiveLine directive + line >= start.Line && line <= finish.Line) + + let exact = + (start, parseTree) + ||> ParsedInput.tryPickLast (fun path node -> + match node with + | SyntaxNode.SynExpr expr when Position.posEq expr.Range.Start start && Position.posEq expr.Range.End finish -> + Some(expr, path) + | _ -> None) + + match exact with + | Some(expr, path) when + not crossesDirective + && isExtractableShape expr path + && not (isInExcludedContext expr path) + && not (hasStatementOnlyConstruct expr) + -> + let content = + match expr with + | SynExpr.Paren(expr = inner) -> textSpanOf sourceText inner.Range + | _ -> span + + let replaced = + match expr with + | SynExpr.Paren _ when isCall path -> content + | _ -> span + + ValueSome + { + Expr = expr + Path = path + Content = content + Replaced = replaced + } + | _ -> ValueNone + | _ -> ValueNone + +let private isFunctionBinding (binding: SynBinding) = + match binding with + | SynBinding(headPat = SynPat.LongIdent(argPats = SynArgPats.Pats(_ :: _))) -> true + | _ -> false + +let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: SyntaxNode voption) = + match parent, child with + | SyntaxNode.SynExpr(SynExpr.Sequential _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) + | 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)), + SyntaxNode.SynExpr expr when isSame body expr -> ValueSome(Anchor.Statement expr.Range.Start) + | SyntaxNode.SynModule(SynModuleDecl.Expr _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) + | SyntaxNode.SynBinding(SynBinding(expr = rhs; trivia = trivia) as binding), SyntaxNode.SynExpr expr when isSame rhs expr -> + match grandparent, trivia.EqualsRange with + | ValueSome(SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse)), _ when not letOrUse.IsRecursive && not (isFunctionBinding binding) -> + ValueSome(Anchor.Statement letOrUse.Range.Start) + | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range)) + | _ -> ValueNone + | SyntaxNode.SynMatchClause(SynMatchClause(resultExpr = result; trivia = trivia)), SyntaxNode.SynExpr expr when isSame result expr -> + match trivia.ArrowRange with + | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range)) + | None -> ValueNone + | SyntaxNode.SynExpr(SynExpr.IfThenElse(thenExpr = thenExpr; elseExpr = elseExpr; trivia = trivia)), SyntaxNode.SynExpr expr -> + match elseExpr, trivia.ElseKeyword with + | _ when isSame thenExpr expr -> ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range)) + | Some elseExpr, Some elseKeyword when isSame elseExpr expr -> ValueSome(Anchor.Clause(elseKeyword.End, expr.Range)) + | _ -> ValueNone + | SyntaxNode.SynExpr(SynExpr.Lambda(parsedData = Some(_, body); trivia = trivia)), SyntaxNode.SynExpr expr when isSame body expr -> + match trivia.ArrowRange with + | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range)) + | None -> ValueNone + | _ -> ValueNone + +/// The places a declaration used by the expression can go, innermost first. +let anchorsOf (expr: SynExpr) (path: SyntaxVisitorPath) = + let rec loop (child: SyntaxNode) (path: SyntaxVisitorPath) = + match path with + | [] -> [] + | parent :: rest -> + let grandparent = + match rest with + | node :: _ -> ValueSome node + | [] -> ValueNone + + match anchorOf child parent grandparent with + | ValueSome anchor -> anchor :: loop parent rest + | ValueNone -> loop parent rest + + loop (SyntaxNode.SynExpr expr) path + +let isLiteralConstant (expr: SynExpr) = + match expr with + | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.IntPtr _ | SynConst.UIntPtr _ | SynConst.Single _ | SynConst.Double _ | SynConst.Char _ | SynConst.String _), + _) -> true + | _ -> false + +/// The module-level let declaration containing the path, with its first binding. +let tryEnclosingModuleLet (path: SyntaxVisitorPath) = + path + |> List.tryPick (function + | SyntaxNode.SynModule(SynModuleDecl.Let(bindings = binding :: _; range = m)) -> Some struct (binding, m) + | _ -> None) + +let private padding (width: int) = String(' ', width) + +/// `header = `, with a multi-line content starting on its own line at bodyColumn. +let private tryDeclaration (sourceText: SourceText) (content: TextSpan) (header: string) (bodyColumn: int) literalLines lineBreak = + tryIndentedText sourceText content bodyColumn literalLines + |> ValueOption.map (fun body -> + let lines = sourceText.Lines + + if lines.GetLineFromPosition(content.Start).LineNumber = lines.GetLineFromPosition(content.End).LineNumber then + $"{header} = {body}" + else + $"{header} ={lineBreak}{padding bodyColumn}{body}") + +/// Changes that declare `header = ` in front of the anchor and put replacement where the selection was. +/// A body that shares its line with its keyword moves to new lines under that keyword, when only closing brackets +/// and a comment follow it. +let tryDeclareInFront + (sourceText: SourceText) + (target: ExtractionTarget) + (anchor: Anchor) + (header: string) + (replacement: string) + (indentSize: int) + (literalLines: HashSet) + = + let lines = sourceText.Lines + let lineBreak = lineBreakOf sourceText + let replacementChange = TextChange(target.Replaced, replacement) + + let inFrontOfLine (start: pos) = + let line = lines[Line.toZ start.Line] + + tryDeclaration sourceText target.Content header (start.Column + indentSize) literalLines lineBreak + |> ValueOption.map (fun declaration -> + [ + TextChange(TextSpan(line.Start, 0), $"{padding start.Column}{declaration}{lineBreak}") + replacementChange + ]) + + match anchor with + | Anchor.Statement start when isLineLeading sourceText start -> inFrontOfLine start + | Anchor.Clause(_, body) when isLineLeading sourceText body.Start -> inFrontOfLine body.Start + | Anchor.Clause(keywordEnd, body) when keywordEnd.Line = body.StartLine && restOfLineIsClosers sourceText body.End -> + let keywordLine = lines[Line.toZ keywordEnd.Line] + let keywordEndOffset = keywordLine.Start + keywordEnd.Column + let bodySpan = textSpanOf sourceText body + let newIndent = leadingSpaces sourceText keywordLine + indentSize + let shift = newIndent - body.StartColumn + + let continuation = + [ + for lineNumber in Line.toZ body.StartLine + 1 .. Line.toZ body.EndLine do + let line = lines[lineNumber] + + let keep = + literalLines.Contains lineNumber + || String.IsNullOrWhiteSpace(line.ToString()) + || (line.Start >= target.Replaced.Start && line.Start <= target.Replaced.End) + + if not keep then + line + ] + + let movable = + continuation + |> List.forall (fun line -> shift >= 0 || leadingSpaces sourceText line >= -shift) + + let separatedByWhitespace = + String.IsNullOrWhiteSpace(sourceText.ToString(TextSpan.FromBounds(keywordEndOffset, bodySpan.Start))) + + if movable && separatedByWhitespace then + tryDeclaration sourceText target.Content header (newIndent + indentSize) literalLines lineBreak + |> ValueOption.map (fun declaration -> + [ + TextChange( + TextSpan.FromBounds(keywordEndOffset, bodySpan.Start), + $"{lineBreak}{padding newIndent}{declaration}{lineBreak}{padding newIndent}" + ) + + for line in continuation do + if shift > 0 then + TextChange(TextSpan(line.Start, 0), padding shift) + elif shift < 0 then + TextChange(TextSpan(line.Start, -shift), "") + + replacementChange + ] + |> List.sortBy _.Span.Start) + else + ValueNone + | _ -> ValueNone + +/// Changes that declare `header = `, preceded by the attribute lines, in front of the module-level let +/// containing the selection, and put replacement where the selection was. +let tryDeclareInFrontOfModuleLet + (sourceText: SourceText) + (target: ExtractionTarget) + (attributes: string list) + (header: string) + (replacement: string) + (indentSize: int) + (literalLines: HashSet) + = + match tryEnclosingModuleLet target.Path with + | Some(struct (SynBinding(xmlDoc = xmlDoc), declaration)) -> + let firstLine = + if xmlDoc.IsEmpty then + declaration.StartLine + else + min declaration.StartLine xmlDoc.Range.StartLine + + let line = sourceText.Lines[Line.toZ firstLine] + let indent = leadingSpaces sourceText line + let lineBreak = lineBreakOf sourceText + + tryDeclaration sourceText target.Content header (indent + indentSize) literalLines lineBreak + |> ValueOption.map (fun declaration -> + let attributeLines = + attributes + |> List.map (fun attribute -> $"{padding indent}{attribute}{lineBreak}") + |> String.concat "" + + [ + TextChange(TextSpan(line.Start, 0), $"{attributeLines}{padding indent}{declaration}{lineBreak}{lineBreak}") + TextChange(target.Replaced, replacement) + ]) + | None -> ValueNone diff --git a/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs b/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs index 230f4988438..a5bf5e6b4ef 100644 --- a/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs +++ b/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs @@ -18,6 +18,9 @@ module TelemetryEvents = [] let CodefixActivated = "codefixactivated" + [] + let RefactoringActivated = "refactoringactivated" + [] let Hints = "hints" diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index 5af8dfaab66..621a7cc6182 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -111,6 +111,15 @@ Navrhnout názvy pro nerozpoznané identifikátory; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf index e7cf1e34711..55ff3d83516 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -111,6 +111,15 @@ Namen für nicht aufgelöste Bezeichner vorschlagen; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index a763e64aca8..05884c36794 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -111,6 +111,15 @@ Sugerir nombres para identificadores sin resolver; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index 6fd805187e5..a704b5c0ee9 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -111,6 +111,15 @@ Suggérer des noms pour les identificateurs non résolus ; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index 766483153f0..ab458f374d6 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -111,6 +111,15 @@ Suggerisci i nomi per gli identificatori non risolti; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index 30834f89e84..6d939190627 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -111,6 +111,15 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index 1ef22e3047d..8c2e6ff8120 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -111,6 +111,15 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index 1091913daa2..b905856be5f 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -111,6 +111,15 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf index 28e5ff703d2..b6d772e6ecc 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf @@ -111,6 +111,15 @@ Sugerir nomes para identificadores não resolvidos; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 5787d9818f3..20c238f2e3f 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -111,6 +111,15 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index 933e42779d4..9bd18a8e47a 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -111,6 +111,15 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf index 69381cc377d..16ac685c747 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf @@ -111,6 +111,15 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf index 4bdafbf0121..1bcc227c0d8 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf @@ -111,6 +111,15 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index d5d1a09c79a..dfae9f2c44e 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -79,6 +79,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs new file mode 100644 index 00000000000..e30e122c27b --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -0,0 +1,170 @@ +module FSharp.Editor.Tests.Refactors.ExtractLetBindingTests + +open System + +open Microsoft.CodeAnalysis.Text + +open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks + +open Xunit + +open FSharp.Editor.Tests.Refactors.RefactorTestFramework + +let private extractToLetBinding = "Extract to let binding" +let private extractToLiteral = "Extract to literal" + +let private selectionOf (code: string) (selected: string) = + TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) + +let private extracted (title: string) (code: string) (selected: string) = + use context = TestContext.CreateWithCode code + + let document = + refactorSpan code (selectionOf code selected) title context (new FSharpExtractLetBindingRefactoring()) + + let parseResults = + document.GetFSharpParseResultsAsync "test" + |> CancellableTask.runSynchronouslyWithoutCancellation + + Assert.Empty(parseResults.Diagnostics) + (document.GetTextAsync() |> GetTaskResult).ToString() + +let private titlesFor (code: string) (selected: string) = + use context = TestContext.CreateWithCode code + + tryGetRefactoringActionsForSpan code (selectionOf code selected) context (new FSharpExtractLetBindingRefactoring()) + |> Seq.map _.Title + |> List.ofSeq + +[] +[] +[] +let ``Expression in a statement is bound in front of it`` (selected: string, body: string) = + let code = "module M\n\nlet area w h =\n printfn \"%d\" (w * h + 1)\n" + Assert.Equal($"module M\n\nlet area w h =\n{body}", extracted extractToLetBinding code selected) + +[] +let ``Parentheses of a method call stay`` () = + let code = + "module M\n\nlet append (sb: System.Text.StringBuilder) =\n sb.Append(1 + 2)\n" + + let expected = + "module M\n\nlet append (sb: System.Text.StringBuilder) =\n let extracted = 1 + 2\n sb.Append(extracted)\n" + + Assert.Equal(expected, extracted extractToLetBinding code "(1 + 2)") + +[] +let ``Multi-line right-hand side is bound in front of its let`` () = + let code = + "module M\n\nlet run items =\n let mutable acc = 0\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n total\n" + + let expected = + "module M\n\nlet run items =\n let mutable acc = 0\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n total\n" + + Assert.Equal(expected, extracted extractToLetBinding code "items\n |> List.filter (fun i -> i > acc)\n |> List.sum") + +[] +let ``Match clause body on the arrow line moves to its own lines`` () = + let code = + "module M\n\nlet f x offset =\n match x with\n | Some v -> v * 2 + offset\n | None -> 0\n" + + let expected = + "module M\n\nlet f x offset =\n match x with\n | Some v ->\n let extracted = v * 2 + offset\n extracted\n | None -> 0\n" + + Assert.Equal(expected, extracted extractToLetBinding code "v * 2 + offset") + +[] +let ``Right-hand side on the equals line keeps its trailing comment`` () = + let code = "module M\n\nlet r = compute a b // slow\n" + + let expected = + "module M\n\nlet r =\n let extracted = compute a b\n extracted // slow\n" + + Assert.Equal(expected, extracted extractToLetBinding code "compute a b") + +[] +let ``Expression in a computation expression is bound in front of its statement`` () = + let code = + "module M\n\nlet load id = async {\n let! raw = fetch id\n return parse raw |> List.length }\n" + + let expected = + "module M\n\nlet load id = async {\n let! raw = fetch id\n let extracted = parse raw |> List.length\n return extracted }\n" + + Assert.Equal(expected, extracted extractToLetBinding code "parse raw |> List.length") + +[] +let ``Lambda body on the arrow line moves to its own lines`` () = + let code = "module M\n\nlet r = xs |> List.map (fun x -> x + 1)\n" + + let expected = + "module M\n\nlet r = xs |> List.map (fun x ->\n let extracted = x + 1\n extracted)\n" + + Assert.Equal(expected, extracted extractToLetBinding code "x + 1") + +[] +let ``Name does not collide with an existing identifier`` () = + let code = + "module M\n\nlet extracted = 0\n\nlet f x =\n printfn \"%d\" (x + 1)\n" + + let expected = + "module M\n\nlet extracted = 0\n\nlet f x =\n let extracted1 = x + 1\n printfn \"%d\" (extracted1)\n" + + Assert.Equal(expected, extracted extractToLetBinding code "x + 1") + +[] +let ``Line breaks of the file are kept`` () = + let code = "module M\r\n\r\nlet f x =\r\n printfn \"%d\" (x + 1)\r\n" + + let expected = + "module M\r\n\r\nlet f x =\r\n let extracted = x + 1\r\n printfn \"%d\" (extracted)\r\n" + + Assert.Equal(expected, extracted extractToLetBinding code "x + 1") + +[] +let ``Lines inside a multi-line string are not re-indented`` () = + let code = + "module M\n\nlet f name =\n printfn \"%s\" (String.Format(\"\"\"Hello\n{0}\"\"\", name))\n" + + let expected = + "module M\n\nlet f name =\n let extracted =\n String.Format(\"\"\"Hello\n{0}\"\"\", name)\n printfn \"%s\" (extracted)\n" + + Assert.Equal(expected, extracted extractToLetBinding code "String.Format(\"\"\"Hello\n{0}\"\"\", name)") + +[] +let ``Constant in a module-level declaration becomes a literal in front of it`` () = + let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" + + let expected = + "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" + + Assert.Equal(expected, extracted extractToLiteral code "\"Hello %s\"") + +[] +let ``Negative number becomes a literal`` () = + let code = "module M\n\nlet f () = g -1\n" + + let expected = + "module M\n\n[]\nlet ExtractedConstant = -1\n\nlet f () = g ExtractedConstant\n" + + Assert.Equal(expected, extracted extractToLiteral code "-1") + +[] +let ``Literal is offered only for constants outside types`` () = + Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor "module M\n\nlet f () = g 42\n" "42") + Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\ntype T() =\n member _.M() = 42\n" "42") + Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\nlet f x = g (x + 1)\n" "x + 1") + +[] +[] +[] +[] +[] +[ 0 -> v\n | _ -> 0\n", "v > 0")>] +[] +[] +[] +[] +[ 0\n", "s.Length > 0")>] +[] +let ``No action`` (code: string, selected: string) = Assert.Empty(titlesFor code selected) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs index 849da8c84ec..4aad2b130d1 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs @@ -87,3 +87,30 @@ let tryGetRefactoringActions (code: string) (cursorPosition) (context: TestConte } |> CancellableTask.startWithoutCancellation |> fun task -> task.Result + +let private refactoringActionsAt (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: CodeRefactoringProvider) = + let actions = List() + let existingDocument = RoslynTestHelpers.GetLastDocument context.Solution + context.Solution <- context.Solution.WithDocumentText(existingDocument.Id, SourceText.From(code)) + let document = RoslynTestHelpers.GetLastDocument context.Solution + + let refactoringContext = + CodeRefactoringContext(document, span, (fun action -> actions.Add action), context.CancellationToken) + + refactorProvider.ComputeRefactoringsAsync(refactoringContext).GetAwaiter().GetResult() + actions + +let tryGetRefactoringActionsForSpan (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: #CodeRefactoringProvider) = + refactoringActionsAt code span context refactorProvider + +let refactorSpan (code: string) (span: TextSpan) (title: string) (context: TestContext) (refactorProvider: #CodeRefactoringProvider) = + let action = + refactoringActionsAt code span context refactorProvider + |> Seq.find (fun action -> String.Equals(action.Title, title, StringComparison.Ordinal)) + + for operation in action.GetOperationsAsync(context.CancellationToken) |> GetTaskResult do + let applyChanges = operation :?> ApplyChangesOperation + applyChanges.Apply(context.Solution.Workspace, context.CancellationToken) + context.Solution <- applyChanges.ChangedSolution + + RoslynTestHelpers.GetLastDocument context.Solution From 1442653cd2ddad6ae6e4f6b0d00793b86c29a795 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 13:23:10 +0200 Subject: [PATCH 02/31] Offer Extract to literal on a caret inside a constant, as C# does 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) --- .../Refactor/ExtractLetBinding.fs | 25 +++++---- .../Refactor/RefactoringHelpers.fs | 30 +++++++++++ .../Refactors/ExtractLetBindingTests.fs | 54 +++++++++++++++++-- 3 files changed, 96 insertions(+), 13 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 947e5c4871a..8233a79ae43 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -42,12 +42,18 @@ type internal FSharpExtractLetBindingRefactoring [] () = cancellableTask { let document = context.Document - if not (context.Span.IsEmpty || document.IsFSharpSignatureFile) then + if not document.IsFSharpSignatureFile then let! cancellationToken = CancellableTask.getCancellationToken () let! sourceText = document.GetTextAsync cancellationToken let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractLetBindingRefactoring) - match tryExtractionTarget sourceText parseResults.ParseTree context.Span with + let target = + if context.Span.IsEmpty then + tryConstantAtCaret sourceText parseResults.ParseTree context.Span.Start + else + tryExtractionTarget sourceText parseResults.ParseTree context.Span + + match target with | ValueNone -> () | ValueSome target -> let! options = document.GetOptionsAsync cancellationToken @@ -58,14 +64,15 @@ type internal FSharpExtractLetBindingRefactoring [] () = let names = usedNames parseResults.ParseTree let literalLines = linesInsideLiterals parseResults.ParseTree - match anchorsOf target.Expr target.Path with - | anchor :: _ -> - let name = uniqueName "extracted" names + if not context.Span.IsEmpty then + match anchorsOf target.Expr target.Path with + | anchor :: _ -> + let name = uniqueName "extracted" names - match tryDeclareInFront sourceText target anchor $"let {name}" name indentSize literalLines with - | ValueSome changes -> register context sourceText (SR.ExtractToLetBinding()) "let" changes - | ValueNone -> () - | [] -> () + match tryDeclareInFront sourceText target anchor $"let {name}" name indentSize literalLines with + | ValueSome changes -> register context sourceText (SR.ExtractToLetBinding()) "let" changes + | ValueNone -> () + | [] -> () let constant = match target.Expr with diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index f69c579a364..525a4a555cb 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -371,6 +371,36 @@ let isLiteralConstant (expr: SynExpr) = _) -> true | _ -> false +/// The literal constant the caret is in or touches. +let tryConstantAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = + let position = positionOf sourceText caret + + let constant = + (position, parseTree) + ||> ParsedInput.tryPickLast (fun path node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Const(range = m) as expr) when + isLiteralConstant expr + && Position.posGeq position m.Start + && Position.posGeq m.End position + && not (isInExcludedContext expr path) + -> + Some(expr, path) + | _ -> None) + + match constant with + | Some(expr, path) -> + let span = textSpanOf sourceText expr.Range + + ValueSome + { + Expr = expr + Path = path + Content = span + Replaced = span + } + | None -> ValueNone + /// The module-level let declaration containing the path, with its first binding. let tryEnclosingModuleLet (path: SyntaxVisitorPath) = path diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index e30e122c27b..bb356ad5fa5 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -17,11 +17,14 @@ let private extractToLiteral = "Extract to literal" let private selectionOf (code: string) (selected: string) = TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) -let private extracted (title: string) (code: string) (selected: string) = +let private caretAt (code: string) (marker: string) = + TextSpan(code.IndexOf(marker, StringComparison.Ordinal), 0) + +let private extractedAt (title: string) (code: string) (span: TextSpan) = use context = TestContext.CreateWithCode code let document = - refactorSpan code (selectionOf code selected) title context (new FSharpExtractLetBindingRefactoring()) + refactorSpan code span title context (new FSharpExtractLetBindingRefactoring()) let parseResults = document.GetFSharpParseResultsAsync "test" @@ -30,13 +33,19 @@ let private extracted (title: string) (code: string) (selected: string) = Assert.Empty(parseResults.Diagnostics) (document.GetTextAsync() |> GetTaskResult).ToString() -let private titlesFor (code: string) (selected: string) = +let private extracted (title: string) (code: string) (selected: string) = + extractedAt title code (selectionOf code selected) + +let private titlesAt (code: string) (span: TextSpan) = use context = TestContext.CreateWithCode code - tryGetRefactoringActionsForSpan code (selectionOf code selected) context (new FSharpExtractLetBindingRefactoring()) + tryGetRefactoringActionsForSpan code span context (new FSharpExtractLetBindingRefactoring()) |> Seq.map _.Title |> List.ofSeq +let private titlesFor (code: string) (selected: string) = + titlesAt code (selectionOf code selected) + [] [] [] @@ -64,6 +73,19 @@ let ``Multi-line right-hand side is bound in front of its let`` () = Assert.Equal(expected, extracted extractToLetBinding code "items\n |> List.filter (fun i -> i > acc)\n |> List.sum") +[] +let ``Whole lines selected with their indentation and line break are extracted`` () = + let code = + "module M\n\nlet run items =\n let mutable acc = 0\n\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n\n total\n" + + let expected = + "module M\n\nlet run items =\n let mutable acc = 0\n\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n\n total\n" + + Assert.Equal( + expected, + extracted extractToLetBinding code " items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n" + ) + [] let ``Match clause body on the arrow line moves to its own lines`` () = let code = @@ -155,6 +177,30 @@ let ``Literal is offered only for constants outside types`` () = Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\ntype T() =\n member _.M() = 42\n" "42") Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\nlet f x = g (x + 1)\n" "x + 1") +[] +[] +[] +[] +let ``Constant at the caret becomes a literal without a selection`` (marker: string) = + let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" + + let expected = + "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" + + Assert.Equal(expected, extractedAt extractToLiteral code (caretAt code marker)) + +[] +let ``Only the literal is offered without a selection`` () = + let code = "module M\n\nlet f () = g 42\n" + Assert.Equal([ extractToLiteral ], titlesAt code (caretAt code "2\n")) + +[] +[] +[] +[] +let ``No action without a selection`` (code: string, marker: string) = + Assert.Empty(titlesAt code (caretAt code marker)) + [] [] [] From 60e3e107b4e44abf7c785cd4a8b68f1cc4dd0a0d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 13:24:19 +0200 Subject: [PATCH 03/31] Mention the caret in the Extract to literal release note Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index b89e38849a4..41bb812a2a9 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -7,6 +7,7 @@ * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) * **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration. +* **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration, also with just the caret inside the constant. ### Fixed From 6e777aad3978c814b9bab492888ff02bd8c5d982 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 13:33:14 +0200 Subject: [PATCH 04/31] Extract a parenthesized lambda from a caret in its fun ... -> header 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) --- .../Refactor/ExtractLetBinding.fs | 16 ++++++++++---- .../Refactor/RefactoringHelpers.fs | 20 +++++++++++++++++ .../Refactors/ExtractLetBindingTests.fs | 22 +++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 8233a79ae43..4bc595bf49d 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -47,11 +47,19 @@ type internal FSharpExtractLetBindingRefactoring [] () = let! sourceText = document.GetTextAsync cancellationToken let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractLetBindingRefactoring) - let target = + let lambdaAtCaret = if context.Span.IsEmpty then - tryConstantAtCaret sourceText parseResults.ParseTree context.Span.Start + tryParenthesizedLambdaAtCaret sourceText parseResults.ParseTree context.Span.Start else - tryExtractionTarget sourceText parseResults.ParseTree context.Span + ValueNone + + let isSelected = not context.Span.IsEmpty || lambdaAtCaret.IsSome + + let target = + match lambdaAtCaret with + | ValueSome _ -> lambdaAtCaret + | ValueNone when context.Span.IsEmpty -> tryConstantAtCaret sourceText parseResults.ParseTree context.Span.Start + | ValueNone -> tryExtractionTarget sourceText parseResults.ParseTree context.Span match target with | ValueNone -> () @@ -64,7 +72,7 @@ type internal FSharpExtractLetBindingRefactoring [] () = let names = usedNames parseResults.ParseTree let literalLines = linesInsideLiterals parseResults.ParseTree - if not context.Span.IsEmpty then + if isSelected then match anchorsOf target.Expr target.Path with | anchor :: _ -> let name = uniqueName "extracted" names diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index 525a4a555cb..b6cd11425cf 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -401,6 +401,26 @@ let tryConstantAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: } | None -> ValueNone +/// The parenthesized lambda whose `fun ->` the caret is in, as if it were selected with its parentheses. +let tryParenthesizedLambdaAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = + let position = positionOf sourceText caret + + let parenthesized = + (position, parseTree) + ||> ParsedInput.tryPickLast (fun _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Paren( + expr = SynExpr.Lambda(parsedData = Some _; trivia = { ArrowRange = Some arrow }) as lambda; range = m)) when + Position.posGeq position lambda.Range.Start + && Position.posGeq arrow.End position + -> + Some m + | _ -> None) + + match parenthesized with + | Some m -> tryExtractionTarget sourceText parseTree (textSpanOf sourceText m) + | None -> ValueNone + /// The module-level let declaration containing the path, with its first binding. let tryEnclosingModuleLet (path: SyntaxVisitorPath) = path diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index bb356ad5fa5..7974f2ba4b8 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -177,6 +177,28 @@ let ``Literal is offered only for constants outside types`` () = Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\ntype T() =\n member _.M() = 42\n" "42") Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\nlet f x = g (x + 1)\n" "x + 1") +[] +[] +[")>] +[ x")>] +[] +let ``Caret in the header of a parenthesized lambda extracts the lambda`` (marker: string) = + let code = "module M\n\nlet f xs =\n xs |> List.map (fun x -> x + 1)\n" + + let expected = + "module M\n\nlet f xs =\n let extracted = fun x -> x + 1\n xs |> List.map extracted\n" + + Assert.Equal([ extractToLetBinding ], titlesAt code (caretAt code marker)) + Assert.Equal(expected, extractedAt extractToLetBinding code (caretAt code marker)) + +[] +[ List.map (fun x -> x + 1)\n", "x + 1)")>] +[ List.map (fun x -> x + 1)\n", "+ 1)")>] +[ List.map (fun x -> x + 1)\n", "(fun")>] +[ x + 1\n", "fun")>] +let ``No action without a selection outside a parenthesized lambda header`` (code: string, marker: string) = + Assert.Empty(titlesAt code (caretAt code marker)) + [] [] [] From 628e9bb4452ce1be18cacbdefa695c243ad64829 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 15:30:50 +0200 Subject: [PATCH 05/31] Write the refactoring test code as multi-line strings Co-Authored-By: Claude Opus 5 (1M context) --- .../Refactors/ExtractLetBindingTests.fs | 452 +++++++++++++++--- 1 file changed, 377 insertions(+), 75 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index 7974f2ba4b8..50078b08db0 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -46,91 +46,246 @@ let private titlesAt (code: string) (span: TextSpan) = let private titlesFor (code: string) (selected: string) = titlesAt code (selectionOf code selected) +let private area = + """ +module M + +let area w h = + printfn "%d" (w * h + 1) +""" + [] -[] -[] -let ``Expression in a statement is bound in front of it`` (selected: string, body: string) = - let code = "module M\n\nlet area w h =\n printfn \"%d\" (w * h + 1)\n" - Assert.Equal($"module M\n\nlet area w h =\n{body}", extracted extractToLetBinding code selected) +[] +[] +let ``Expression in a statement is bound in front of it`` (selected: string, expected: string) = + Assert.Equal(expected, extracted extractToLetBinding area selected) [] let ``Parentheses of a method call stay`` () = let code = - "module M\n\nlet append (sb: System.Text.StringBuilder) =\n sb.Append(1 + 2)\n" + """ +module M + +let append (sb: System.Text.StringBuilder) = + sb.Append(1 + 2) +""" let expected = - "module M\n\nlet append (sb: System.Text.StringBuilder) =\n let extracted = 1 + 2\n sb.Append(extracted)\n" + """ +module M + +let append (sb: System.Text.StringBuilder) = + let extracted = 1 + 2 + sb.Append(extracted) +""" Assert.Equal(expected, extracted extractToLetBinding code "(1 + 2)") +let private run = + """ +module M + +let run items = + let mutable acc = 0 + let total = + items + |> List.filter (fun i -> i > acc) + |> List.sum + total +""" + [] let ``Multi-line right-hand side is bound in front of its let`` () = - let code = - "module M\n\nlet run items =\n let mutable acc = 0\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n total\n" - let expected = - "module M\n\nlet run items =\n let mutable acc = 0\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n total\n" + """ +module M - Assert.Equal(expected, extracted extractToLetBinding code "items\n |> List.filter (fun i -> i > acc)\n |> List.sum") +let run items = + let mutable acc = 0 + let extracted = + items + |> List.filter (fun i -> i > acc) + |> List.sum + let total = + extracted + total +""" + + let start = run.LastIndexOf("items", StringComparison.Ordinal) + + let finish = + run.IndexOf("|> List.sum", start, StringComparison.Ordinal) + + "|> List.sum".Length + + Assert.Equal(expected, extractedAt extractToLetBinding run (TextSpan.FromBounds(start, finish))) [] let ``Whole lines selected with their indentation and line break are extracted`` () = let code = - "module M\n\nlet run items =\n let mutable acc = 0\n\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n\n total\n" + """ +module M + +let run items = + let mutable acc = 0 + + let total = + items + |> List.filter (fun i -> i > acc) + |> List.sum + + total +""" let expected = - "module M\n\nlet run items =\n let mutable acc = 0\n\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n\n total\n" + """ +module M + +let run items = + let mutable acc = 0 + + let extracted = + items + |> List.filter (fun i -> i > acc) + |> List.sum + let total = + extracted - Assert.Equal( - expected, - extracted extractToLetBinding code " items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n" - ) + total +""" + + let lineStart = code.LastIndexOf(" items", StringComparison.Ordinal) + + let afterLineBreak = + code.IndexOf('\n', code.IndexOf("|> List.sum", lineStart, StringComparison.Ordinal)) + + 1 + + Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan.FromBounds(lineStart, afterLineBreak))) [] let ``Match clause body on the arrow line moves to its own lines`` () = let code = - "module M\n\nlet f x offset =\n match x with\n | Some v -> v * 2 + offset\n | None -> 0\n" + """ +module M + +let f x offset = + match x with + | Some v -> v * 2 + offset + | None -> 0 +""" let expected = - "module M\n\nlet f x offset =\n match x with\n | Some v ->\n let extracted = v * 2 + offset\n extracted\n | None -> 0\n" + """ +module M + +let f x offset = + match x with + | Some v -> + let extracted = v * 2 + offset + extracted + | None -> 0 +""" Assert.Equal(expected, extracted extractToLetBinding code "v * 2 + offset") [] let ``Right-hand side on the equals line keeps its trailing comment`` () = - let code = "module M\n\nlet r = compute a b // slow\n" + let code = + """ +module M + +let r = compute a b // slow +""" let expected = - "module M\n\nlet r =\n let extracted = compute a b\n extracted // slow\n" + """ +module M + +let r = + let extracted = compute a b + extracted // slow +""" Assert.Equal(expected, extracted extractToLetBinding code "compute a b") [] let ``Expression in a computation expression is bound in front of its statement`` () = let code = - "module M\n\nlet load id = async {\n let! raw = fetch id\n return parse raw |> List.length }\n" + """ +module M + +let load id = async { + let! raw = fetch id + return parse raw |> List.length } +""" let expected = - "module M\n\nlet load id = async {\n let! raw = fetch id\n let extracted = parse raw |> List.length\n return extracted }\n" + """ +module M + +let load id = async { + let! raw = fetch id + let extracted = parse raw |> List.length + return extracted } +""" Assert.Equal(expected, extracted extractToLetBinding code "parse raw |> List.length") [] let ``Lambda body on the arrow line moves to its own lines`` () = - let code = "module M\n\nlet r = xs |> List.map (fun x -> x + 1)\n" + let code = + """ +module M + +let r = xs |> List.map (fun x -> x + 1) +""" let expected = - "module M\n\nlet r = xs |> List.map (fun x ->\n let extracted = x + 1\n extracted)\n" + """ +module M + +let r = xs |> List.map (fun x -> + let extracted = x + 1 + extracted) +""" Assert.Equal(expected, extracted extractToLetBinding code "x + 1") [] let ``Name does not collide with an existing identifier`` () = let code = - "module M\n\nlet extracted = 0\n\nlet f x =\n printfn \"%d\" (x + 1)\n" + """ +module M + +let extracted = 0 + +let f x = + printfn "%d" (x + 1) +""" let expected = - "module M\n\nlet extracted = 0\n\nlet f x =\n let extracted1 = x + 1\n printfn \"%d\" (extracted1)\n" + """ +module M + +let extracted = 0 + +let f x = + let extracted1 = x + 1 + printfn "%d" (extracted1) +""" Assert.Equal(expected, extracted extractToLetBinding code "x + 1") @@ -145,6 +300,7 @@ let ``Line breaks of the file are kept`` () = [] let ``Lines inside a multi-line string are not re-indented`` () = + // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. let code = "module M\n\nlet f name =\n printfn \"%s\" (String.Format(\"\"\"Hello\n{0}\"\"\", name))\n" @@ -153,29 +309,85 @@ let ``Lines inside a multi-line string are not re-indented`` () = Assert.Equal(expected, extracted extractToLetBinding code "String.Format(\"\"\"Hello\n{0}\"\"\", name)") -[] -let ``Constant in a module-level declaration becomes a literal in front of it`` () = - let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" +let private greet = + """ +module M - let expected = - "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" +let greet name = + printfn "Hello %s" name +""" - Assert.Equal(expected, extracted extractToLiteral code "\"Hello %s\"") +let private greetWithLiteral = + """ +module M + +[] +let ExtractedConstant = "Hello %s" + +let greet name = + printfn ExtractedConstant name +""" + +[] +let ``Constant in a module-level declaration becomes a literal in front of it`` () = + Assert.Equal(greetWithLiteral, extracted extractToLiteral greet "\"Hello %s\"") [] let ``Negative number becomes a literal`` () = - let code = "module M\n\nlet f () = g -1\n" + let code = + """ +module M + +let f () = g -1 +""" let expected = - "module M\n\n[]\nlet ExtractedConstant = -1\n\nlet f () = g ExtractedConstant\n" + """ +module M + +[] +let ExtractedConstant = -1 + +let f () = g ExtractedConstant +""" Assert.Equal(expected, extracted extractToLiteral code "-1") +let private moduleConstant = + """ +module M + +let f () = g 42 +""" + [] let ``Literal is offered only for constants outside types`` () = - Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor "module M\n\nlet f () = g 42\n" "42") - Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\ntype T() =\n member _.M() = 42\n" "42") - Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\nlet f x = g (x + 1)\n" "x + 1") + let memberConstant = + """ +module M + +type T() = + member _.M() = 42 +""" + + let expression = + """ +module M + +let f x = g (x + 1) +""" + + Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor moduleConstant "42") + Assert.Equal([ extractToLetBinding ], titlesFor memberConstant "42") + Assert.Equal([ extractToLetBinding ], titlesFor expression "x + 1") + +let private mapIncrement = + """ +module M + +let f xs = + xs |> List.map (fun x -> x + 1) +""" [] [] @@ -183,56 +395,146 @@ let ``Literal is offered only for constants outside types`` () = [ x")>] [] let ``Caret in the header of a parenthesized lambda extracts the lambda`` (marker: string) = - let code = "module M\n\nlet f xs =\n xs |> List.map (fun x -> x + 1)\n" - let expected = - "module M\n\nlet f xs =\n let extracted = fun x -> x + 1\n xs |> List.map extracted\n" + """ +module M - Assert.Equal([ extractToLetBinding ], titlesAt code (caretAt code marker)) - Assert.Equal(expected, extractedAt extractToLetBinding code (caretAt code marker)) +let f xs = + let extracted = fun x -> x + 1 + xs |> List.map extracted +""" -[] -[ List.map (fun x -> x + 1)\n", "x + 1)")>] -[ List.map (fun x -> x + 1)\n", "+ 1)")>] -[ List.map (fun x -> x + 1)\n", "(fun")>] -[ x + 1\n", "fun")>] -let ``No action without a selection outside a parenthesized lambda header`` (code: string, marker: string) = - Assert.Empty(titlesAt code (caretAt code marker)) + Assert.Equal([ extractToLetBinding ], titlesAt mapIncrement (caretAt mapIncrement marker)) + Assert.Equal(expected, extractedAt extractToLetBinding mapIncrement (caretAt mapIncrement marker)) [] -[] -[] -[] -let ``Constant at the caret becomes a literal without a selection`` (marker: string) = - let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" +[] +[] +[] +let ``No action without a selection outside a parenthesized lambda header`` (marker: string) = + Assert.Empty(titlesAt mapIncrement (caretAt mapIncrement marker)) - let expected = - "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" +[] +let ``No action without a selection on a lambda without parentheses`` () = + let code = + """ +module M + +let f = fun x -> x + 1 +""" - Assert.Equal(expected, extractedAt extractToLiteral code (caretAt code marker)) + Assert.Empty(titlesAt code (caretAt code "fun")) + +[] +[] +[] +[] +let ``Constant at the caret becomes a literal without a selection`` (marker: string, offset: int) = + let caret = TextSpan(greet.IndexOf(marker, StringComparison.Ordinal) + offset, 0) + Assert.Equal(greetWithLiteral, extractedAt extractToLiteral greet caret) [] let ``Only the literal is offered without a selection`` () = - let code = "module M\n\nlet f () = g 42\n" - Assert.Equal([ extractToLiteral ], titlesAt code (caretAt code "2\n")) + let caret = TextSpan(moduleConstant.IndexOf("42", StringComparison.Ordinal) + 1, 0) + + Assert.Equal([ extractToLiteral ], titlesAt moduleConstant caret) [] -[] -[] -[] +[] +[] +[] let ``No action without a selection`` (code: string, marker: string) = Assert.Empty(titlesAt code (caretAt code marker)) [] -[] -[] -[] -[] -[ 0 -> v\n | _ -> 0\n", "v > 0")>] -[] -[] -[] -[] -[ 0\n", "s.Length > 0")>] -[] +[] +[] +[] +[] +[ 0 -> v + | _ -> 0 +""", + "v > 0")>] +[] +[] +[] +[] +[ 0 +""", + "s.Length > 0")>] +[] let ``No action`` (code: string, selected: string) = Assert.Empty(titlesFor code selected) From f597b3dc854677e8730e8b29585099720545e752 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 21:36:07 +0200 Subject: [PATCH 06/31] Link the release note to the pull request and move it to a random line of its section Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 41bb812a2a9..514a77b782f 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,6 +2,7 @@ * Suggest active-pattern cases from unopened modules and offer valid Add Open and qualification edits. ([PR #20719](https://github.com/dotnet/fsharp/pull/20719)) * Add maintainer-requested F# Apex Visual Studio integration tests on DartLab-1ES CloudTest, using PR pipeline definitions pinned to an explicitly requested commit SHA. ([PR #20625](https://github.com/dotnet/fsharp/pull/20625)) +* **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration, also with just the caret inside the constant. ([PR #20537](https://github.com/dotnet/fsharp/pull/20537)) * Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880)) * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) From bbbc71e569be5b754ab67e75271a04bcf53cc946 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 29 Sep 2026 19:21:29 +0200 Subject: [PATCH 07/31] Keep assignment targets and inactive string lines out of the extraction'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 --- .../Refactor/ExtractLetBinding.fs | 5 +- .../Refactor/RefactoringHelpers.fs | 105 ++++++++++++++++-- .../Refactors/ExtractLetBindingTests.fs | 56 ++++++++++ .../Refactors/RefactorTestFramework.fs | 20 +++- 4 files changed, 169 insertions(+), 17 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 4bc595bf49d..13dca45a187 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -69,8 +69,11 @@ type internal FSharpExtractLetBindingRefactoring [] () = let indentSize = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) + let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpExtractLetBindingRefactoring) let names = usedNames parseResults.ParseTree - let literalLines = linesInsideLiterals parseResults.ParseTree + + let literalLines = + linesInsideLiterals sourceText parseResults.ParseTree defines langVersion if isSelected then match anchorsOf target.Expr target.Path with diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index b6cd11425cf..fc0ba48abb3 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -11,6 +11,7 @@ open Microsoft.CodeAnalysis.Text open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTrivia open FSharp.Compiler.Text +open FSharp.Compiler.Tokenization /// A place in front of which a declaration used by the selected expression can be inserted. [] @@ -76,18 +77,98 @@ let restOfLineIsClosers (sourceText: SourceText) (position: pos) = code |> Seq.forall (fun c -> Char.IsWhiteSpace c || c = ')' || c = ']' || c = '}' || c = '|') -let linesInsideLiterals (parseTree: ParsedInput) = - (HashSet(), parseTree) - ||> ParsedInput.fold (fun lines _ node -> - match node with - | SyntaxNode.SynExpr(SynExpr.Const(range = m)) - | SyntaxNode.SynExpr(SynExpr.InterpolatedString(range = m)) - | SyntaxNode.SynPat(SynPat.Const(range = m)) -> - for line in m.StartLine + 1 .. m.EndLine do - lines.Add(Line.toZ line) |> ignore - | _ -> () +/// Whether a line lexed from this state starts inside a string literal. +let private continuesString (state: FSharpTokenizerColorState) = + match state with + | FSharpTokenizerColorState.InitialState + | FSharpTokenizerColorState.Token + | FSharpTokenizerColorState.IfDefSkip + | FSharpTokenizerColorState.EndLineThenSkip + | FSharpTokenizerColorState.EndLineThenToken + | FSharpTokenizerColorState.Comment + | FSharpTokenizerColorState.SingleLineComment + | FSharpTokenizerColorState.StringInComment + | FSharpTokenizerColorState.VerbatimStringInComment + | FSharpTokenizerColorState.TripleQuoteStringInComment -> false + // String, VerbatimString, TripleQuoteString and the extended interpolated string state, which the public enum does not name. + | _ -> true + +let private isSkipped (state: FSharpTokenizerLexState) = + match FSharpLineTokenizer.ColorStateOfLexState state with + | FSharpTokenizerColorState.IfDefSkip + | FSharpTokenizerColorState.EndLineThenSkip -> true + | _ -> false + +let private startsWithDirective (code: ReadOnlySpan) (keyword: string) = + code.StartsWith(keyword.AsSpan(), StringComparison.Ordinal) + && (code.Length = keyword.Length + || Char.IsWhiteSpace code[keyword.Length] + || code[keyword.Length] = '/') + +/// Whether the line is a directive the lexer recognizes while it skips an inactive branch. +let private isSkippedDirective (line: string) = + let code = line.AsSpan().TrimStart() + + startsWithDirective code "#if" + || startsWithDirective code "#elif" + || startsWithDirective code "#else" + || startsWithDirective code "#endif" + +let private scanLine (tokenizer: FSharpSourceTokenizer) (line: string) (state: FSharpTokenizerLexState) = + let lineTokenizer = tokenizer.CreateLineTokenizer line + + let rec loop state = + match lineTokenizer.ScanToken state with + | Some _, next -> loop next + | None, next -> next + + loop state + +/// Lines inside multi-line string literals of the branches of `#if` the defines leave inactive. Each run of inactive +/// lines between two directives is lexed as the code it is when its branch is taken. +let private linesInsideInactiveLiterals (sourceText: SourceText) (defines: string list) (langVersion: string) (lines: HashSet) = + let fileTokenizer = FSharpSourceTokenizer(defines, None, Some langVersion) + let branchTokenizer = FSharpSourceTokenizer([], None, Some langVersion) + let mutable fileState = FSharpTokenizerLexState.Initial + let mutable branchState = FSharpTokenizerLexState.Initial + + for line in sourceText.Lines do + let text = line.ToString() + + if isSkipped fileState && not (isSkippedDirective text) then + if continuesString (FSharpLineTokenizer.ColorStateOfLexState branchState) then + lines.Add line.LineNumber |> ignore + + branchState <- scanLine branchTokenizer text branchState + else + branchState <- FSharpTokenizerLexState.Initial + + fileState <- scanLine fileTokenizer text fileState + +/// Lines inside multi-line string literals, including those in branches of `#if` the defines leave inactive. +let linesInsideLiterals (sourceText: SourceText) (parseTree: ParsedInput) (defines: string list) (langVersion: string) = + let lines = + (HashSet(), parseTree) + ||> ParsedInput.fold (fun lines _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Const(range = m)) + | SyntaxNode.SynExpr(SynExpr.InterpolatedString(range = m)) + | SyntaxNode.SynPat(SynPat.Const(range = m)) -> + for line in m.StartLine + 1 .. m.EndLine do + lines.Add(Line.toZ line) |> ignore + | _ -> () + + lines) + + let directives = + match parseTree with + | ParsedInput.ImplFile file -> file.Trivia.ConditionalDirectives + | ParsedInput.SigFile file -> file.Trivia.ConditionalDirectives + + if not directives.IsEmpty then + linesInsideInactiveLiterals sourceText defines langVersion lines - lines) + lines let usedNames (parseTree: ParsedInput) = (HashSet(StringComparer.Ordinal), parseTree) @@ -95,6 +176,8 @@ let usedNames (parseTree: ParsedInput) = match node with | SyntaxNode.SynExpr(SynExpr.Ident ident) | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _))) + | SyntaxNode.SynExpr(SynExpr.LongIdentSet(longDotId = SynLongIdent(id = ident :: _))) + | SyntaxNode.SynExpr(SynExpr.NamedIndexedPropertySet(longDotId = SynLongIdent(id = ident :: _))) | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = ident :: _))) -> names.Add ident.idText |> ignore | _ -> () diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index 50078b08db0..536a77ce202 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -309,6 +309,62 @@ let ``Lines inside a multi-line string are not re-indented`` () = Assert.Equal(expected, extracted extractToLetBinding code "String.Format(\"\"\"Hello\n{0}\"\"\", name)") +[] +let ``Lines inside a multi-line string in an inactive branch are not re-indented`` () = + // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. + let code = + "module M\n\nlet r = combine 1 [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" + + let expected = + "module M\n\nlet r =\n let extracted = 1\n combine extracted [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" + + Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan(code.IndexOf("1 [", StringComparison.Ordinal), 1))) + +[] +[] +[] +[] +let ``Name does not collide with an assignment target from another file`` (declaration: string, assignment: string) = + let dependency = + """ +module Dependency + +type Box = { mutable Value: int } + +DECLARATION +""" + .Replace("DECLARATION", declaration) + + let code = + """ +module M + +open Dependency + +let f () = + ASSIGNMENT({ Value = 1 }).Value +""" + .Replace("ASSIGNMENT", assignment) + + let expected = + """ +module M + +open Dependency + +let f () = + let extracted1 = { Value = 1 } + ASSIGNMENTextracted1.Value +""" + .Replace("ASSIGNMENT", assignment) + + use context = TestContext.CreateWithCodeAndDependency code dependency + + let document = + refactorSpan code (selectionOf code "({ Value = 1 })") extractToLetBinding context (new FSharpExtractLetBindingRefactoring()) + + Assert.Equal(expected, (document.GetTextAsync() |> GetTaskResult).ToString()) + let private greet = """ module M diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs index 4aad2b130d1..cd385130efe 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs @@ -8,6 +8,8 @@ open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks +open FSharp.Test.ProjectGeneration + open FSharp.Editor.Tests.Helpers open Microsoft.CodeAnalysis.CodeRefactorings open Microsoft.CodeAnalysis.CodeActions @@ -31,11 +33,19 @@ type TestContext(Solution: Solution) = new TestContext(solution) static member CreateWithCodeAndDependency (code: string) (codeForPreviousFile: string) = - let mutable solution = RoslynTestHelpers.CreateSolution(codeForPreviousFile) - - let firstProject = solution.Projects.First() - solution <- solution.AddDocument(DocumentId.CreateNewId(firstProject.Id), "test2.fs", code, filePath = "C:\\test2.fs") - + let project = + { SyntheticProject.Create( + { sourceFile "First" [] with + Source = codeForPreviousFile + }, + { sourceFile "Second" [ "First" ] with + Source = code + } + ) with + AutoAddModules = false + } + + let solution, _ = RoslynTestHelpers.CreateSolution project new TestContext(solution) let tryRefactor (code: string) (cursorPosition) (context: TestContext) (refactorProvider: 'T :> CodeRefactoringProvider) = From 2e634c46283b6dccae56eb2dc63a1d19f5478153 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 02:19:56 +0200 Subject: [PATCH 08/31] Close the gaps reviewers found in Extract to let binding and Extract 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 --- .../Refactor/ExtractLetBinding.fs | 29 +- .../Refactor/RefactoringHelpers.fs | 92 +++++-- .../Refactors/ExtractLetBindingTests.fs | 257 +++++++++++++++++- 3 files changed, 348 insertions(+), 30 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 13dca45a187..257a72b3199 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -70,31 +70,44 @@ type internal FSharpExtractLetBindingRefactoring [] () = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpExtractLetBindingRefactoring) + let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof FSharpExtractLetBindingRefactoring) let names = usedNames parseResults.ParseTree let literalLines = linesInsideLiterals sourceText parseResults.ParseTree defines langVersion + let content = + match target.Expr with + | SynExpr.Paren(expr = inner) -> inner + | expr -> expr + if isSelected then match anchorsOf target.Expr target.Path with | anchor :: _ -> let name = uniqueName "extracted" names - match tryDeclareInFront sourceText target anchor $"let {name}" name indentSize literalLines with + let functionType = + match content with + | SynExpr.Lambda _ + | SynExpr.MatchLambda _ -> tryTypeText checkResults target.Expr.Range + | _ -> ValueNone + + let header = + match functionType with + | ValueSome functionType -> $"let {name}: {functionType}" + | ValueNone -> $"let {name}" + + match tryDeclareInFront sourceText target anchor header name indentSize literalLines with | ValueSome changes -> register context sourceText (SR.ExtractToLetBinding()) "let" changes | ValueNone -> () | [] -> () - let constant = - match target.Expr with - | SynExpr.Paren(expr = inner) -> inner - | expr -> expr - - if isLiteralConstant constant then + if isLiteralConstant content then let name = uniqueName "ExtractedConstant" names + let attribute = literalAttributeAt checkResults target.Expr.Range.Start match - tryDeclareInFrontOfModuleLet sourceText target [ "[]" ] $"let {name}" name indentSize literalLines + tryDeclareInFrontOfModuleLet sourceText target [ $"[<{attribute}>]" ] $"let {name}" name indentSize literalLines with | ValueSome changes -> register context sourceText (SR.ExtractToLiteral()) "literal" changes | ValueNone -> () diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index fc0ba48abb3..174099bc0e5 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -8,6 +8,8 @@ open System.Text open Microsoft.CodeAnalysis.Text +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTrivia open FSharp.Compiler.Text @@ -18,8 +20,9 @@ open FSharp.Compiler.Tokenization type Anchor = /// An expression that is a statement of its block. | Statement of start: pos - /// The body of a binding, match clause, branch or lambda, after the keyword ending at keywordEnd. - | Clause of keywordEnd: pos * body: range + /// The body of a binding, match clause, branch or lambda, after the keyword ending at keywordEnd. No line of the + /// body can start left of column limit, where the construct that sets its offside line starts. + | Clause of keywordEnd: pos * body: range * limit: int [] type ExtractionTarget = @@ -178,8 +181,10 @@ let usedNames (parseTree: ParsedInput) = | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _))) | SyntaxNode.SynExpr(SynExpr.LongIdentSet(longDotId = SynLongIdent(id = ident :: _))) | SyntaxNode.SynExpr(SynExpr.NamedIndexedPropertySet(longDotId = SynLongIdent(id = ident :: _))) - | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) - | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = ident :: _))) -> names.Add ident.idText |> ignore + | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) -> names.Add ident.idText |> ignore + | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = idents))) -> + for ident in idents do + names.Add ident.idText |> ignore | _ -> () names) @@ -263,7 +268,7 @@ let private isMethodArgument (path: SyntaxVisitorPath) = | SyntaxNode.SynExpr(SynExpr.Tuple _) :: SyntaxNode.SynExpr(SynExpr.Paren _) :: call -> isCall call | _ -> false -let private isExtractableShape (expr: SynExpr) (path: SyntaxVisitorPath) = +let rec private isExtractableShape (expr: SynExpr) (path: SyntaxVisitorPath) = match expr with | SynExpr.App(isInfix = true) | SynExpr.Ident _ @@ -296,6 +301,7 @@ let private isExtractableShape (expr: SynExpr) (path: SyntaxVisitorPath) = | SynExpr.DotIndexedSet _ | SynExpr.NamedIndexedPropertySet _ | SynExpr.DotNamedIndexedPropertySet _ -> false + | SynExpr.Paren(expr = inner) -> isExtractableShape inner (SyntaxNode.SynExpr expr :: path) | SynExpr.LetOrUse letOrUse -> not letOrUse.IsBang | SynExpr.Tuple _ -> not (isMethodArgument path) | SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true; funcExpr = equals; argExpr = SynExpr.Ident _)) when @@ -308,11 +314,15 @@ let private isInExcludedContext (expr: SynExpr) (path: SyntaxVisitorPath) = let rec loop (child: SyntaxNode) (path: SyntaxVisitorPath) = match path, child with | [], _ -> false - | SyntaxNode.SynExpr(SynExpr.InterpolatedString _ | SynExpr.Quote _ | SynExpr.Lazy _) :: _, _ -> true + | SyntaxNode.SynExpr(SynExpr.InterpolatedString _ | SynExpr.Quote _ | SynExpr.Lazy _ | SynExpr.Assert _) :: _, _ -> true | SyntaxNode.SynExpr(SynExpr.While(whileExpr = condition) | SynExpr.WhileBang(whileExpr = condition)) :: _, SyntaxNode.SynExpr expr when isSame condition expr -> true + | SyntaxNode.SynExpr(SynExpr.App(funcExpr = SynExpr.Ident operator; argExpr = operand)) :: _, SyntaxNode.SynExpr expr when + hasName "nameof" operator && isSame operand expr + -> + true | SyntaxNode.SynMatchClause(SynMatchClause(whenExpr = Some guard)) :: _, SyntaxNode.SynExpr expr when isSame guard expr -> true | SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true; funcExpr = operator); argExpr = right)) :: _, SyntaxNode.SynExpr expr when @@ -401,7 +411,7 @@ let private isFunctionBinding (binding: SynBinding) = | SynBinding(headPat = SynPat.LongIdent(argPats = SynArgPats.Pats(_ :: _))) -> true | _ -> false -let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: SyntaxNode voption) = +let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: SyntaxNode voption) (limit: int) = match parent, child with | SyntaxNode.SynExpr(SynExpr.Sequential _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse), SyntaxNode.SynExpr expr when isSame letOrUse.Body expr -> @@ -409,28 +419,44 @@ let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: Synt | 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)), SyntaxNode.SynExpr expr when isSame body expr -> ValueSome(Anchor.Statement expr.Range.Start) + | SyntaxNode.SynExpr(SynExpr.TryFinally(finallyExpr = body)), SyntaxNode.SynExpr expr when isSame body expr -> + ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynModule(SynModuleDecl.Expr _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynBinding(SynBinding(expr = rhs; trivia = trivia) as binding), SyntaxNode.SynExpr expr when isSame rhs expr -> match grandparent, trivia.EqualsRange with | ValueSome(SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse)), _ when not letOrUse.IsRecursive && not (isFunctionBinding binding) -> ValueSome(Anchor.Statement letOrUse.Range.Start) - | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range)) + | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range, limit)) | _ -> ValueNone | SyntaxNode.SynMatchClause(SynMatchClause(resultExpr = result; trivia = trivia)), SyntaxNode.SynExpr expr when isSame result expr -> match trivia.ArrowRange with - | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range)) + | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range, limit)) | None -> ValueNone | SyntaxNode.SynExpr(SynExpr.IfThenElse(thenExpr = thenExpr; elseExpr = elseExpr; trivia = trivia)), SyntaxNode.SynExpr expr -> match elseExpr, trivia.ElseKeyword with - | _ when isSame thenExpr expr -> ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range)) - | Some elseExpr, Some elseKeyword when isSame elseExpr expr -> ValueSome(Anchor.Clause(elseKeyword.End, expr.Range)) + | _ when isSame thenExpr expr -> ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range, limit)) + | Some elseExpr, Some elseKeyword when isSame elseExpr expr -> ValueSome(Anchor.Clause(elseKeyword.End, expr.Range, limit)) | _ -> ValueNone | SyntaxNode.SynExpr(SynExpr.Lambda(parsedData = Some(_, body); trivia = trivia)), SyntaxNode.SynExpr expr when isSame body expr -> match trivia.ArrowRange with - | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range)) + | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range, limit)) | None -> ValueNone | _ -> ValueNone +/// The column where the innermost `let`, `match`, `try`, `if` or loop of the path starts. An `if` after `else` continues +/// the `if` it belongs to. +let rec private limitColumn (path: SyntaxVisitorPath) = + match path with + | [] -> 0 + | SyntaxNode.SynExpr(SynExpr.IfThenElse _ as nested) :: (SyntaxNode.SynExpr(SynExpr.IfThenElse(elseExpr = Some elseExpr)) :: _ as rest) when + isSame elseExpr nested + -> + limitColumn rest + | SyntaxNode.SynBinding(SynBinding(trivia = trivia)) :: _ -> trivia.LeadingKeyword.Range.StartColumn + | SyntaxNode.SynExpr(SynExpr.Match(range = m) | SynExpr.MatchBang(range = m) | SynExpr.TryWith(range = m) | SynExpr.TryFinally(range = m) | SynExpr.IfThenElse( + range = m) | SynExpr.For(range = m) | SynExpr.ForEach(range = m) | SynExpr.While(range = m)) :: _ -> m.StartColumn + | _ :: rest -> limitColumn rest + /// The places a declaration used by the expression can go, innermost first. let anchorsOf (expr: SynExpr) (path: SyntaxVisitorPath) = let rec loop (child: SyntaxNode) (path: SyntaxVisitorPath) = @@ -442,15 +468,40 @@ let anchorsOf (expr: SynExpr) (path: SyntaxVisitorPath) = | node :: _ -> ValueSome node | [] -> ValueNone - match anchorOf child parent grandparent with + match anchorOf child parent grandparent (limitColumn path) with | ValueSome anchor -> anchor :: loop parent rest | ValueNone -> loop parent rest loop (SyntaxNode.SynExpr expr) path +let rec private hasInferredTypeVariable (ty: FSharpType) = + if ty.IsGenericParameter then + ty.GenericParameter.IsCompilerGenerated + else + not ty.IsMeasureType + && ty.GenericArguments |> Seq.exists hasInferredTypeVariable + +/// The type the checker gave the expression at the range, as the source can write it. Unavailable when the type has a +/// variable that inference introduced, which has no name in the source. +let tryTypeText (checkResults: FSharpCheckFileResults) (m: range) = + match checkResults.TryGetCapturedType m, checkResults.TryGetCapturedDisplayContext m with + | Some ty, Some displayContext when not (hasInferredTypeVariable ty) -> ValueSome(ty.Format displayContext) + | _ -> ValueNone + +/// `Literal`, with its namespace where the name alone does not reach the FSharp.Core attribute from the position. +let literalAttributeAt (checkResults: FSharpCheckFileResults) (position: pos) = + let attribute = + checkResults.ProjectContext.GetReferencedAssemblies() + |> List.tryPick _.Contents.FindEntityByPath([ "Microsoft"; "FSharp"; "Core"; "LiteralAttribute" ]) + + match attribute with + | Some attribute when not (checkResults.IsRelativeNameResolvableFromSymbol(position, [], attribute)) -> + "global.Microsoft.FSharp.Core.Literal" + | _ -> "Literal" + let isLiteralConstant (expr: SynExpr) = match expr with - | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.IntPtr _ | SynConst.UIntPtr _ | SynConst.Single _ | SynConst.Double _ | SynConst.Char _ | SynConst.String _), + | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.Single _ | SynConst.Double _ | SynConst.Decimal _ | SynConst.Char _ | SynConst.String _), _) -> true | _ -> false @@ -552,12 +603,12 @@ let tryDeclareInFront match anchor with | Anchor.Statement start when isLineLeading sourceText start -> inFrontOfLine start - | Anchor.Clause(_, body) when isLineLeading sourceText body.Start -> inFrontOfLine body.Start - | Anchor.Clause(keywordEnd, body) when keywordEnd.Line = body.StartLine && restOfLineIsClosers sourceText body.End -> + | Anchor.Clause(body = body) when isLineLeading sourceText body.Start -> inFrontOfLine body.Start + | Anchor.Clause(keywordEnd, body, limit) when keywordEnd.Line = body.StartLine && restOfLineIsClosers sourceText body.End -> let keywordLine = lines[Line.toZ keywordEnd.Line] let keywordEndOffset = keywordLine.Start + keywordEnd.Column let bodySpan = textSpanOf sourceText body - let newIndent = leadingSpaces sourceText keywordLine + indentSize + let newIndent = max (leadingSpaces sourceText keywordLine) limit + indentSize let shift = newIndent - body.StartColumn let continuation = @@ -604,7 +655,8 @@ let tryDeclareInFront | _ -> ValueNone /// Changes that declare `header = `, preceded by the attribute lines, in front of the module-level let -/// containing the selection, and put replacement where the selection was. +/// containing the selection, and put replacement where the selection was. Unavailable when that let does not start its +/// line, as in `module M = let f () = 1`. let tryDeclareInFrontOfModuleLet (sourceText: SourceText) (target: ExtractionTarget) @@ -615,7 +667,7 @@ let tryDeclareInFrontOfModuleLet (literalLines: HashSet) = match tryEnclosingModuleLet target.Path with - | Some(struct (SynBinding(xmlDoc = xmlDoc), declaration)) -> + | Some(struct (SynBinding(xmlDoc = xmlDoc), declaration)) when isLineLeading sourceText declaration.Start -> let firstLine = if xmlDoc.IsEmpty then declaration.StartLine @@ -637,4 +689,4 @@ let tryDeclareInFrontOfModuleLet TextChange(TextSpan(line.Start, 0), $"{attributeLines}{padding indent}{declaration}{lineBreak}{lineBreak}") TextChange(target.Replaced, replacement) ]) - | None -> ValueNone + | _ -> ValueNone diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index 536a77ce202..fe5952ba413 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -9,6 +9,7 @@ open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks open Xunit +open FSharp.Editor.Tests.Helpers open FSharp.Editor.Tests.Refactors.RefactorTestFramework let private extractToLetBinding = "Extract to let binding" @@ -41,11 +42,20 @@ let private titlesAt (code: string) (span: TextSpan) = tryGetRefactoringActionsForSpan code span context (new FSharpExtractLetBindingRefactoring()) |> Seq.map _.Title - |> List.ofSeq + |> Seq.toList let private titlesFor (code: string) (selected: string) = titlesAt code (selectionOf code selected) +let private assertTypeChecks (code: string) = + use context = TestContext.CreateWithCode code + + let _, checkResults = + (RoslynTestHelpers.GetLastDocument context.Solution).GetFSharpParseAndCheckResultsAsync "test" + |> CancellableTask.runSynchronouslyWithoutCancellation + + Assert.Empty(checkResults.Diagnostics) + let private area = """ module M @@ -264,6 +274,76 @@ let r = xs |> List.map (fun x -> Assert.Equal(expected, extracted extractToLetBinding code "x + 1") +[] +[ 1 + 2 +""", + "1 + 2", + """ +module M + +let f x = match x with _ -> + let extracted = 1 + 2 + extracted +""")>] +[] +[] +let ``Body moved under its keyword is indented from the construct that starts inside the line`` + (code: string, selected: string, expected: string) + = + Assert.Equal(expected, extracted extractToLetBinding code selected) + +[] +let ``Expression in a finally block is bound inside the block`` () = + let code = + """ +module M + +let f (stream: System.IO.Stream) = + try + stream.WriteByte(1uy) + finally + stream.Dispose() +""" + + let expected = + """ +module M + +let f (stream: System.IO.Stream) = + try + stream.WriteByte(1uy) + finally + let extracted = stream.Dispose() + extracted +""" + + Assert.Equal(expected, extracted extractToLetBinding code "stream.Dispose()") + [] let ``Name does not collide with an existing identifier`` () = let code = @@ -437,6 +517,90 @@ let f x = g (x + 1) Assert.Equal([ extractToLetBinding ], titlesFor memberConstant "42") Assert.Equal([ extractToLetBinding ], titlesFor expression "x + 1") +[] +let ``Decimal constant becomes a literal`` () = + let code = + """ +module M + +let f () = id 1.5m +""" + + let expected = + """ +module M + +[] +let ExtractedConstant = 1.5m + +let f () = id ExtractedConstant +""" + + let result = extracted extractToLiteral code "1.5m" + Assert.Equal(expected, result) + assertTypeChecks result + +[] +[] +[] +let ``Native integer does not become a literal`` (constant: string) = + let code = moduleConstant.Replace("42", constant) + + Assert.Equal([ extractToLetBinding ], titlesFor code constant) + Assert.Empty(titlesAt code (caretAt code constant)) + +[] +let ``Literal attribute is qualified where another LiteralAttribute hides it`` () = + let code = + """ +module M + +type LiteralAttribute(required: int) = + inherit System.Attribute() + +let x = 42 +""" + + let expected = + """ +module M + +type LiteralAttribute(required: int) = + inherit System.Attribute() + +[] +let ExtractedConstant = 42 + +let x = ExtractedConstant +""" + + let result = extracted extractToLiteral code "42" + Assert.Equal(expected, result) + assertTypeChecks result + +[] +let ``Literal is not offered where the declaration does not start its line`` () = + let code = + """ +namespace N +module M = let f () = 42 +""" + + Assert.Equal([ extractToLetBinding ], titlesFor code "42") + Assert.Empty(titlesAt code (caretAt code "42")) + +[] +let ``Operand of nameof is not extracted where the nameof expression is`` () = + let code = + """ +module M + +let name = nameof System.String.Empty +""" + + Assert.Empty(titlesFor code "System.String.Empty") + Assert.Equal([ extractToLetBinding ], titlesFor code "nameof System.String.Empty") + let private mapIncrement = """ module M @@ -456,13 +620,72 @@ let ``Caret in the header of a parenthesized lambda extracts the lambda`` (marke module M let f xs = - let extracted = fun x -> x + 1 + let extracted: int -> int = fun x -> x + 1 xs |> List.map extracted """ Assert.Equal([ extractToLetBinding ], titlesAt mapIncrement (caretAt mapIncrement marker)) Assert.Equal(expected, extractedAt extractToLetBinding mapIncrement (caretAt mapIncrement marker)) +[] +[ int = (fun x -> x.Length) +""", + "(fun x -> x.Length)", + """ +module M + +let f: string -> int = + let extracted: string -> int = fun x -> x.Length + extracted +""")>] +[ List.map (fun x -> [ x ]) +""", + "(fun x -> [ x ])", + """ +module M + +let f (xs: 'T list) = + let extracted: 'T -> 'T list = fun x -> [ x ] + xs |> List.map extracted +""")>] +[ List.map (fun x -> [ x ]) +""", + "(fun x -> [ x ])", + """ +module M + +let f xs = + let extracted = fun x -> [ x ] + xs |> List.map extracted +""")>] +[ int = (function Some x -> x.Length | None -> 0) +""", + "(function Some x -> x.Length | None -> 0)", + """ +module M + +let f: string option -> int = + let extracted: string option -> int = function Some x -> x.Length | None -> 0 + extracted +""")>] +let ``Extracted function expression keeps the type its context gave it`` (code: string, selected: string, expected: string) = + let result = extracted extractToLetBinding code selected + + Assert.Equal(expected, result) + assertTypeChecks result + [] [] [] @@ -590,6 +813,36 @@ let f (a: bool) (s: string) = a && s.Length > 0 [] +[] +type S = + val mutable X: int + new(x) = { X = x } + member s.Inc() = s.X <- s.X + 1 + +let f () = + let mutable s = S(1) + (s).Inc() + s.X +""", + "(s)")>] +[] +[] From 22b4bce82e1fc8841e56c5ade6155c0d8b1a5787 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 09:32:26 +0200 Subject: [PATCH 09/31] Annotate an extracted lambda only with a function type, also when selected 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 --- .../Refactor/ExtractLetBinding.fs | 20 +++++--------- .../Refactor/RefactoringHelpers.fs | 22 +++++++++++----- .../Refactors/ExtractLetBindingTests.fs | 26 +++++++++++++++++++ 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 257a72b3199..34c8799df7f 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -76,24 +76,13 @@ type internal FSharpExtractLetBindingRefactoring [] () = let literalLines = linesInsideLiterals sourceText parseResults.ParseTree defines langVersion - let content = - match target.Expr with - | SynExpr.Paren(expr = inner) -> inner - | expr -> expr - if isSelected then match anchorsOf target.Expr target.Path with | anchor :: _ -> let name = uniqueName "extracted" names - let functionType = - match content with - | SynExpr.Lambda _ - | SynExpr.MatchLambda _ -> tryTypeText checkResults target.Expr.Range - | _ -> ValueNone - let header = - match functionType with + match tryFunctionTypeText checkResults target with | ValueSome functionType -> $"let {name}: {functionType}" | ValueNone -> $"let {name}" @@ -102,7 +91,12 @@ type internal FSharpExtractLetBindingRefactoring [] () = | ValueNone -> () | [] -> () - if isLiteralConstant content then + let constant = + match target.Expr with + | SynExpr.Paren(expr = inner) -> inner + | expr -> expr + + if isLiteralConstant constant then let name = uniqueName "ExtractedConstant" names let attribute = literalAttributeAt checkResults target.Expr.Range.Start diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index 174099bc0e5..d034fb4a4ad 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -481,12 +481,22 @@ let rec private hasInferredTypeVariable (ty: FSharpType) = not ty.IsMeasureType && ty.GenericArguments |> Seq.exists hasInferredTypeVariable -/// The type the checker gave the expression at the range, as the source can write it. Unavailable when the type has a -/// variable that inference introduced, which has no name in the source. -let tryTypeText (checkResults: FSharpCheckFileResults) (m: range) = - match checkResults.TryGetCapturedType m, checkResults.TryGetCapturedDisplayContext m with - | Some ty, Some displayContext when not (hasInferredTypeVariable ty) -> ValueSome(ty.Format displayContext) - | _ -> ValueNone +/// The type the checker gave the parenthesized lambda or `function` the target is, as the source can write it. The +/// checker records no type for one without parentheses. A type with a variable that inference introduced has no name +/// in the source. +let tryFunctionTypeText (checkResults: FSharpCheckFileResults) (target: ExtractionTarget) = + let parentheses = + match target.Expr, target.Path with + | SynExpr.Paren(expr = SynExpr.Lambda _ | SynExpr.MatchLambda _; range = m), _ + | (SynExpr.Lambda _ | SynExpr.MatchLambda _), SyntaxNode.SynExpr(SynExpr.Paren(range = m)) :: _ -> ValueSome m + | _ -> ValueNone + + parentheses + |> ValueOption.bind (fun m -> + match checkResults.TryGetCapturedType m, checkResults.TryGetCapturedDisplayContext m with + | Some ty, Some displayContext when ty.StripAbbreviations().IsFunctionType && not (hasInferredTypeVariable ty) -> + ValueSome(ty.Format displayContext) + | _ -> ValueNone) /// `Literal`, with its namespace where the name alone does not reach the FSharp.Core attribute from the position. let literalAttributeAt (checkResults: FSharpCheckFileResults) (position: pos) = diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index fe5952ba413..a53e01a46e2 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -680,6 +680,32 @@ let f: string option -> int = let extracted: string option -> int = function Some x -> x.Length | None -> 0 extracted """)>] +[ List.map (fun x -> x.Length) +""", + "fun x -> x.Length", + """ +module M + +let f (xs: string list) = + let extracted: string -> int = fun x -> x.Length + xs |> List.map (extracted) +""")>] +[) = xs.ConvertAll(fun x -> x.Length) +""", + "(fun x -> x.Length)", + """ +module M + +let f (xs: System.Collections.Generic.List) = + let extracted: string -> int = fun x -> x.Length + xs.ConvertAll(extracted) +""")>] let ``Extracted function expression keeps the type its context gave it`` (code: string, selected: string, expected: string) = let result = extracted extractToLetBinding code selected From 55e8ba3227382d169b7617e024d98fb076a1784e Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 15 Oct 2026 03:09:24 +0100 Subject: [PATCH 10/31] Compact the Extract to let binding code and name what its comments said 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 --- .../Refactor/ExtractLetBinding.fs | 118 ++-- .../Refactor/RefactoringHelpers.fs | 601 +++++++++--------- 2 files changed, 356 insertions(+), 363 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 34c8799df7f..bab5d714b4f 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -4,11 +4,7 @@ namespace Microsoft.VisualStudio.FSharp.Editor open System.Composition -open Microsoft.CodeAnalysis.CodeActions open Microsoft.CodeAnalysis.CodeRefactorings -open Microsoft.CodeAnalysis.Formatting -open Microsoft.CodeAnalysis.Text -open Microsoft.VisualStudio.FSharp.Editor.Telemetry open FSharp.Compiler.Syntax @@ -19,91 +15,59 @@ open CancellableTasks type internal FSharpExtractLetBindingRefactoring [] () = inherit CodeRefactoringProvider() - static let register - (context: CodeRefactoringContext) - (sourceText: SourceText) - (title: string) - (kind: string) - (changes: TextChange list) - = - let changedDocument = - cancellableTask { - TelemetryReporter.ReportSingleEvent( - TelemetryEvents.RefactoringActivated, - [| "name", box (nameof FSharpExtractLetBindingRefactoring); "kind", box kind |] - ) - - return context.Document.WithText(sourceText.WithChanges changes) - } - - context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + static let letBindingChanges (extraction: Extraction) = + match anchorsOf extraction.Target.Expr extraction.Target.Path with + | [] -> ValueNone + | anchor :: _ -> + let name = uniqueName "extracted" extraction.Names + + let header = + match tryFunctionTypeText extraction.CheckResults extraction.Target with + | ValueSome functionType -> $"let {name}: {functionType}" + | ValueNone -> $"let {name}" + + tryDeclareInFront extraction anchor header name + + static let literalChanges (extraction: Extraction) = + let constant = + match extraction.Target.Expr with + | SynExpr.Paren(expr = inner) -> inner + | expr -> expr + + if isLiteralConstant constant then + let name = uniqueName "ExtractedConstant" extraction.Names + + let attribute = + literalAttributeAt extraction.CheckResults extraction.Target.Expr.Range.Start + + tryDeclareInFrontOfModuleLet extraction [ $"[<{attribute}>]" ] $"let {name}" name + else + ValueNone + + static let offer (isSelected: bool) (extraction: Extraction) = + if isSelected then + registerRefactoring extraction (SR.ExtractToLetBinding()) "let" (letBindingChanges extraction) + + registerRefactoring extraction (SR.ExtractToLiteral()) "literal" (literalChanges extraction) override _.ComputeRefactoringsAsync context = cancellableTask { let document = context.Document if not document.IsFSharpSignatureFile then - let! cancellationToken = CancellableTask.getCancellationToken () - let! sourceText = document.GetTextAsync cancellationToken + let! sourceText = document.GetTextAsync context.CancellationToken let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractLetBindingRefactoring) - - let lambdaAtCaret = - if context.Span.IsEmpty then - tryParenthesizedLambdaAtCaret sourceText parseResults.ParseTree context.Span.Start - else - ValueNone - - let isSelected = not context.Span.IsEmpty || lambdaAtCaret.IsSome + let parseTree = parseResults.ParseTree + let selected = trySelectedTarget sourceText parseTree context.Span let target = - match lambdaAtCaret with - | ValueSome _ -> lambdaAtCaret - | ValueNone when context.Span.IsEmpty -> tryConstantAtCaret sourceText parseResults.ParseTree context.Span.Start - | ValueNone -> tryExtractionTarget sourceText parseResults.ParseTree context.Span + selected + |> ValueOption.orElseWith (fun () -> tryConstantAtCaret sourceText parseTree context.Span) match target with | ValueNone -> () | ValueSome target -> - let! options = document.GetOptionsAsync cancellationToken - - let indentSize = - options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) - - let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpExtractLetBindingRefactoring) - let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof FSharpExtractLetBindingRefactoring) - let names = usedNames parseResults.ParseTree - - let literalLines = - linesInsideLiterals sourceText parseResults.ParseTree defines langVersion - - if isSelected then - match anchorsOf target.Expr target.Path with - | anchor :: _ -> - let name = uniqueName "extracted" names - - let header = - match tryFunctionTypeText checkResults target with - | ValueSome functionType -> $"let {name}: {functionType}" - | ValueNone -> $"let {name}" - - match tryDeclareInFront sourceText target anchor header name indentSize literalLines with - | ValueSome changes -> register context sourceText (SR.ExtractToLetBinding()) "let" changes - | ValueNone -> () - | [] -> () - - let constant = - match target.Expr with - | SynExpr.Paren(expr = inner) -> inner - | expr -> expr - - if isLiteralConstant constant then - let name = uniqueName "ExtractedConstant" names - let attribute = literalAttributeAt checkResults target.Expr.Range.Start - - match - tryDeclareInFrontOfModuleLet sourceText target [ $"[<{attribute}>]" ] $"let {name}" name indentSize literalLines - with - | ValueSome changes -> register context sourceText (SR.ExtractToLiteral()) "literal" changes - | ValueNone -> () + let! extraction = prepareExtraction context (nameof FSharpExtractLetBindingRefactoring) sourceText parseTree target + offer selected.IsSome extraction } |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index d034fb4a4ad..8158df27329 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -6,7 +6,11 @@ open System open System.Collections.Generic open System.Text +open Microsoft.CodeAnalysis.CodeActions +open Microsoft.CodeAnalysis.CodeRefactorings +open Microsoft.CodeAnalysis.Formatting open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor.Telemetry open FSharp.Compiler.CodeAnalysis open FSharp.Compiler.Symbols @@ -15,26 +19,36 @@ open FSharp.Compiler.SyntaxTrivia open FSharp.Compiler.Text open FSharp.Compiler.Tokenization -/// A place in front of which a declaration used by the selected expression can be inserted. +open CancellableTasks + [] type Anchor = - /// An expression that is a statement of its block. | Statement of start: pos - /// The body of a binding, match clause, branch or lambda, after the keyword ending at keywordEnd. No line of the - /// body can start left of column limit, where the construct that sets its offside line starts. - | Clause of keywordEnd: pos * body: range * limit: int + | Clause of keywordEnd: pos * body: range * offsideColumn: int [] type ExtractionTarget = { Expr: SynExpr Path: SyntaxVisitorPath - /// The selection without its parentheses when the selected expression is parenthesized. - Content: TextSpan - /// The text the new name replaces; the parentheses of a method call stay. + Extracted: TextSpan Replaced: TextSpan } +[] +type Extraction = + { + Context: CodeRefactoringContext + Provider: string + SourceText: SourceText + Target: ExtractionTarget + CheckResults: FSharpCheckFileResults + IndentSize: int + LiteralLines: HashSet + LineBreak: string + Names: HashSet + } + let private isSame (expr: SynExpr) (other: SynExpr) = obj.ReferenceEquals(expr, other) let private hasName (name: string) (ident: Ident) = @@ -47,6 +61,8 @@ let positionOf (sourceText: SourceText) (offset: int) = let linePosition = sourceText.Lines.GetLinePosition offset Position.mkPos (Line.fromZ linePosition.Line) linePosition.Character +let padding (width: int) = String(' ', width) + let leadingSpaces (sourceText: SourceText) (line: TextLine) = let mutable position = line.Start @@ -57,44 +73,37 @@ let leadingSpaces (sourceText: SourceText) (line: TextLine) = let lineBreakOf (sourceText: SourceText) = sourceText.Lines - |> Seq.tryFind (fun line -> line.EndIncludingLineBreak > line.End) - |> Option.map (fun line -> sourceText.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak))) - |> Option.defaultValue Environment.NewLine + |> Seq.tryFindV (fun line -> line.EndIncludingLineBreak > line.End) + |> ValueOption.map (fun line -> sourceText.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak))) + |> ValueOption.defaultValue Environment.NewLine -let isLineLeading (sourceText: SourceText) (position: pos) = +let private isLineLeading (sourceText: SourceText) (position: pos) = let line = sourceText.Lines[Line.toZ position.Line] leadingSpaces sourceText line = position.Column -/// Whether only closing brackets and a line comment follow the position on its line. -let restOfLineIsClosers (sourceText: SourceText) (position: pos) = +let private onlyClosersFollow (sourceText: SourceText) (position: pos) = let line = sourceText.Lines[Line.toZ position.Line] - let rest = - sourceText.ToString(TextSpan.FromBounds(line.Start + position.Column, line.End)) + let isCloser c = + Char.IsWhiteSpace c || c = ')' || c = ']' || c = '}' || c = '|' - let code = - match rest.IndexOf("//", StringComparison.Ordinal) with - | -1 -> rest - | comment -> rest.Substring(0, comment) + let mutable offset = line.Start + position.Column - code - |> Seq.forall (fun c -> Char.IsWhiteSpace c || c = ')' || c = ']' || c = '}' || c = '|') + while offset < line.End && isCloser (sourceText[offset]) do + offset <- offset + 1 -/// Whether a line lexed from this state starts inside a string literal. -let private continuesString (state: FSharpTokenizerColorState) = - match state with - | FSharpTokenizerColorState.InitialState - | FSharpTokenizerColorState.Token - | FSharpTokenizerColorState.IfDefSkip - | FSharpTokenizerColorState.EndLineThenSkip - | FSharpTokenizerColorState.EndLineThenToken - | FSharpTokenizerColorState.Comment - | FSharpTokenizerColorState.SingleLineComment - | FSharpTokenizerColorState.StringInComment - | FSharpTokenizerColorState.VerbatimStringInComment - | FSharpTokenizerColorState.TripleQuoteStringInComment -> false - // String, VerbatimString, TripleQuoteString and the extended interpolated string state, which the public enum does not name. - | _ -> true + offset = line.End + || (offset + 1 < line.End + && sourceText[offset] = '/' + && sourceText[offset + 1] = '/') + +let private startsInsideString (state: FSharpTokenizerLexState) = + match FSharpLineTokenizer.ColorStateOfLexState state with + | FSharpTokenizerColorState.String + | FSharpTokenizerColorState.VerbatimString + | FSharpTokenizerColorState.TripleQuoteString -> true + // The public enum does not name the interpolated string states. + | color -> not (Enum.IsDefined(typeof, color)) let private isSkipped (state: FSharpTokenizerLexState) = match FSharpLineTokenizer.ColorStateOfLexState state with @@ -108,7 +117,6 @@ let private startsWithDirective (code: ReadOnlySpan) (keyword: string) = || Char.IsWhiteSpace code[keyword.Length] || code[keyword.Length] = '/') -/// Whether the line is a directive the lexer recognizes while it skips an inactive branch. let private isSkippedDirective (line: string) = let code = line.AsSpan().TrimStart() @@ -127,8 +135,6 @@ let private scanLine (tokenizer: FSharpSourceTokenizer) (line: string) (state: F loop state -/// Lines inside multi-line string literals of the branches of `#if` the defines leave inactive. Each run of inactive -/// lines between two directives is lexed as the code it is when its branch is taken. let private linesInsideInactiveLiterals (sourceText: SourceText) (defines: string list) (langVersion: string) (lines: HashSet) = let fileTokenizer = FSharpSourceTokenizer(defines, None, Some langVersion) let branchTokenizer = FSharpSourceTokenizer([], None, Some langVersion) @@ -137,18 +143,22 @@ let private linesInsideInactiveLiterals (sourceText: SourceText) (defines: strin for line in sourceText.Lines do let text = line.ToString() + let inInactiveBranch = isSkipped fileState && not (isSkippedDirective text) - if isSkipped fileState && not (isSkippedDirective text) then - if continuesString (FSharpLineTokenizer.ColorStateOfLexState branchState) then - lines.Add line.LineNumber |> ignore + if inInactiveBranch && startsInsideString branchState then + lines.Add line.LineNumber |> ignore - branchState <- scanLine branchTokenizer text branchState - else - branchState <- FSharpTokenizerLexState.Initial + branchState <- + if inInactiveBranch then + scanLine branchTokenizer text branchState + else + FSharpTokenizerLexState.Initial fileState <- scanLine fileTokenizer text fileState -/// Lines inside multi-line string literals, including those in branches of `#if` the defines leave inactive. +let private linesAfterFirst (m: range) = + seq { for line in m.StartLine + 1 .. m.EndLine -> Line.toZ line } + let linesInsideLiterals (sourceText: SourceText) (parseTree: ParsedInput) (defines: string list) (langVersion: string) = let lines = (HashSet(), parseTree) @@ -156,9 +166,7 @@ let linesInsideLiterals (sourceText: SourceText) (parseTree: ParsedInput) (defin match node with | SyntaxNode.SynExpr(SynExpr.Const(range = m)) | SyntaxNode.SynExpr(SynExpr.InterpolatedString(range = m)) - | SyntaxNode.SynPat(SynPat.Const(range = m)) -> - for line in m.StartLine + 1 .. m.EndLine do - lines.Add(Line.toZ line) |> ignore + | SyntaxNode.SynPat(SynPat.Const(range = m)) -> lines.UnionWith(linesAfterFirst m) | _ -> () lines) @@ -182,9 +190,7 @@ let usedNames (parseTree: ParsedInput) = | SyntaxNode.SynExpr(SynExpr.LongIdentSet(longDotId = SynLongIdent(id = ident :: _))) | SyntaxNode.SynExpr(SynExpr.NamedIndexedPropertySet(longDotId = SynLongIdent(id = ident :: _))) | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) -> names.Add ident.idText |> ignore - | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = idents))) -> - for ident in idents do - names.Add ident.idText |> ignore + | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = idents))) -> names.UnionWith(idents |> Seq.map _.idText) | _ -> () names) @@ -193,10 +199,9 @@ let uniqueName (baseName: string) (used: HashSet) = Seq.initInfinite (fun i -> if i = 0 then baseName else $"{baseName}{i}") |> Seq.find (used.Contains >> not) -/// The text of the span with every line after the first moved by as many columns as the first line moves to reach -/// column. Lines inside multi-line string literals are kept as they are; a line that would need a negative indentation -/// makes the whole text unavailable. -let tryIndentedText (sourceText: SourceText) (span: TextSpan) (column: int) (literalLines: HashSet) = +let private tryIndentedText (extraction: Extraction) (column: int) = + let sourceText = extraction.SourceText + let span = extraction.Target.Extracted let lines = sourceText.Lines let first = lines.GetLineFromPosition span.Start let last = lines.GetLineFromPosition span.End @@ -216,7 +221,7 @@ let tryIndentedText (sourceText: SourceText) (span: TextSpan) (column: int) (lit sourceText.ToString(TextSpan.FromBounds(line.Start, min line.End span.End)) let moved = - match literalLines.Contains lineNumber with + match extraction.LiteralLines.Contains lineNumber with | true -> ValueSome content | false when String.IsNullOrWhiteSpace content -> ValueSome content | false when shift >= 0 -> ValueSome(String(' ', shift) + content) @@ -334,8 +339,7 @@ let private isInExcludedContext (expr: SynExpr) (path: SyntaxVisitorPath) = loop (SyntaxNode.SynExpr expr) path -/// Whether the expression contains a computation expression construct outside any computation expression of its own. -let private hasStatementOnlyConstruct (expr: SynExpr) = +let private hasOrphanedComputationConstruct (expr: SynExpr) = let containers = ResizeArray() let statements = ResizeArray() @@ -357,6 +361,37 @@ let private hasStatementOnlyConstruct (expr: SynExpr) = |> Seq.exists (fun container -> Range.rangeContainsRange container statement) )) +let private targetOf (sourceText: SourceText) (expr: SynExpr) (path: SyntaxVisitorPath) = + let span = textSpanOf sourceText expr.Range + + let extracted = + match expr with + | SynExpr.Paren(expr = inner) -> textSpanOf sourceText inner.Range + | _ -> span + + let replaced = + match expr with + | SynExpr.Paren _ when isCall path -> extracted + | _ -> span + + { + Expr = expr + Path = path + Extracted = extracted + Replaced = replaced + } + +let private exprSpanning (start: pos) (finish: pos) (path: SyntaxVisitorPath) (node: SyntaxNode) = + match node with + | SyntaxNode.SynExpr expr when Position.posEq expr.Range.Start start && Position.posEq expr.Range.End finish -> Some(expr, path) + | _ -> None + +let private crossesDirective (directives: ConditionalDirectiveTrivia list) (start: pos) (finish: pos) = + directives + |> List.exists (fun directive -> + let line = directiveLine directive + line >= start.Line && line <= finish.Line) + let tryExtractionTarget (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = let span = trimmed sourceText selection @@ -365,53 +400,75 @@ let tryExtractionTarget (sourceText: SourceText) (parseTree: ParsedInput) (selec let start = positionOf sourceText span.Start let finish = positionOf sourceText span.End - let crossesDirective = - file.Trivia.ConditionalDirectives - |> List.exists (fun directive -> - let line = directiveLine directive - line >= start.Line && line <= finish.Line) - - let exact = - (start, parseTree) - ||> ParsedInput.tryPickLast (fun path node -> - match node with - | SyntaxNode.SynExpr expr when Position.posEq expr.Range.Start start && Position.posEq expr.Range.End finish -> - Some(expr, path) - | _ -> None) - - match exact with + match (start, parseTree) ||> ParsedInput.tryPickLast (exprSpanning start finish) with | Some(expr, path) when - not crossesDirective + not (crossesDirective file.Trivia.ConditionalDirectives start finish) && isExtractableShape expr path && not (isInExcludedContext expr path) - && not (hasStatementOnlyConstruct expr) + && not (hasOrphanedComputationConstruct expr) -> - let content = - match expr with - | SynExpr.Paren(expr = inner) -> textSpanOf sourceText inner.Range - | _ -> span - - let replaced = - match expr with - | SynExpr.Paren _ when isCall path -> content - | _ -> span - - ValueSome - { - Expr = expr - Path = path - Content = content - Replaced = replaced - } + ValueSome(targetOf sourceText expr path) | _ -> ValueNone | _ -> ValueNone +let isLiteralConstant (expr: SynExpr) = + match expr with + | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.Single _ | SynConst.Double _ | SynConst.Decimal _ | SynConst.Char _ | SynConst.String _), + _) -> true + | _ -> false + +let private constantAt (position: pos) (path: SyntaxVisitorPath) (node: SyntaxNode) = + match node with + | SyntaxNode.SynExpr(SynExpr.Const(range = m) as expr) when + isLiteralConstant expr + && Position.posGeq position m.Start + && Position.posGeq m.End position + && not (isInExcludedContext expr path) + -> + Some(expr, path) + | _ -> None + +let tryConstantAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = + let position = positionOf sourceText selection.Start + + if selection.IsEmpty then + match (position, parseTree) ||> ParsedInput.tryPickLast (constantAt position) with + | Some(expr, path) -> ValueSome(targetOf sourceText expr path) + | None -> ValueNone + else + ValueNone + +let private parenthesizedLambdaHeaderAt (position: pos) (_: SyntaxVisitorPath) (node: SyntaxNode) = + match node with + | SyntaxNode.SynExpr(SynExpr.Paren(expr = SynExpr.Lambda(parsedData = Some _; trivia = { ArrowRange = Some arrow }) as lambda; range = m)) when + Position.posGeq position lambda.Range.Start + && Position.posGeq arrow.End position + -> + Some m + | _ -> None + +let private tryParenthesizedLambdaAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = + let position = positionOf sourceText caret + + match + (position, parseTree) + ||> ParsedInput.tryPickLast (parenthesizedLambdaHeaderAt position) + with + | Some m -> tryExtractionTarget sourceText parseTree (textSpanOf sourceText m) + | None -> ValueNone + +let trySelectedTarget (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = + if selection.IsEmpty then + tryParenthesizedLambdaAtCaret sourceText parseTree selection.Start + else + tryExtractionTarget sourceText parseTree selection + let private isFunctionBinding (binding: SynBinding) = match binding with | SynBinding(headPat = SynPat.LongIdent(argPats = SynArgPats.Pats(_ :: _))) -> true | _ -> false -let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: SyntaxNode voption) (limit: int) = +let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (outer: SyntaxVisitorPath) (offsideColumn: int) = match parent, child with | SyntaxNode.SynExpr(SynExpr.Sequential _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse), SyntaxNode.SynExpr expr when isSame letOrUse.Body expr -> @@ -423,54 +480,52 @@ let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: Synt ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynModule(SynModuleDecl.Expr _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynBinding(SynBinding(expr = rhs; trivia = trivia) as binding), SyntaxNode.SynExpr expr when isSame rhs expr -> - match grandparent, trivia.EqualsRange with - | ValueSome(SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse)), _ when not letOrUse.IsRecursive && not (isFunctionBinding binding) -> + match outer, trivia.EqualsRange with + | SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse) :: _, _ when not letOrUse.IsRecursive && not (isFunctionBinding binding) -> ValueSome(Anchor.Statement letOrUse.Range.Start) - | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range, limit)) - | _ -> ValueNone - | SyntaxNode.SynMatchClause(SynMatchClause(resultExpr = result; trivia = trivia)), SyntaxNode.SynExpr expr when isSame result expr -> - match trivia.ArrowRange with - | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range, limit)) - | None -> ValueNone - | SyntaxNode.SynExpr(SynExpr.IfThenElse(thenExpr = thenExpr; elseExpr = elseExpr; trivia = trivia)), SyntaxNode.SynExpr expr -> - match elseExpr, trivia.ElseKeyword with - | _ when isSame thenExpr expr -> ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range, limit)) - | Some elseExpr, Some elseKeyword when isSame elseExpr expr -> ValueSome(Anchor.Clause(elseKeyword.End, expr.Range, limit)) + | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range, offsideColumn)) | _ -> ValueNone - | SyntaxNode.SynExpr(SynExpr.Lambda(parsedData = Some(_, body); trivia = trivia)), SyntaxNode.SynExpr expr when isSame body expr -> - match trivia.ArrowRange with - | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range, limit)) - | None -> ValueNone + | SyntaxNode.SynMatchClause(SynMatchClause(resultExpr = body; trivia = { ArrowRange = Some arrow })), SyntaxNode.SynExpr expr when + isSame body expr + -> + ValueSome(Anchor.Clause(arrow.End, expr.Range, offsideColumn)) + | SyntaxNode.SynExpr(SynExpr.IfThenElse(thenExpr = body; trivia = trivia)), SyntaxNode.SynExpr expr when isSame body expr -> + ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range, offsideColumn)) + | SyntaxNode.SynExpr(SynExpr.IfThenElse(elseExpr = Some body; trivia = { ElseKeyword = Some elseKeyword })), SyntaxNode.SynExpr expr when + isSame body expr + -> + ValueSome(Anchor.Clause(elseKeyword.End, expr.Range, offsideColumn)) + | SyntaxNode.SynExpr(SynExpr.Lambda(parsedData = Some(_, body); trivia = { ArrowRange = Some arrow })), SyntaxNode.SynExpr expr when + isSame body expr + -> + ValueSome(Anchor.Clause(arrow.End, expr.Range, offsideColumn)) | _ -> ValueNone -/// The column where the innermost `let`, `match`, `try`, `if` or loop of the path starts. An `if` after `else` continues -/// the `if` it belongs to. -let rec private limitColumn (path: SyntaxVisitorPath) = +let private (|ElifBranch|_|) (path: SyntaxVisitorPath) = match path with - | [] -> 0 - | SyntaxNode.SynExpr(SynExpr.IfThenElse _ as nested) :: (SyntaxNode.SynExpr(SynExpr.IfThenElse(elseExpr = Some elseExpr)) :: _ as rest) when + | SyntaxNode.SynExpr(SynExpr.IfThenElse _ as nested) :: (SyntaxNode.SynExpr(SynExpr.IfThenElse(elseExpr = Some elseExpr)) :: _ as enclosingIf) when isSame elseExpr nested -> - limitColumn rest + Some enclosingIf + | _ -> None + +let rec private offsideColumnOf (path: SyntaxVisitorPath) = + match path with + | [] -> 0 + | ElifBranch enclosingIf -> offsideColumnOf enclosingIf | SyntaxNode.SynBinding(SynBinding(trivia = trivia)) :: _ -> trivia.LeadingKeyword.Range.StartColumn | SyntaxNode.SynExpr(SynExpr.Match(range = m) | SynExpr.MatchBang(range = m) | SynExpr.TryWith(range = m) | SynExpr.TryFinally(range = m) | SynExpr.IfThenElse( range = m) | SynExpr.For(range = m) | SynExpr.ForEach(range = m) | SynExpr.While(range = m)) :: _ -> m.StartColumn - | _ :: rest -> limitColumn rest + | _ :: rest -> offsideColumnOf rest -/// The places a declaration used by the expression can go, innermost first. let anchorsOf (expr: SynExpr) (path: SyntaxVisitorPath) = let rec loop (child: SyntaxNode) (path: SyntaxVisitorPath) = match path with | [] -> [] - | parent :: rest -> - let grandparent = - match rest with - | node :: _ -> ValueSome node - | [] -> ValueNone - - match anchorOf child parent grandparent (limitColumn path) with - | ValueSome anchor -> anchor :: loop parent rest - | ValueNone -> loop parent rest + | parent :: outer -> + match anchorOf child parent outer (offsideColumnOf path) with + | ValueSome anchor -> anchor :: loop parent outer + | ValueNone -> loop parent outer loop (SyntaxNode.SynExpr expr) path @@ -481,24 +536,20 @@ let rec private hasInferredTypeVariable (ty: FSharpType) = not ty.IsMeasureType && ty.GenericArguments |> Seq.exists hasInferredTypeVariable -/// The type the checker gave the parenthesized lambda or `function` the target is, as the source can write it. The -/// checker records no type for one without parentheses. A type with a variable that inference introduced has no name -/// in the source. let tryFunctionTypeText (checkResults: FSharpCheckFileResults) (target: ExtractionTarget) = - let parentheses = + let parenthesizedFunction = match target.Expr, target.Path with | SynExpr.Paren(expr = SynExpr.Lambda _ | SynExpr.MatchLambda _; range = m), _ | (SynExpr.Lambda _ | SynExpr.MatchLambda _), SyntaxNode.SynExpr(SynExpr.Paren(range = m)) :: _ -> ValueSome m | _ -> ValueNone - parentheses + parenthesizedFunction |> ValueOption.bind (fun m -> match checkResults.TryGetCapturedType m, checkResults.TryGetCapturedDisplayContext m with | Some ty, Some displayContext when ty.StripAbbreviations().IsFunctionType && not (hasInferredTypeVariable ty) -> ValueSome(ty.Format displayContext) | _ -> ValueNone) -/// `Literal`, with its namespace where the name alone does not reach the FSharp.Core attribute from the position. let literalAttributeAt (checkResults: FSharpCheckFileResults) (position: pos) = let attribute = checkResults.ProjectContext.GetReferencedAssemblies() @@ -509,174 +560,107 @@ let literalAttributeAt (checkResults: FSharpCheckFileResults) (position: pos) = "global.Microsoft.FSharp.Core.Literal" | _ -> "Literal" -let isLiteralConstant (expr: SynExpr) = - match expr with - | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.Single _ | SynConst.Double _ | SynConst.Decimal _ | SynConst.Char _ | SynConst.String _), - _) -> true - | _ -> false - -/// The literal constant the caret is in or touches. -let tryConstantAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = - let position = positionOf sourceText caret - - let constant = - (position, parseTree) - ||> ParsedInput.tryPickLast (fun path node -> - match node with - | SyntaxNode.SynExpr(SynExpr.Const(range = m) as expr) when - isLiteralConstant expr - && Position.posGeq position m.Start - && Position.posGeq m.End position - && not (isInExcludedContext expr path) - -> - Some(expr, path) - | _ -> None) - - match constant with - | Some(expr, path) -> - let span = textSpanOf sourceText expr.Range - - ValueSome - { - Expr = expr - Path = path - Content = span - Replaced = span - } - | None -> ValueNone - -/// The parenthesized lambda whose `fun ->` the caret is in, as if it were selected with its parentheses. -let tryParenthesizedLambdaAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = - let position = positionOf sourceText caret - - let parenthesized = - (position, parseTree) - ||> ParsedInput.tryPickLast (fun _ node -> - match node with - | SyntaxNode.SynExpr(SynExpr.Paren( - expr = SynExpr.Lambda(parsedData = Some _; trivia = { ArrowRange = Some arrow }) as lambda; range = m)) when - Position.posGeq position lambda.Range.Start - && Position.posGeq arrow.End position - -> - Some m - | _ -> None) - - match parenthesized with - | Some m -> tryExtractionTarget sourceText parseTree (textSpanOf sourceText m) - | None -> ValueNone - -/// The module-level let declaration containing the path, with its first binding. -let tryEnclosingModuleLet (path: SyntaxVisitorPath) = +let private tryEnclosingModuleLet (path: SyntaxVisitorPath) = path |> List.tryPick (function | SyntaxNode.SynModule(SynModuleDecl.Let(bindings = binding :: _; range = m)) -> Some struct (binding, m) | _ -> None) -let private padding (width: int) = String(' ', width) +let tryDeclaration (extraction: Extraction) (header: string) (bodyColumn: int) = + let sourceText = extraction.SourceText + let lines = sourceText.Lines + let extracted = extraction.Target.Extracted -/// `header = `, with a multi-line content starting on its own line at bodyColumn. -let private tryDeclaration (sourceText: SourceText) (content: TextSpan) (header: string) (bodyColumn: int) literalLines lineBreak = - tryIndentedText sourceText content bodyColumn literalLines + tryIndentedText extraction bodyColumn |> ValueOption.map (fun body -> - let lines = sourceText.Lines - - if lines.GetLineFromPosition(content.Start).LineNumber = lines.GetLineFromPosition(content.End).LineNumber then + if lines.GetLineFromPosition(extracted.Start).LineNumber = lines.GetLineFromPosition(extracted.End).LineNumber then $"{header} = {body}" else - $"{header} ={lineBreak}{padding bodyColumn}{body}") - -/// Changes that declare `header = ` in front of the anchor and put replacement where the selection was. -/// A body that shares its line with its keyword moves to new lines under that keyword, when only closing brackets -/// and a comment follow it. -let tryDeclareInFront - (sourceText: SourceText) - (target: ExtractionTarget) - (anchor: Anchor) + $"{header} ={extraction.LineBreak}{padding bodyColumn}{body}") + +let private tryInFrontOfLine (extraction: Extraction) (start: pos) (header: string) (replacement: string) = + let line = extraction.SourceText.Lines[Line.toZ start.Line] + + tryDeclaration extraction header (start.Column + extraction.IndentSize) + |> ValueOption.map (fun declaration -> + [ + TextChange(TextSpan(line.Start, 0), $"{padding start.Column}{declaration}{extraction.LineBreak}") + TextChange(extraction.Target.Replaced, replacement) + ]) + +let private linesMovedWithBody (extraction: Extraction) (body: range) = + let lines = extraction.SourceText.Lines + let replaced = extraction.Target.Replaced + + let stays (line: TextLine) = + extraction.LiteralLines.Contains line.LineNumber + || String.IsNullOrWhiteSpace(line.ToString()) + || (line.Start >= replaced.Start && line.Start <= replaced.End) + + [ + for lineNumber in Line.toZ body.StartLine + 1 .. Line.toZ body.EndLine -> lines[lineNumber] + ] + |> List.filter (stays >> not) + +let private tryUnderKeyword + (extraction: Extraction) + (keywordEnd: pos) + (body: range) + (offsideColumn: int) (header: string) (replacement: string) - (indentSize: int) - (literalLines: HashSet) = - let lines = sourceText.Lines - let lineBreak = lineBreakOf sourceText - let replacementChange = TextChange(target.Replaced, replacement) + let sourceText = extraction.SourceText + let keywordLine = sourceText.Lines[Line.toZ keywordEnd.Line] - let inFrontOfLine (start: pos) = - let line = lines[Line.toZ start.Line] + let gap = + TextSpan.FromBounds(keywordLine.Start + keywordEnd.Column, (textSpanOf sourceText body).Start) - tryDeclaration sourceText target.Content header (start.Column + indentSize) literalLines lineBreak - |> ValueOption.map (fun declaration -> - [ - TextChange(TextSpan(line.Start, 0), $"{padding start.Column}{declaration}{lineBreak}") - replacementChange - ]) + let newIndent = + max (leadingSpaces sourceText keywordLine) offsideColumn + extraction.IndentSize - match anchor with - | Anchor.Statement start when isLineLeading sourceText start -> inFrontOfLine start - | Anchor.Clause(body = body) when isLineLeading sourceText body.Start -> inFrontOfLine body.Start - | Anchor.Clause(keywordEnd, body, limit) when keywordEnd.Line = body.StartLine && restOfLineIsClosers sourceText body.End -> - let keywordLine = lines[Line.toZ keywordEnd.Line] - let keywordEndOffset = keywordLine.Start + keywordEnd.Column - let bodySpan = textSpanOf sourceText body - let newIndent = max (leadingSpaces sourceText keywordLine) limit + indentSize - let shift = newIndent - body.StartColumn - - let continuation = - [ - for lineNumber in Line.toZ body.StartLine + 1 .. Line.toZ body.EndLine do - let line = lines[lineNumber] + let shift = newIndent - body.StartColumn + let movedLines = linesMovedWithBody extraction body + + let indentation = + [ + for line in movedLines do + if shift > 0 then + TextChange(TextSpan(line.Start, 0), padding shift) + elif shift < 0 then + TextChange(TextSpan(line.Start, -shift), "") + ] - let keep = - literalLines.Contains lineNumber - || String.IsNullOrWhiteSpace(line.ToString()) - || (line.Start >= target.Replaced.Start && line.Start <= target.Replaced.End) + let canMove = + movedLines + |> List.forall (fun line -> shift >= 0 || leadingSpaces sourceText line >= -shift) - if not keep then - line + if canMove && String.IsNullOrWhiteSpace(sourceText.ToString gap) then + tryDeclaration extraction header (newIndent + extraction.IndentSize) + |> ValueOption.map (fun declaration -> + [ + TextChange(gap, $"{extraction.LineBreak}{padding newIndent}{declaration}{extraction.LineBreak}{padding newIndent}") + yield! indentation + TextChange(extraction.Target.Replaced, replacement) ] + |> List.sortBy _.Span.Start) + else + ValueNone - let movable = - continuation - |> List.forall (fun line -> shift >= 0 || leadingSpaces sourceText line >= -shift) - - let separatedByWhitespace = - String.IsNullOrWhiteSpace(sourceText.ToString(TextSpan.FromBounds(keywordEndOffset, bodySpan.Start))) - - if movable && separatedByWhitespace then - tryDeclaration sourceText target.Content header (newIndent + indentSize) literalLines lineBreak - |> ValueOption.map (fun declaration -> - [ - TextChange( - TextSpan.FromBounds(keywordEndOffset, bodySpan.Start), - $"{lineBreak}{padding newIndent}{declaration}{lineBreak}{padding newIndent}" - ) - - for line in continuation do - if shift > 0 then - TextChange(TextSpan(line.Start, 0), padding shift) - elif shift < 0 then - TextChange(TextSpan(line.Start, -shift), "") - - replacementChange - ] - |> List.sortBy _.Span.Start) - else - ValueNone +let tryDeclareInFront (extraction: Extraction) (anchor: Anchor) (header: string) (replacement: string) = + let sourceText = extraction.SourceText + + match anchor with + | Anchor.Statement start when isLineLeading sourceText start -> tryInFrontOfLine extraction start header replacement + | Anchor.Clause(body = body) when isLineLeading sourceText body.Start -> tryInFrontOfLine extraction body.Start header replacement + | Anchor.Clause(keywordEnd, body, offsideColumn) when keywordEnd.Line = body.StartLine && onlyClosersFollow sourceText body.End -> + tryUnderKeyword extraction keywordEnd body offsideColumn header replacement | _ -> ValueNone -/// Changes that declare `header = `, preceded by the attribute lines, in front of the module-level let -/// containing the selection, and put replacement where the selection was. Unavailable when that let does not start its -/// line, as in `module M = let f () = 1`. -let tryDeclareInFrontOfModuleLet - (sourceText: SourceText) - (target: ExtractionTarget) - (attributes: string list) - (header: string) - (replacement: string) - (indentSize: int) - (literalLines: HashSet) - = - match tryEnclosingModuleLet target.Path with +let tryDeclareInFrontOfModuleLet (extraction: Extraction) (attributes: string list) (header: string) (replacement: string) = + let sourceText = extraction.SourceText + + match tryEnclosingModuleLet extraction.Target.Path with | Some(struct (SynBinding(xmlDoc = xmlDoc), declaration)) when isLineLeading sourceText declaration.Start -> let firstLine = if xmlDoc.IsEmpty then @@ -686,17 +670,62 @@ let tryDeclareInFrontOfModuleLet let line = sourceText.Lines[Line.toZ firstLine] let indent = leadingSpaces sourceText line - let lineBreak = lineBreakOf sourceText - tryDeclaration sourceText target.Content header (indent + indentSize) literalLines lineBreak + tryDeclaration extraction header (indent + extraction.IndentSize) |> ValueOption.map (fun declaration -> let attributeLines = attributes - |> List.map (fun attribute -> $"{padding indent}{attribute}{lineBreak}") + |> List.map (fun attribute -> $"{padding indent}{attribute}{extraction.LineBreak}") |> String.concat "" [ - TextChange(TextSpan(line.Start, 0), $"{attributeLines}{padding indent}{declaration}{lineBreak}{lineBreak}") - TextChange(target.Replaced, replacement) + TextChange( + TextSpan(line.Start, 0), + $"{attributeLines}{padding indent}{declaration}{extraction.LineBreak}{extraction.LineBreak}" + ) + TextChange(extraction.Target.Replaced, replacement) ]) | _ -> ValueNone + +let prepareExtraction + (context: CodeRefactoringContext) + (provider: string) + (sourceText: SourceText) + (parseTree: ParsedInput) + (target: ExtractionTarget) + = + cancellableTask { + let document = context.Document + let! options = document.GetOptionsAsync context.CancellationToken + let! defines, langVersion = document.GetFsharpParsingOptionsAsync provider + let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync provider + + return + { + Context = context + Provider = provider + SourceText = sourceText + Target = target + CheckResults = checkResults + IndentSize = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) + LiteralLines = linesInsideLiterals sourceText parseTree defines langVersion + LineBreak = lineBreakOf sourceText + Names = usedNames parseTree + } + } + +let registerRefactoring (extraction: Extraction) (title: string) (kind: string) (changes: TextChange list voption) = + match changes with + | ValueSome changes -> + let changedDocument = + cancellableTask { + TelemetryReporter.ReportSingleEvent( + TelemetryEvents.RefactoringActivated, + [| "name", box extraction.Provider; "kind", box kind |] + ) + + return extraction.Context.Document.WithText(extraction.SourceText.WithChanges changes) + } + + extraction.Context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + | ValueNone -> () From 57e05c70f6aa5f01adedcbe3483dad55d4c86407 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 15 Oct 2026 03:09:34 +0100 Subject: [PATCH 11/31] Fold the Extract to let binding tests into parameterized theories 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 --- .../Refactors/ExtractLetBindingTests.fs | 947 +++++++----------- .../Refactors/RefactorTestFramework.fs | 49 +- 2 files changed, 384 insertions(+), 612 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index a53e01a46e2..b0764e21902 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -1,9 +1,5 @@ module FSharp.Editor.Tests.Refactors.ExtractLetBindingTests -open System - -open Microsoft.CodeAnalysis.Text - open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks @@ -12,40 +8,67 @@ open Xunit open FSharp.Editor.Tests.Helpers open FSharp.Editor.Tests.Refactors.RefactorTestFramework +[] let private extractToLetBinding = "Extract to let binding" + +[] let private extractToLiteral = "Extract to literal" -let private selectionOf (code: string) (selected: string) = - TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) +[] +let private bothTitles = extractToLetBinding + ";" + extractToLiteral -let private caretAt (code: string) (marker: string) = - TextSpan(code.IndexOf(marker, StringComparison.Ordinal), 0) +[] +let private area = + """ +let area w h = + printfn "%d" (w * h + 1) +""" -let private extractedAt (title: string) (code: string) (span: TextSpan) = - use context = TestContext.CreateWithCode code +[] +let private greet = + """ +let greet name = + printfn "Hello %s" name +""" + +[] +let private greetWithLiteral = + """ +[] +let ExtractedConstant = "Hello %s" - let document = - refactorSpan code span title context (new FSharpExtractLetBindingRefactoring()) +let greet name = + printfn ExtractedConstant name +""" - let parseResults = - document.GetFSharpParseResultsAsync "test" - |> CancellableTask.runSynchronouslyWithoutCancellation +[] +let private moduleConstant = + """ +let f () = g 42 +""" - Assert.Empty(parseResults.Diagnostics) - (document.GetTextAsync() |> GetTaskResult).ToString() +[] +let private mapIncrement = + """ +let f xs = + xs |> List.map (fun x -> x + 1) +""" -let private extracted (title: string) (code: string) (selected: string) = - extractedAt title code (selectionOf code selected) +[] +let private mapIncrementExtracted = + """ +let f xs = + let extracted: int -> int = fun x -> x + 1 + xs |> List.map extracted +""" -let private titlesAt (code: string) (span: TextSpan) = +let private extractedAt title code span = use context = TestContext.CreateWithCode code + refactoredText (new FSharpExtractLetBindingRefactoring()) context title code span - tryGetRefactoringActionsForSpan code span context (new FSharpExtractLetBindingRefactoring()) - |> Seq.map _.Title - |> Seq.toList - -let private titlesFor (code: string) (selected: string) = - titlesAt code (selectionOf code selected) +let private titlesAt code span = + use context = TestContext.CreateWithCode code + offeredTitles (new FSharpExtractLetBindingRefactoring()) context code span let private assertTypeChecks (code: string) = use context = TestContext.CreateWithCode code @@ -56,59 +79,40 @@ let private assertTypeChecks (code: string) = Assert.Empty(checkResults.Diagnostics) -let private area = - """ -module M - -let area w h = - printfn "%d" (w * h + 1) -""" - [] -[] -[] -let ``Expression in a statement is bound in front of it`` (selected: string, expected: string) = - Assert.Equal(expected, extracted extractToLetBinding area selected) - -[] -let ``Parentheses of a method call stay`` () = - let code = - """ -module M - +[] +[ List.filter (fun i -> i > acc) |> List.sum total -""" - -[] -let ``Multi-line right-hand side is bound in front of its let`` () = - let expected = - """ -module M - +""", + """items + |> List.filter (fun i -> i > acc) + |> List.sum""", + """ let run items = let mutable acc = 0 let extracted = @@ -133,22 +134,10 @@ let run items = let total = extracted total -""" - - let start = run.LastIndexOf("items", StringComparison.Ordinal) - - let finish = - run.IndexOf("|> List.sum", start, StringComparison.Ordinal) - + "|> List.sum".Length - - Assert.Equal(expected, extractedAt extractToLetBinding run (TextSpan.FromBounds(start, finish))) - -[] -let ``Whole lines selected with their indentation and line break are extracted`` () = - let code = - """ -module M - +""")>] +[ List.sum total -""" - - let expected = - """ -module M - +""", + """ items + |> List.filter (fun i -> i > acc) + |> List.sum +""", + """ let run items = let mutable acc = 0 @@ -175,133 +164,85 @@ let run items = extracted total -""" - - let lineStart = code.LastIndexOf(" items", StringComparison.Ordinal) - - let afterLineBreak = - code.IndexOf('\n', code.IndexOf("|> List.sum", lineStart, StringComparison.Ordinal)) - + 1 - - Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan.FromBounds(lineStart, afterLineBreak))) - -[] -let ``Match clause body on the arrow line moves to its own lines`` () = - let code = - """ -module M - +""")>] +[ v * 2 + offset | None -> 0 -""" - - let expected = - """ -module M - +""", + "v * 2 + offset", + """ let f x offset = match x with | Some v -> let extracted = v * 2 + offset extracted | None -> 0 -""" - - Assert.Equal(expected, extracted extractToLetBinding code "v * 2 + offset") - -[] -let ``Right-hand side on the equals line keeps its trailing comment`` () = - let code = - """ -module M - +""")>] +[] -let ``Expression in a computation expression is bound in front of its statement`` () = - let code = - """ -module M - +""")>] +[ List.length } -""" - - let expected = - """ -module M - +""", + "parse raw |> List.length", + """ let load id = async { let! raw = fetch id let extracted = parse raw |> List.length return extracted } -""" - - Assert.Equal(expected, extracted extractToLetBinding code "parse raw |> List.length") - -[] -let ``Lambda body on the arrow line moves to its own lines`` () = - let code = - """ -module M - +""")>] +[ List.map (fun x -> x + 1) -""" - - let expected = - """ -module M - +""", + "x + 1", + """ let r = xs |> List.map (fun x -> let extracted = x + 1 extracted) -""" - - Assert.Equal(expected, extracted extractToLetBinding code "x + 1") - -[] -[] +[ 1 + 2 """, "1 + 2", """ -module M - let f x = match x with _ -> let extracted = 1 + 2 extracted """)>] -[] -[] -let ``Body moved under its keyword is indented from the construct that starts inside the line`` - (code: string, selected: string, expected: string) - = - Assert.Equal(expected, extracted extractToLetBinding code selected) - -[] -let ``Expression in a finally block is bound inside the block`` () = - let code = - """ -module M - +[] -let ``Name does not collide with an existing identifier`` () = - let code = - """ -module M - +""")>] +[] +[] +[] +[] +[] +[] +let ExtractedConstant = -1 -[] -let ``Line breaks of the file are kept`` () = - let code = "module M\r\n\r\nlet f x =\r\n printfn \"%d\" (x + 1)\r\n" +let f () = g ExtractedConstant +""")>] +let ``Selection is extracted`` (_scenario: string, title: string, code: string, selected: string, expected: string) = + let code = fileOf code + Assert.Equal(fileOf expected, extractedAt title code (selectionOf code selected)) - let expected = - "module M\r\n\r\nlet f x =\r\n let extracted = x + 1\r\n printfn \"%d\" (extracted)\r\n" +[] +[] +let ExtractedConstant = 1.5m - Assert.Equal(expected, extracted extractToLetBinding code "x + 1") - -[] -let ``Lines inside a multi-line string are not re-indented`` () = - // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. - let code = - "module M\n\nlet f name =\n printfn \"%s\" (String.Format(\"\"\"Hello\n{0}\"\"\", name))\n" - - let expected = - "module M\n\nlet f name =\n let extracted =\n String.Format(\"\"\"Hello\n{0}\"\"\", name)\n printfn \"%s\" (extracted)\n" - - Assert.Equal(expected, extracted extractToLetBinding code "String.Format(\"\"\"Hello\n{0}\"\"\", name)") - -[] -let ``Lines inside a multi-line string in an inactive branch are not re-indented`` () = - // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. - let code = - "module M\n\nlet r = combine 1 [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" - - let expected = - "module M\n\nlet r =\n let extracted = 1\n combine extracted [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" - - Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan(code.IndexOf("1 [", StringComparison.Ordinal), 1))) - -[] -[] -[] -[] -let ``Name does not collide with an assignment target from another file`` (declaration: string, assignment: string) = - let dependency = - """ -module Dependency - -type Box = { mutable Value: int } - -DECLARATION -""" - .Replace("DECLARATION", declaration) - - let code = - """ -module M - -open Dependency - -let f () = - ASSIGNMENT({ Value = 1 }).Value -""" - .Replace("ASSIGNMENT", assignment) - - let expected = - """ -module M - -open Dependency - -let f () = - let extracted1 = { Value = 1 } - ASSIGNMENTextracted1.Value -""" - .Replace("ASSIGNMENT", assignment) - - use context = TestContext.CreateWithCodeAndDependency code dependency - - let document = - refactorSpan code (selectionOf code "({ Value = 1 })") extractToLetBinding context (new FSharpExtractLetBindingRefactoring()) - - Assert.Equal(expected, (document.GetTextAsync() |> GetTaskResult).ToString()) - -let private greet = - """ -module M - -let greet name = - printfn "Hello %s" name -""" - -let private greetWithLiteral = - """ -module M - -[] -let ExtractedConstant = "Hello %s" - -let greet name = - printfn ExtractedConstant name -""" - -[] -let ``Constant in a module-level declaration becomes a literal in front of it`` () = - Assert.Equal(greetWithLiteral, extracted extractToLiteral greet "\"Hello %s\"") - -[] -let ``Negative number becomes a literal`` () = - let code = - """ -module M - -let f () = g -1 -""" - - let expected = - """ -module M - -[] -let ExtractedConstant = -1 - -let f () = g ExtractedConstant -""" - - Assert.Equal(expected, extracted extractToLiteral code "-1") - -let private moduleConstant = - """ -module M - -let f () = g 42 -""" - -[] -let ``Literal is offered only for constants outside types`` () = - let memberConstant = - """ -module M - -type T() = - member _.M() = 42 -""" - - let expression = - """ -module M - -let f x = g (x + 1) -""" - - Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor moduleConstant "42") - Assert.Equal([ extractToLetBinding ], titlesFor memberConstant "42") - Assert.Equal([ extractToLetBinding ], titlesFor expression "x + 1") - -[] -let ``Decimal constant becomes a literal`` () = - let code = - """ -module M - -let f () = id 1.5m -""" - - let expected = - """ -module M - -[] -let ExtractedConstant = 1.5m - -let f () = id ExtractedConstant -""" - - let result = extracted extractToLiteral code "1.5m" - Assert.Equal(expected, result) - assertTypeChecks result - -[] -[] -[] -let ``Native integer does not become a literal`` (constant: string) = - let code = moduleConstant.Replace("42", constant) - - Assert.Equal([ extractToLetBinding ], titlesFor code constant) - Assert.Empty(titlesAt code (caretAt code constant)) - -[] -let ``Literal attribute is qualified where another LiteralAttribute hides it`` () = - let code = - """ -module M - -type LiteralAttribute(required: int) = - inherit System.Attribute() +let f () = id ExtractedConstant +""")>] +[] -let ``Literal is not offered where the declaration does not start its line`` () = - let code = - """ -namespace N -module M = let f () = 42 -""" - - Assert.Equal([ extractToLetBinding ], titlesFor code "42") - Assert.Empty(titlesAt code (caretAt code "42")) - -[] -let ``Operand of nameof is not extracted where the nameof expression is`` () = - let code = - """ -module M - -let name = nameof System.String.Empty -""" - - Assert.Empty(titlesFor code "System.String.Empty") - Assert.Equal([ extractToLetBinding ], titlesFor code "nameof System.String.Empty") - -let private mapIncrement = - """ -module M - -let f xs = - xs |> List.map (fun x -> x + 1) -""" - -[] -[] -[")>] -[ x")>] -[] -let ``Caret in the header of a parenthesized lambda extracts the lambda`` (marker: string) = - let expected = - """ -module M - -let f xs = - let extracted: int -> int = fun x -> x + 1 - xs |> List.map extracted -""" - - Assert.Equal([ extractToLetBinding ], titlesAt mapIncrement (caretAt mapIncrement marker)) - Assert.Equal(expected, extractedAt extractToLetBinding mapIncrement (caretAt mapIncrement marker)) - -[] -[] +[ int = (fun x -> x.Length) """, "(fun x -> x.Length)", """ -module M - let f: string -> int = let extracted: string -> int = fun x -> x.Length extracted """)>] -[ List.map (fun x -> [ x ]) """, "(fun x -> [ x ])", """ -module M - let f (xs: 'T list) = let extracted: 'T -> 'T list = fun x -> [ x ] xs |> List.map extracted """)>] -[ List.map (fun x -> [ x ]) """, "(fun x -> [ x ])", """ -module M - let f xs = let extracted = fun x -> [ x ] xs |> List.map extracted """)>] -[ int = (function Some x -> x.Length | None -> 0) """, "(function Some x -> x.Length | None -> 0)", """ -module M - let f: string option -> int = let extracted: string option -> int = function Some x -> x.Length | None -> 0 extracted """)>] -[ List.map (fun x -> x.Length) """, "fun x -> x.Length", """ -module M - let f (xs: string list) = let extracted: string -> int = fun x -> x.Length xs |> List.map (extracted) """)>] -[) = xs.ConvertAll(fun x -> x.Length) """, "(fun x -> x.Length)", """ -module M - let f (xs: System.Collections.Generic.List) = let extracted: string -> int = fun x -> x.Length xs.ConvertAll(extracted) """)>] -let ``Extracted function expression keeps the type its context gave it`` (code: string, selected: string, expected: string) = - let result = extracted extractToLetBinding code selected +let ``Extracted code type checks`` (_scenario: string, title: string, code: string, selected: string, expected: string) = + let code = fileOf code + let result = extractedAt title code (selectionOf code selected) - Assert.Equal(expected, result) + Assert.Equal(fileOf expected, result) assertTypeChecks result [] -[] -[] -[] -let ``No action without a selection outside a parenthesized lambda header`` (marker: string) = - Assert.Empty(titlesAt mapIncrement (caretAt mapIncrement marker)) - -[] -let ``No action without a selection on a lambda without parentheses`` () = - let code = +[] +[", mapIncrementExtracted)>] +[ x", mapIncrementExtracted)>] +[] +[] +[] +[] +let ``Caret extracts the lambda or constant around it`` (_scenario: string, title: string, code: string, marker: string, expected: string) = + let code = fileOf code + Assert.Equal(fileOf expected, extractedAt title code (caretAt code marker)) + +[] +[] +[] +[] +let ``Name does not collide with an assignment target from another file`` (_scenario: string, declaration: string, assignment: string) = + let dependency = """ -module M +module Dependency -let f = fun x -> x + 1 -""" +type Box = { mutable Value: int } - Assert.Empty(titlesAt code (caretAt code "fun")) +DECLARATION +""" + .Replace("DECLARATION", declaration) -[] -[] -[] -[] -let ``Constant at the caret becomes a literal without a selection`` (marker: string, offset: int) = - let caret = TextSpan(greet.IndexOf(marker, StringComparison.Ordinal) + offset, 0) - Assert.Equal(greetWithLiteral, extractedAt extractToLiteral greet caret) + let code = + """ +module M -[] -let ``Only the literal is offered without a selection`` () = - let caret = TextSpan(moduleConstant.IndexOf("42", StringComparison.Ordinal) + 1, 0) +open Dependency - Assert.Equal([ extractToLiteral ], titlesAt moduleConstant caret) +let f () = + ASSIGNMENT({ Value = 1 }).Value +""" + .Replace("ASSIGNMENT", assignment) -[] -[] -[] -[] -let ``No action without a selection`` (code: string, marker: string) = - Assert.Empty(titlesAt code (caretAt code marker)) + use context = TestContext.CreateWithCodeAndDependency code dependency -[] -[] +[] +[] -[] +[] +[] +[] +[] -[] +[] +[] +[] +[] +[] -[] +[] -[] +[ 0 -> v | _ -> 0 """, - "v > 0")>] -[] -[ 0", + "")>] +[] +[] -[] -[] -[ 0 -""", - "s.Length > 0")>] -[] +[] +[] +[ 0", "s.Length > 0", "")>] +[] -[] +[] type S = val mutable X: int @@ -857,19 +566,49 @@ let f () = (s).Inc() s.X """, - "(s)")>] -[] +[] -[] +[] +let ``Actions offered for a selection`` (_scenario: string, code: string, selected: string, titles: string) = + let code = fileOf code + Assert.Equal(titles, titlesAt code (selectionOf code selected)) -let f x = x + 1 +[] +[] +[", extractToLetBinding)>] +[ x", extractToLetBinding)>] +[] +[] +[] +[] +[] +[ x + 1", "fun", "")>] +[] +[] +[] +[] +[] +[] -let ``No action`` (code: string, selected: string) = Assert.Empty(titlesFor code selected) +let ``Actions offered at a caret`` (_scenario: string, code: string, marker: string, titles: string) = + let code = fileOf code + Assert.Equal(titles, titlesAt code (caretAt code marker)) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs index cd385130efe..2a75ccb9085 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs @@ -1,13 +1,15 @@ -module FSharp.Editor.Tests.Refactors.RefactorTestFramework +module FSharp.Editor.Tests.Refactors.RefactorTestFramework open System -open System.Linq open System.Collections.Generic open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks +open Xunit + open FSharp.Test.ProjectGeneration open FSharp.Editor.Tests.Helpers @@ -98,7 +100,7 @@ let tryGetRefactoringActions (code: string) (cursorPosition) (context: TestConte |> CancellableTask.startWithoutCancellation |> fun task -> task.Result -let private refactoringActionsAt (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: CodeRefactoringProvider) = +let tryGetRefactoringActionsForSpan (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: CodeRefactoringProvider) = let actions = List() let existingDocument = RoslynTestHelpers.GetLastDocument context.Solution context.Solution <- context.Solution.WithDocumentText(existingDocument.Id, SourceText.From(code)) @@ -110,12 +112,9 @@ let private refactoringActionsAt (code: string) (span: TextSpan) (context: TestC refactorProvider.ComputeRefactoringsAsync(refactoringContext).GetAwaiter().GetResult() actions -let tryGetRefactoringActionsForSpan (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: #CodeRefactoringProvider) = - refactoringActionsAt code span context refactorProvider - -let refactorSpan (code: string) (span: TextSpan) (title: string) (context: TestContext) (refactorProvider: #CodeRefactoringProvider) = +let refactorSpan (code: string) (span: TextSpan) (title: string) (context: TestContext) (refactorProvider: CodeRefactoringProvider) = let action = - refactoringActionsAt code span context refactorProvider + tryGetRefactoringActionsForSpan code span context refactorProvider |> Seq.find (fun action -> String.Equals(action.Title, title, StringComparison.Ordinal)) for operation in action.GetOperationsAsync(context.CancellationToken) |> GetTaskResult do @@ -124,3 +123,37 @@ let refactorSpan (code: string) (span: TextSpan) (title: string) (context: TestC context.Solution <- applyChanges.ChangedSolution RoslynTestHelpers.GetLastDocument context.Solution + +let selectionOf (code: string) (selected: string) = + TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) + +let caretAt (code: string) (marker: string) = + let offset = max 0 (marker.IndexOf '|') + TextSpan(code.IndexOf(marker.Replace("|", ""), StringComparison.Ordinal) + offset, 0) + +let fileOf (declarations: string) = + let text = declarations.Trim('\r', '\n') + + if + text.StartsWith("module ", StringComparison.Ordinal) + || text.StartsWith("namespace ", StringComparison.Ordinal) + then + declarations + else + let lineBreak = if declarations.IndexOf '\r' >= 0 then "\r\n" else "\n" + $"{lineBreak}module M{lineBreak}{lineBreak}{text}{lineBreak}" + +let refactoredText (provider: CodeRefactoringProvider) (context: TestContext) (title: string) (code: string) (span: TextSpan) = + let document = refactorSpan code span title context provider + + let parseResults = + document.GetFSharpParseResultsAsync "test" + |> CancellableTask.runSynchronouslyWithoutCancellation + + Assert.Empty(parseResults.Diagnostics) + (document.GetTextAsync() |> GetTaskResult).ToString() + +let offeredTitles (provider: CodeRefactoringProvider) (context: TestContext) (code: string) (span: TextSpan) = + tryGetRefactoringActionsForSpan code span context provider + |> Seq.map _.Title + |> String.concat ";" From 725ed0b677317a8193b503a79357cdcfce28c38e Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 29 Oct 2026 22:15:27 +0100 Subject: [PATCH 12/31] Bring the rebased branch to what it had before the rebase Replaying the branch onto main left some files short of the branch's own content; this restores it. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/.VisualStudio/18.vNext.md | 2 -- .../src/FSharp.Editor/FSharp.Editor.resx | 1 + .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 19 ++++++++++--------- .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 19 ++++++++++--------- .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 19 ++++++++++--------- .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 19 ++++++++++--------- .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 19 ++++++++++--------- .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 19 ++++++++++--------- .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 19 ++++++++++--------- .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 19 ++++++++++--------- .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 19 ++++++++++--------- .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 19 ++++++++++--------- .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 19 ++++++++++--------- .../xlf/FSharp.Editor.zh-Hans.xlf | 19 ++++++++++--------- .../xlf/FSharp.Editor.zh-Hant.xlf | 19 ++++++++++--------- 15 files changed, 131 insertions(+), 119 deletions(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 514a77b782f..164124fa673 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -7,8 +7,6 @@ * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) -* **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration. -* **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration, also with just the caret inside the constant. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx index df31b5b5295..c10dcdea2dd 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx @@ -372,6 +372,7 @@ Use live (unsaved) buffers for analysis {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + Extract to let binding diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index 621a7cc6182..023d23d0d04 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -111,15 +111,6 @@ Navrhnout názvy pro nerozpoznané identifikátory; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Zobrazit poznámky v Rychlých informacích Uzavřít výraz do závorek + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf index 55ff3d83516..f4af5ecf92f 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -111,15 +111,6 @@ Namen für nicht aufgelöste Bezeichner vorschlagen; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Hinweise in QuickInfo anzeigen Ausdruck in Klammern einschließen + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index 05884c36794..e7e8fd4cd51 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -111,15 +111,6 @@ Sugerir nombres para identificadores sin resolver; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Mostrar comentarios en Información rápida Encapsular la expresión entre paréntesis + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index a704b5c0ee9..f8b826e53de 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -111,15 +111,6 @@ Suggérer des noms pour les identificateurs non résolus ; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Afficher les notes dans Info express Mettre l'expression entre parenthèses + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index ab458f374d6..b7245caa860 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -111,15 +111,6 @@ Suggerisci i nomi per gli identificatori non risolti; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Mostra i commenti in Informazioni rapide Racchiudere l'espressione tra parentesi + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index 6d939190627..690e6d0a2cb 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -111,15 +111,6 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ F# 構文規則に準拠した改行を追加して、署名を指定された 式をかっこで囲む + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index 8c2e6ff8120..a54784acd32 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -111,15 +111,6 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ F# 구문 규칙에 맞는 줄 바꿈을 추가하여 지정된 너비에 시그 식을 괄호로 래핑 + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index b905856be5f..d153a70184e 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -111,15 +111,6 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Pokaż uwagi w szybkich informacjach Ujmij wyrażenie w nawiasy + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf index b6d772e6ecc..e3e2499cd17 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf @@ -111,15 +111,6 @@ Sugerir nomes para identificadores não resolvidos; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Mostrar os comentários nas Informações Rápidas Coloque a expressão entre parênteses + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 20c238f2e3f..e68bc0fa449 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -111,15 +111,6 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Show remarks in Quick Info Заключите выражение в круглые скобки. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index 9bd18a8e47a..e2560caac91 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -111,15 +111,6 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Açıklamaları Hızlı Bilgide göster İfadeyi parantez içinde sarmalayın + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf index 16ac685c747..084852fb37c 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf @@ -111,15 +111,6 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Show remarks in Quick Info 将表达式用括号括起来 + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf index 1bcc227c0d8..26ed7e45524 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf @@ -111,15 +111,6 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - F# Disposable Values (locals) @@ -449,6 +440,16 @@ Show remarks in Quick Info 使用括弧包裝運算式 + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + + \ No newline at end of file From b2a3459300b475aace318365a342eb0386444144 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 01:32:25 +0200 Subject: [PATCH 13/31] Add refactorings that extract a selected expression into a let binding 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 [] 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) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../Refactor/ExtractLetBinding.fs | 99 +-- .../Refactor/RefactoringHelpers.fs | 636 +++++----------- .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 9 + .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 9 + .../xlf/FSharp.Editor.zh-Hans.xlf | 9 + .../xlf/FSharp.Editor.zh-Hant.xlf | 9 + .../Refactors/ExtractLetBindingTests.fs | 708 ++++-------------- .../Refactors/RefactorTestFramework.fs | 69 +- 18 files changed, 524 insertions(+), 1106 deletions(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 164124fa673..913f8811175 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -7,6 +7,7 @@ * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) +* **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index bab5d714b4f..947e5c4871a 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -4,7 +4,11 @@ namespace Microsoft.VisualStudio.FSharp.Editor open System.Composition +open Microsoft.CodeAnalysis.CodeActions open Microsoft.CodeAnalysis.CodeRefactorings +open Microsoft.CodeAnalysis.Formatting +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor.Telemetry open FSharp.Compiler.Syntax @@ -15,59 +19,66 @@ open CancellableTasks type internal FSharpExtractLetBindingRefactoring [] () = inherit CodeRefactoringProvider() - static let letBindingChanges (extraction: Extraction) = - match anchorsOf extraction.Target.Expr extraction.Target.Path with - | [] -> ValueNone - | anchor :: _ -> - let name = uniqueName "extracted" extraction.Names + static let register + (context: CodeRefactoringContext) + (sourceText: SourceText) + (title: string) + (kind: string) + (changes: TextChange list) + = + let changedDocument = + cancellableTask { + TelemetryReporter.ReportSingleEvent( + TelemetryEvents.RefactoringActivated, + [| "name", box (nameof FSharpExtractLetBindingRefactoring); "kind", box kind |] + ) + + return context.Document.WithText(sourceText.WithChanges changes) + } + + context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) - let header = - match tryFunctionTypeText extraction.CheckResults extraction.Target with - | ValueSome functionType -> $"let {name}: {functionType}" - | ValueNone -> $"let {name}" - - tryDeclareInFront extraction anchor header name - - static let literalChanges (extraction: Extraction) = - let constant = - match extraction.Target.Expr with - | SynExpr.Paren(expr = inner) -> inner - | expr -> expr + override _.ComputeRefactoringsAsync context = + cancellableTask { + let document = context.Document - if isLiteralConstant constant then - let name = uniqueName "ExtractedConstant" extraction.Names + if not (context.Span.IsEmpty || document.IsFSharpSignatureFile) then + let! cancellationToken = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync cancellationToken + let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractLetBindingRefactoring) - let attribute = - literalAttributeAt extraction.CheckResults extraction.Target.Expr.Range.Start + match tryExtractionTarget sourceText parseResults.ParseTree context.Span with + | ValueNone -> () + | ValueSome target -> + let! options = document.GetOptionsAsync cancellationToken - tryDeclareInFrontOfModuleLet extraction [ $"[<{attribute}>]" ] $"let {name}" name - else - ValueNone + let indentSize = + options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) - static let offer (isSelected: bool) (extraction: Extraction) = - if isSelected then - registerRefactoring extraction (SR.ExtractToLetBinding()) "let" (letBindingChanges extraction) + let names = usedNames parseResults.ParseTree + let literalLines = linesInsideLiterals parseResults.ParseTree - registerRefactoring extraction (SR.ExtractToLiteral()) "literal" (literalChanges extraction) + match anchorsOf target.Expr target.Path with + | anchor :: _ -> + let name = uniqueName "extracted" names - override _.ComputeRefactoringsAsync context = - cancellableTask { - let document = context.Document + match tryDeclareInFront sourceText target anchor $"let {name}" name indentSize literalLines with + | ValueSome changes -> register context sourceText (SR.ExtractToLetBinding()) "let" changes + | ValueNone -> () + | [] -> () - if not document.IsFSharpSignatureFile then - let! sourceText = document.GetTextAsync context.CancellationToken - let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractLetBindingRefactoring) - let parseTree = parseResults.ParseTree - let selected = trySelectedTarget sourceText parseTree context.Span + let constant = + match target.Expr with + | SynExpr.Paren(expr = inner) -> inner + | expr -> expr - let target = - selected - |> ValueOption.orElseWith (fun () -> tryConstantAtCaret sourceText parseTree context.Span) + if isLiteralConstant constant then + let name = uniqueName "ExtractedConstant" names - match target with - | ValueNone -> () - | ValueSome target -> - let! extraction = prepareExtraction context (nameof FSharpExtractLetBindingRefactoring) sourceText parseTree target - offer selected.IsSome extraction + match + tryDeclareInFrontOfModuleLet sourceText target [ "[]" ] $"let {name}" name indentSize literalLines + with + | ValueSome changes -> register context sourceText (SR.ExtractToLiteral()) "literal" changes + | ValueNone -> () } |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index 8158df27329..f69c579a364 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -6,49 +6,31 @@ open System open System.Collections.Generic open System.Text -open Microsoft.CodeAnalysis.CodeActions -open Microsoft.CodeAnalysis.CodeRefactorings -open Microsoft.CodeAnalysis.Formatting open Microsoft.CodeAnalysis.Text -open Microsoft.VisualStudio.FSharp.Editor.Telemetry -open FSharp.Compiler.CodeAnalysis -open FSharp.Compiler.Symbols open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTrivia open FSharp.Compiler.Text -open FSharp.Compiler.Tokenization - -open CancellableTasks +/// A place in front of which a declaration used by the selected expression can be inserted. [] type Anchor = + /// An expression that is a statement of its block. | Statement of start: pos - | Clause of keywordEnd: pos * body: range * offsideColumn: int + /// The body of a binding, match clause, branch or lambda, after the keyword ending at keywordEnd. + | Clause of keywordEnd: pos * body: range [] type ExtractionTarget = { Expr: SynExpr Path: SyntaxVisitorPath - Extracted: TextSpan + /// The selection without its parentheses when the selected expression is parenthesized. + Content: TextSpan + /// The text the new name replaces; the parentheses of a method call stay. Replaced: TextSpan } -[] -type Extraction = - { - Context: CodeRefactoringContext - Provider: string - SourceText: SourceText - Target: ExtractionTarget - CheckResults: FSharpCheckFileResults - IndentSize: int - LiteralLines: HashSet - LineBreak: string - Names: HashSet - } - let private isSame (expr: SynExpr) (other: SynExpr) = obj.ReferenceEquals(expr, other) let private hasName (name: string) (ident: Ident) = @@ -61,8 +43,6 @@ let positionOf (sourceText: SourceText) (offset: int) = let linePosition = sourceText.Lines.GetLinePosition offset Position.mkPos (Line.fromZ linePosition.Line) linePosition.Character -let padding (width: int) = String(' ', width) - let leadingSpaces (sourceText: SourceText) (line: TextLine) = let mutable position = line.Start @@ -73,113 +53,41 @@ let leadingSpaces (sourceText: SourceText) (line: TextLine) = let lineBreakOf (sourceText: SourceText) = sourceText.Lines - |> Seq.tryFindV (fun line -> line.EndIncludingLineBreak > line.End) - |> ValueOption.map (fun line -> sourceText.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak))) - |> ValueOption.defaultValue Environment.NewLine + |> Seq.tryFind (fun line -> line.EndIncludingLineBreak > line.End) + |> Option.map (fun line -> sourceText.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak))) + |> Option.defaultValue Environment.NewLine -let private isLineLeading (sourceText: SourceText) (position: pos) = +let isLineLeading (sourceText: SourceText) (position: pos) = let line = sourceText.Lines[Line.toZ position.Line] leadingSpaces sourceText line = position.Column -let private onlyClosersFollow (sourceText: SourceText) (position: pos) = +/// Whether only closing brackets and a line comment follow the position on its line. +let restOfLineIsClosers (sourceText: SourceText) (position: pos) = let line = sourceText.Lines[Line.toZ position.Line] - let isCloser c = - Char.IsWhiteSpace c || c = ')' || c = ']' || c = '}' || c = '|' - - let mutable offset = line.Start + position.Column - - while offset < line.End && isCloser (sourceText[offset]) do - offset <- offset + 1 - - offset = line.End - || (offset + 1 < line.End - && sourceText[offset] = '/' - && sourceText[offset + 1] = '/') - -let private startsInsideString (state: FSharpTokenizerLexState) = - match FSharpLineTokenizer.ColorStateOfLexState state with - | FSharpTokenizerColorState.String - | FSharpTokenizerColorState.VerbatimString - | FSharpTokenizerColorState.TripleQuoteString -> true - // The public enum does not name the interpolated string states. - | color -> not (Enum.IsDefined(typeof, color)) - -let private isSkipped (state: FSharpTokenizerLexState) = - match FSharpLineTokenizer.ColorStateOfLexState state with - | FSharpTokenizerColorState.IfDefSkip - | FSharpTokenizerColorState.EndLineThenSkip -> true - | _ -> false - -let private startsWithDirective (code: ReadOnlySpan) (keyword: string) = - code.StartsWith(keyword.AsSpan(), StringComparison.Ordinal) - && (code.Length = keyword.Length - || Char.IsWhiteSpace code[keyword.Length] - || code[keyword.Length] = '/') - -let private isSkippedDirective (line: string) = - let code = line.AsSpan().TrimStart() - - startsWithDirective code "#if" - || startsWithDirective code "#elif" - || startsWithDirective code "#else" - || startsWithDirective code "#endif" - -let private scanLine (tokenizer: FSharpSourceTokenizer) (line: string) (state: FSharpTokenizerLexState) = - let lineTokenizer = tokenizer.CreateLineTokenizer line - - let rec loop state = - match lineTokenizer.ScanToken state with - | Some _, next -> loop next - | None, next -> next - - loop state - -let private linesInsideInactiveLiterals (sourceText: SourceText) (defines: string list) (langVersion: string) (lines: HashSet) = - let fileTokenizer = FSharpSourceTokenizer(defines, None, Some langVersion) - let branchTokenizer = FSharpSourceTokenizer([], None, Some langVersion) - let mutable fileState = FSharpTokenizerLexState.Initial - let mutable branchState = FSharpTokenizerLexState.Initial - - for line in sourceText.Lines do - let text = line.ToString() - let inInactiveBranch = isSkipped fileState && not (isSkippedDirective text) - - if inInactiveBranch && startsInsideString branchState then - lines.Add line.LineNumber |> ignore - - branchState <- - if inInactiveBranch then - scanLine branchTokenizer text branchState - else - FSharpTokenizerLexState.Initial - - fileState <- scanLine fileTokenizer text fileState - -let private linesAfterFirst (m: range) = - seq { for line in m.StartLine + 1 .. m.EndLine -> Line.toZ line } - -let linesInsideLiterals (sourceText: SourceText) (parseTree: ParsedInput) (defines: string list) (langVersion: string) = - let lines = - (HashSet(), parseTree) - ||> ParsedInput.fold (fun lines _ node -> - match node with - | SyntaxNode.SynExpr(SynExpr.Const(range = m)) - | SyntaxNode.SynExpr(SynExpr.InterpolatedString(range = m)) - | SyntaxNode.SynPat(SynPat.Const(range = m)) -> lines.UnionWith(linesAfterFirst m) - | _ -> () + let rest = + sourceText.ToString(TextSpan.FromBounds(line.Start + position.Column, line.End)) - lines) + let code = + match rest.IndexOf("//", StringComparison.Ordinal) with + | -1 -> rest + | comment -> rest.Substring(0, comment) - let directives = - match parseTree with - | ParsedInput.ImplFile file -> file.Trivia.ConditionalDirectives - | ParsedInput.SigFile file -> file.Trivia.ConditionalDirectives + code + |> Seq.forall (fun c -> Char.IsWhiteSpace c || c = ')' || c = ']' || c = '}' || c = '|') - if not directives.IsEmpty then - linesInsideInactiveLiterals sourceText defines langVersion lines +let linesInsideLiterals (parseTree: ParsedInput) = + (HashSet(), parseTree) + ||> ParsedInput.fold (fun lines _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Const(range = m)) + | SyntaxNode.SynExpr(SynExpr.InterpolatedString(range = m)) + | SyntaxNode.SynPat(SynPat.Const(range = m)) -> + for line in m.StartLine + 1 .. m.EndLine do + lines.Add(Line.toZ line) |> ignore + | _ -> () - lines + lines) let usedNames (parseTree: ParsedInput) = (HashSet(StringComparer.Ordinal), parseTree) @@ -187,10 +95,8 @@ let usedNames (parseTree: ParsedInput) = match node with | SyntaxNode.SynExpr(SynExpr.Ident ident) | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _))) - | SyntaxNode.SynExpr(SynExpr.LongIdentSet(longDotId = SynLongIdent(id = ident :: _))) - | SyntaxNode.SynExpr(SynExpr.NamedIndexedPropertySet(longDotId = SynLongIdent(id = ident :: _))) - | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) -> names.Add ident.idText |> ignore - | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = idents))) -> names.UnionWith(idents |> Seq.map _.idText) + | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) + | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = ident :: _))) -> names.Add ident.idText |> ignore | _ -> () names) @@ -199,9 +105,10 @@ let uniqueName (baseName: string) (used: HashSet) = Seq.initInfinite (fun i -> if i = 0 then baseName else $"{baseName}{i}") |> Seq.find (used.Contains >> not) -let private tryIndentedText (extraction: Extraction) (column: int) = - let sourceText = extraction.SourceText - let span = extraction.Target.Extracted +/// The text of the span with every line after the first moved by as many columns as the first line moves to reach +/// column. Lines inside multi-line string literals are kept as they are; a line that would need a negative indentation +/// makes the whole text unavailable. +let tryIndentedText (sourceText: SourceText) (span: TextSpan) (column: int) (literalLines: HashSet) = let lines = sourceText.Lines let first = lines.GetLineFromPosition span.Start let last = lines.GetLineFromPosition span.End @@ -221,7 +128,7 @@ let private tryIndentedText (extraction: Extraction) (column: int) = sourceText.ToString(TextSpan.FromBounds(line.Start, min line.End span.End)) let moved = - match extraction.LiteralLines.Contains lineNumber with + match literalLines.Contains lineNumber with | true -> ValueSome content | false when String.IsNullOrWhiteSpace content -> ValueSome content | false when shift >= 0 -> ValueSome(String(' ', shift) + content) @@ -273,7 +180,7 @@ let private isMethodArgument (path: SyntaxVisitorPath) = | SyntaxNode.SynExpr(SynExpr.Tuple _) :: SyntaxNode.SynExpr(SynExpr.Paren _) :: call -> isCall call | _ -> false -let rec private isExtractableShape (expr: SynExpr) (path: SyntaxVisitorPath) = +let private isExtractableShape (expr: SynExpr) (path: SyntaxVisitorPath) = match expr with | SynExpr.App(isInfix = true) | SynExpr.Ident _ @@ -306,7 +213,6 @@ let rec private isExtractableShape (expr: SynExpr) (path: SyntaxVisitorPath) = | SynExpr.DotIndexedSet _ | SynExpr.NamedIndexedPropertySet _ | SynExpr.DotNamedIndexedPropertySet _ -> false - | SynExpr.Paren(expr = inner) -> isExtractableShape inner (SyntaxNode.SynExpr expr :: path) | SynExpr.LetOrUse letOrUse -> not letOrUse.IsBang | SynExpr.Tuple _ -> not (isMethodArgument path) | SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true; funcExpr = equals; argExpr = SynExpr.Ident _)) when @@ -319,15 +225,11 @@ let private isInExcludedContext (expr: SynExpr) (path: SyntaxVisitorPath) = let rec loop (child: SyntaxNode) (path: SyntaxVisitorPath) = match path, child with | [], _ -> false - | SyntaxNode.SynExpr(SynExpr.InterpolatedString _ | SynExpr.Quote _ | SynExpr.Lazy _ | SynExpr.Assert _) :: _, _ -> true + | SyntaxNode.SynExpr(SynExpr.InterpolatedString _ | SynExpr.Quote _ | SynExpr.Lazy _) :: _, _ -> true | SyntaxNode.SynExpr(SynExpr.While(whileExpr = condition) | SynExpr.WhileBang(whileExpr = condition)) :: _, SyntaxNode.SynExpr expr when isSame condition expr -> true - | SyntaxNode.SynExpr(SynExpr.App(funcExpr = SynExpr.Ident operator; argExpr = operand)) :: _, SyntaxNode.SynExpr expr when - hasName "nameof" operator && isSame operand expr - -> - true | SyntaxNode.SynMatchClause(SynMatchClause(whenExpr = Some guard)) :: _, SyntaxNode.SynExpr expr when isSame guard expr -> true | SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true; funcExpr = operator); argExpr = right)) :: _, SyntaxNode.SynExpr expr when @@ -339,7 +241,8 @@ let private isInExcludedContext (expr: SynExpr) (path: SyntaxVisitorPath) = loop (SyntaxNode.SynExpr expr) path -let private hasOrphanedComputationConstruct (expr: SynExpr) = +/// Whether the expression contains a computation expression construct outside any computation expression of its own. +let private hasStatementOnlyConstruct (expr: SynExpr) = let containers = ResizeArray() let statements = ResizeArray() @@ -361,37 +264,6 @@ let private hasOrphanedComputationConstruct (expr: SynExpr) = |> Seq.exists (fun container -> Range.rangeContainsRange container statement) )) -let private targetOf (sourceText: SourceText) (expr: SynExpr) (path: SyntaxVisitorPath) = - let span = textSpanOf sourceText expr.Range - - let extracted = - match expr with - | SynExpr.Paren(expr = inner) -> textSpanOf sourceText inner.Range - | _ -> span - - let replaced = - match expr with - | SynExpr.Paren _ when isCall path -> extracted - | _ -> span - - { - Expr = expr - Path = path - Extracted = extracted - Replaced = replaced - } - -let private exprSpanning (start: pos) (finish: pos) (path: SyntaxVisitorPath) (node: SyntaxNode) = - match node with - | SyntaxNode.SynExpr expr when Position.posEq expr.Range.Start start && Position.posEq expr.Range.End finish -> Some(expr, path) - | _ -> None - -let private crossesDirective (directives: ConditionalDirectiveTrivia list) (start: pos) (finish: pos) = - directives - |> List.exists (fun directive -> - let line = directiveLine directive - line >= start.Line && line <= finish.Line) - let tryExtractionTarget (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = let span = trimmed sourceText selection @@ -400,75 +272,53 @@ let tryExtractionTarget (sourceText: SourceText) (parseTree: ParsedInput) (selec let start = positionOf sourceText span.Start let finish = positionOf sourceText span.End - match (start, parseTree) ||> ParsedInput.tryPickLast (exprSpanning start finish) with + let crossesDirective = + file.Trivia.ConditionalDirectives + |> List.exists (fun directive -> + let line = directiveLine directive + line >= start.Line && line <= finish.Line) + + let exact = + (start, parseTree) + ||> ParsedInput.tryPickLast (fun path node -> + match node with + | SyntaxNode.SynExpr expr when Position.posEq expr.Range.Start start && Position.posEq expr.Range.End finish -> + Some(expr, path) + | _ -> None) + + match exact with | Some(expr, path) when - not (crossesDirective file.Trivia.ConditionalDirectives start finish) + not crossesDirective && isExtractableShape expr path && not (isInExcludedContext expr path) - && not (hasOrphanedComputationConstruct expr) + && not (hasStatementOnlyConstruct expr) -> - ValueSome(targetOf sourceText expr path) + let content = + match expr with + | SynExpr.Paren(expr = inner) -> textSpanOf sourceText inner.Range + | _ -> span + + let replaced = + match expr with + | SynExpr.Paren _ when isCall path -> content + | _ -> span + + ValueSome + { + Expr = expr + Path = path + Content = content + Replaced = replaced + } | _ -> ValueNone | _ -> ValueNone -let isLiteralConstant (expr: SynExpr) = - match expr with - | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.Single _ | SynConst.Double _ | SynConst.Decimal _ | SynConst.Char _ | SynConst.String _), - _) -> true - | _ -> false - -let private constantAt (position: pos) (path: SyntaxVisitorPath) (node: SyntaxNode) = - match node with - | SyntaxNode.SynExpr(SynExpr.Const(range = m) as expr) when - isLiteralConstant expr - && Position.posGeq position m.Start - && Position.posGeq m.End position - && not (isInExcludedContext expr path) - -> - Some(expr, path) - | _ -> None - -let tryConstantAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = - let position = positionOf sourceText selection.Start - - if selection.IsEmpty then - match (position, parseTree) ||> ParsedInput.tryPickLast (constantAt position) with - | Some(expr, path) -> ValueSome(targetOf sourceText expr path) - | None -> ValueNone - else - ValueNone - -let private parenthesizedLambdaHeaderAt (position: pos) (_: SyntaxVisitorPath) (node: SyntaxNode) = - match node with - | SyntaxNode.SynExpr(SynExpr.Paren(expr = SynExpr.Lambda(parsedData = Some _; trivia = { ArrowRange = Some arrow }) as lambda; range = m)) when - Position.posGeq position lambda.Range.Start - && Position.posGeq arrow.End position - -> - Some m - | _ -> None - -let private tryParenthesizedLambdaAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = - let position = positionOf sourceText caret - - match - (position, parseTree) - ||> ParsedInput.tryPickLast (parenthesizedLambdaHeaderAt position) - with - | Some m -> tryExtractionTarget sourceText parseTree (textSpanOf sourceText m) - | None -> ValueNone - -let trySelectedTarget (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = - if selection.IsEmpty then - tryParenthesizedLambdaAtCaret sourceText parseTree selection.Start - else - tryExtractionTarget sourceText parseTree selection - let private isFunctionBinding (binding: SynBinding) = match binding with | SynBinding(headPat = SynPat.LongIdent(argPats = SynArgPats.Pats(_ :: _))) -> true | _ -> false -let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (outer: SyntaxVisitorPath) (offsideColumn: int) = +let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: SyntaxNode voption) = match parent, child with | SyntaxNode.SynExpr(SynExpr.Sequential _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse), SyntaxNode.SynExpr expr when isSame letOrUse.Body expr -> @@ -476,192 +326,163 @@ let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (outer: SyntaxVisi | 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)), SyntaxNode.SynExpr expr when isSame body expr -> ValueSome(Anchor.Statement expr.Range.Start) - | SyntaxNode.SynExpr(SynExpr.TryFinally(finallyExpr = body)), SyntaxNode.SynExpr expr when isSame body expr -> - ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynModule(SynModuleDecl.Expr _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynBinding(SynBinding(expr = rhs; trivia = trivia) as binding), SyntaxNode.SynExpr expr when isSame rhs expr -> - match outer, trivia.EqualsRange with - | SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse) :: _, _ when not letOrUse.IsRecursive && not (isFunctionBinding binding) -> + match grandparent, trivia.EqualsRange with + | ValueSome(SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse)), _ when not letOrUse.IsRecursive && not (isFunctionBinding binding) -> ValueSome(Anchor.Statement letOrUse.Range.Start) - | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range, offsideColumn)) + | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range)) | _ -> ValueNone - | SyntaxNode.SynMatchClause(SynMatchClause(resultExpr = body; trivia = { ArrowRange = Some arrow })), SyntaxNode.SynExpr expr when - isSame body expr - -> - ValueSome(Anchor.Clause(arrow.End, expr.Range, offsideColumn)) - | SyntaxNode.SynExpr(SynExpr.IfThenElse(thenExpr = body; trivia = trivia)), SyntaxNode.SynExpr expr when isSame body expr -> - ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range, offsideColumn)) - | SyntaxNode.SynExpr(SynExpr.IfThenElse(elseExpr = Some body; trivia = { ElseKeyword = Some elseKeyword })), SyntaxNode.SynExpr expr when - isSame body expr - -> - ValueSome(Anchor.Clause(elseKeyword.End, expr.Range, offsideColumn)) - | SyntaxNode.SynExpr(SynExpr.Lambda(parsedData = Some(_, body); trivia = { ArrowRange = Some arrow })), SyntaxNode.SynExpr expr when - isSame body expr - -> - ValueSome(Anchor.Clause(arrow.End, expr.Range, offsideColumn)) + | SyntaxNode.SynMatchClause(SynMatchClause(resultExpr = result; trivia = trivia)), SyntaxNode.SynExpr expr when isSame result expr -> + match trivia.ArrowRange with + | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range)) + | None -> ValueNone + | SyntaxNode.SynExpr(SynExpr.IfThenElse(thenExpr = thenExpr; elseExpr = elseExpr; trivia = trivia)), SyntaxNode.SynExpr expr -> + match elseExpr, trivia.ElseKeyword with + | _ when isSame thenExpr expr -> ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range)) + | Some elseExpr, Some elseKeyword when isSame elseExpr expr -> ValueSome(Anchor.Clause(elseKeyword.End, expr.Range)) + | _ -> ValueNone + | SyntaxNode.SynExpr(SynExpr.Lambda(parsedData = Some(_, body); trivia = trivia)), SyntaxNode.SynExpr expr when isSame body expr -> + match trivia.ArrowRange with + | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range)) + | None -> ValueNone | _ -> ValueNone -let private (|ElifBranch|_|) (path: SyntaxVisitorPath) = - match path with - | SyntaxNode.SynExpr(SynExpr.IfThenElse _ as nested) :: (SyntaxNode.SynExpr(SynExpr.IfThenElse(elseExpr = Some elseExpr)) :: _ as enclosingIf) when - isSame elseExpr nested - -> - Some enclosingIf - | _ -> None - -let rec private offsideColumnOf (path: SyntaxVisitorPath) = - match path with - | [] -> 0 - | ElifBranch enclosingIf -> offsideColumnOf enclosingIf - | SyntaxNode.SynBinding(SynBinding(trivia = trivia)) :: _ -> trivia.LeadingKeyword.Range.StartColumn - | SyntaxNode.SynExpr(SynExpr.Match(range = m) | SynExpr.MatchBang(range = m) | SynExpr.TryWith(range = m) | SynExpr.TryFinally(range = m) | SynExpr.IfThenElse( - range = m) | SynExpr.For(range = m) | SynExpr.ForEach(range = m) | SynExpr.While(range = m)) :: _ -> m.StartColumn - | _ :: rest -> offsideColumnOf rest - +/// The places a declaration used by the expression can go, innermost first. let anchorsOf (expr: SynExpr) (path: SyntaxVisitorPath) = let rec loop (child: SyntaxNode) (path: SyntaxVisitorPath) = match path with | [] -> [] - | parent :: outer -> - match anchorOf child parent outer (offsideColumnOf path) with - | ValueSome anchor -> anchor :: loop parent outer - | ValueNone -> loop parent outer + | parent :: rest -> + let grandparent = + match rest with + | node :: _ -> ValueSome node + | [] -> ValueNone - loop (SyntaxNode.SynExpr expr) path + match anchorOf child parent grandparent with + | ValueSome anchor -> anchor :: loop parent rest + | ValueNone -> loop parent rest -let rec private hasInferredTypeVariable (ty: FSharpType) = - if ty.IsGenericParameter then - ty.GenericParameter.IsCompilerGenerated - else - not ty.IsMeasureType - && ty.GenericArguments |> Seq.exists hasInferredTypeVariable - -let tryFunctionTypeText (checkResults: FSharpCheckFileResults) (target: ExtractionTarget) = - let parenthesizedFunction = - match target.Expr, target.Path with - | SynExpr.Paren(expr = SynExpr.Lambda _ | SynExpr.MatchLambda _; range = m), _ - | (SynExpr.Lambda _ | SynExpr.MatchLambda _), SyntaxNode.SynExpr(SynExpr.Paren(range = m)) :: _ -> ValueSome m - | _ -> ValueNone - - parenthesizedFunction - |> ValueOption.bind (fun m -> - match checkResults.TryGetCapturedType m, checkResults.TryGetCapturedDisplayContext m with - | Some ty, Some displayContext when ty.StripAbbreviations().IsFunctionType && not (hasInferredTypeVariable ty) -> - ValueSome(ty.Format displayContext) - | _ -> ValueNone) - -let literalAttributeAt (checkResults: FSharpCheckFileResults) (position: pos) = - let attribute = - checkResults.ProjectContext.GetReferencedAssemblies() - |> List.tryPick _.Contents.FindEntityByPath([ "Microsoft"; "FSharp"; "Core"; "LiteralAttribute" ]) + loop (SyntaxNode.SynExpr expr) path - match attribute with - | Some attribute when not (checkResults.IsRelativeNameResolvableFromSymbol(position, [], attribute)) -> - "global.Microsoft.FSharp.Core.Literal" - | _ -> "Literal" +let isLiteralConstant (expr: SynExpr) = + match expr with + | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.IntPtr _ | SynConst.UIntPtr _ | SynConst.Single _ | SynConst.Double _ | SynConst.Char _ | SynConst.String _), + _) -> true + | _ -> false -let private tryEnclosingModuleLet (path: SyntaxVisitorPath) = +/// The module-level let declaration containing the path, with its first binding. +let tryEnclosingModuleLet (path: SyntaxVisitorPath) = path |> List.tryPick (function | SyntaxNode.SynModule(SynModuleDecl.Let(bindings = binding :: _; range = m)) -> Some struct (binding, m) | _ -> None) -let tryDeclaration (extraction: Extraction) (header: string) (bodyColumn: int) = - let sourceText = extraction.SourceText - let lines = sourceText.Lines - let extracted = extraction.Target.Extracted +let private padding (width: int) = String(' ', width) - tryIndentedText extraction bodyColumn +/// `header = `, with a multi-line content starting on its own line at bodyColumn. +let private tryDeclaration (sourceText: SourceText) (content: TextSpan) (header: string) (bodyColumn: int) literalLines lineBreak = + tryIndentedText sourceText content bodyColumn literalLines |> ValueOption.map (fun body -> - if lines.GetLineFromPosition(extracted.Start).LineNumber = lines.GetLineFromPosition(extracted.End).LineNumber then + let lines = sourceText.Lines + + if lines.GetLineFromPosition(content.Start).LineNumber = lines.GetLineFromPosition(content.End).LineNumber then $"{header} = {body}" else - $"{header} ={extraction.LineBreak}{padding bodyColumn}{body}") - -let private tryInFrontOfLine (extraction: Extraction) (start: pos) (header: string) (replacement: string) = - let line = extraction.SourceText.Lines[Line.toZ start.Line] - - tryDeclaration extraction header (start.Column + extraction.IndentSize) - |> ValueOption.map (fun declaration -> - [ - TextChange(TextSpan(line.Start, 0), $"{padding start.Column}{declaration}{extraction.LineBreak}") - TextChange(extraction.Target.Replaced, replacement) - ]) - -let private linesMovedWithBody (extraction: Extraction) (body: range) = - let lines = extraction.SourceText.Lines - let replaced = extraction.Target.Replaced - - let stays (line: TextLine) = - extraction.LiteralLines.Contains line.LineNumber - || String.IsNullOrWhiteSpace(line.ToString()) - || (line.Start >= replaced.Start && line.Start <= replaced.End) - - [ - for lineNumber in Line.toZ body.StartLine + 1 .. Line.toZ body.EndLine -> lines[lineNumber] - ] - |> List.filter (stays >> not) - -let private tryUnderKeyword - (extraction: Extraction) - (keywordEnd: pos) - (body: range) - (offsideColumn: int) + $"{header} ={lineBreak}{padding bodyColumn}{body}") + +/// Changes that declare `header = ` in front of the anchor and put replacement where the selection was. +/// A body that shares its line with its keyword moves to new lines under that keyword, when only closing brackets +/// and a comment follow it. +let tryDeclareInFront + (sourceText: SourceText) + (target: ExtractionTarget) + (anchor: Anchor) (header: string) (replacement: string) + (indentSize: int) + (literalLines: HashSet) = - let sourceText = extraction.SourceText - let keywordLine = sourceText.Lines[Line.toZ keywordEnd.Line] - - let gap = - TextSpan.FromBounds(keywordLine.Start + keywordEnd.Column, (textSpanOf sourceText body).Start) + let lines = sourceText.Lines + let lineBreak = lineBreakOf sourceText + let replacementChange = TextChange(target.Replaced, replacement) - let newIndent = - max (leadingSpaces sourceText keywordLine) offsideColumn + extraction.IndentSize + let inFrontOfLine (start: pos) = + let line = lines[Line.toZ start.Line] - let shift = newIndent - body.StartColumn - let movedLines = linesMovedWithBody extraction body + tryDeclaration sourceText target.Content header (start.Column + indentSize) literalLines lineBreak + |> ValueOption.map (fun declaration -> + [ + TextChange(TextSpan(line.Start, 0), $"{padding start.Column}{declaration}{lineBreak}") + replacementChange + ]) - let indentation = - [ - for line in movedLines do - if shift > 0 then - TextChange(TextSpan(line.Start, 0), padding shift) - elif shift < 0 then - TextChange(TextSpan(line.Start, -shift), "") - ] + match anchor with + | Anchor.Statement start when isLineLeading sourceText start -> inFrontOfLine start + | Anchor.Clause(_, body) when isLineLeading sourceText body.Start -> inFrontOfLine body.Start + | Anchor.Clause(keywordEnd, body) when keywordEnd.Line = body.StartLine && restOfLineIsClosers sourceText body.End -> + let keywordLine = lines[Line.toZ keywordEnd.Line] + let keywordEndOffset = keywordLine.Start + keywordEnd.Column + let bodySpan = textSpanOf sourceText body + let newIndent = leadingSpaces sourceText keywordLine + indentSize + let shift = newIndent - body.StartColumn + + let continuation = + [ + for lineNumber in Line.toZ body.StartLine + 1 .. Line.toZ body.EndLine do + let line = lines[lineNumber] - let canMove = - movedLines - |> List.forall (fun line -> shift >= 0 || leadingSpaces sourceText line >= -shift) + let keep = + literalLines.Contains lineNumber + || String.IsNullOrWhiteSpace(line.ToString()) + || (line.Start >= target.Replaced.Start && line.Start <= target.Replaced.End) - if canMove && String.IsNullOrWhiteSpace(sourceText.ToString gap) then - tryDeclaration extraction header (newIndent + extraction.IndentSize) - |> ValueOption.map (fun declaration -> - [ - TextChange(gap, $"{extraction.LineBreak}{padding newIndent}{declaration}{extraction.LineBreak}{padding newIndent}") - yield! indentation - TextChange(extraction.Target.Replaced, replacement) + if not keep then + line ] - |> List.sortBy _.Span.Start) - else - ValueNone - -let tryDeclareInFront (extraction: Extraction) (anchor: Anchor) (header: string) (replacement: string) = - let sourceText = extraction.SourceText - match anchor with - | Anchor.Statement start when isLineLeading sourceText start -> tryInFrontOfLine extraction start header replacement - | Anchor.Clause(body = body) when isLineLeading sourceText body.Start -> tryInFrontOfLine extraction body.Start header replacement - | Anchor.Clause(keywordEnd, body, offsideColumn) when keywordEnd.Line = body.StartLine && onlyClosersFollow sourceText body.End -> - tryUnderKeyword extraction keywordEnd body offsideColumn header replacement + let movable = + continuation + |> List.forall (fun line -> shift >= 0 || leadingSpaces sourceText line >= -shift) + + let separatedByWhitespace = + String.IsNullOrWhiteSpace(sourceText.ToString(TextSpan.FromBounds(keywordEndOffset, bodySpan.Start))) + + if movable && separatedByWhitespace then + tryDeclaration sourceText target.Content header (newIndent + indentSize) literalLines lineBreak + |> ValueOption.map (fun declaration -> + [ + TextChange( + TextSpan.FromBounds(keywordEndOffset, bodySpan.Start), + $"{lineBreak}{padding newIndent}{declaration}{lineBreak}{padding newIndent}" + ) + + for line in continuation do + if shift > 0 then + TextChange(TextSpan(line.Start, 0), padding shift) + elif shift < 0 then + TextChange(TextSpan(line.Start, -shift), "") + + replacementChange + ] + |> List.sortBy _.Span.Start) + else + ValueNone | _ -> ValueNone -let tryDeclareInFrontOfModuleLet (extraction: Extraction) (attributes: string list) (header: string) (replacement: string) = - let sourceText = extraction.SourceText - - match tryEnclosingModuleLet extraction.Target.Path with - | Some(struct (SynBinding(xmlDoc = xmlDoc), declaration)) when isLineLeading sourceText declaration.Start -> +/// Changes that declare `header = `, preceded by the attribute lines, in front of the module-level let +/// containing the selection, and put replacement where the selection was. +let tryDeclareInFrontOfModuleLet + (sourceText: SourceText) + (target: ExtractionTarget) + (attributes: string list) + (header: string) + (replacement: string) + (indentSize: int) + (literalLines: HashSet) + = + match tryEnclosingModuleLet target.Path with + | Some(struct (SynBinding(xmlDoc = xmlDoc), declaration)) -> let firstLine = if xmlDoc.IsEmpty then declaration.StartLine @@ -670,62 +491,17 @@ let tryDeclareInFrontOfModuleLet (extraction: Extraction) (attributes: string li let line = sourceText.Lines[Line.toZ firstLine] let indent = leadingSpaces sourceText line + let lineBreak = lineBreakOf sourceText - tryDeclaration extraction header (indent + extraction.IndentSize) + tryDeclaration sourceText target.Content header (indent + indentSize) literalLines lineBreak |> ValueOption.map (fun declaration -> let attributeLines = attributes - |> List.map (fun attribute -> $"{padding indent}{attribute}{extraction.LineBreak}") + |> List.map (fun attribute -> $"{padding indent}{attribute}{lineBreak}") |> String.concat "" [ - TextChange( - TextSpan(line.Start, 0), - $"{attributeLines}{padding indent}{declaration}{extraction.LineBreak}{extraction.LineBreak}" - ) - TextChange(extraction.Target.Replaced, replacement) + TextChange(TextSpan(line.Start, 0), $"{attributeLines}{padding indent}{declaration}{lineBreak}{lineBreak}") + TextChange(target.Replaced, replacement) ]) - | _ -> ValueNone - -let prepareExtraction - (context: CodeRefactoringContext) - (provider: string) - (sourceText: SourceText) - (parseTree: ParsedInput) - (target: ExtractionTarget) - = - cancellableTask { - let document = context.Document - let! options = document.GetOptionsAsync context.CancellationToken - let! defines, langVersion = document.GetFsharpParsingOptionsAsync provider - let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync provider - - return - { - Context = context - Provider = provider - SourceText = sourceText - Target = target - CheckResults = checkResults - IndentSize = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) - LiteralLines = linesInsideLiterals sourceText parseTree defines langVersion - LineBreak = lineBreakOf sourceText - Names = usedNames parseTree - } - } - -let registerRefactoring (extraction: Extraction) (title: string) (kind: string) (changes: TextChange list voption) = - match changes with - | ValueSome changes -> - let changedDocument = - cancellableTask { - TelemetryReporter.ReportSingleEvent( - TelemetryEvents.RefactoringActivated, - [| "name", box extraction.Provider; "kind", box kind |] - ) - - return extraction.Context.Document.WithText(extraction.SourceText.WithChanges changes) - } - - extraction.Context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) - | ValueNone -> () + | None -> ValueNone diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index 023d23d0d04..387434eef97 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -111,6 +111,15 @@ Navrhnout názvy pro nerozpoznané identifikátory; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf index f4af5ecf92f..08cdadae187 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -111,6 +111,15 @@ Namen für nicht aufgelöste Bezeichner vorschlagen; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index e7e8fd4cd51..dc34e8940c3 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -111,6 +111,15 @@ Sugerir nombres para identificadores sin resolver; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index f8b826e53de..5ecccd93816 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -111,6 +111,15 @@ Suggérer des noms pour les identificateurs non résolus ; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index b7245caa860..28eefa81c2c 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -111,6 +111,15 @@ Suggerisci i nomi per gli identificatori non risolti; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index 690e6d0a2cb..8289471a2ee 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -111,6 +111,15 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index a54784acd32..a2007f1e21d 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -111,6 +111,15 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index d153a70184e..3d31b1823cf 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -111,6 +111,15 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf index e3e2499cd17..533186ebaa4 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf @@ -111,6 +111,15 @@ Sugerir nomes para identificadores não resolvidos; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index e68bc0fa449..6fa7d93f52f 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -111,6 +111,15 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index e2560caac91..dc2fd65cde1 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -111,6 +111,15 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf index 084852fb37c..3926317e5ed 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf @@ -111,6 +111,15 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf index 26ed7e45524..b23cdd33971 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf @@ -111,6 +111,15 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + + Extract to let binding + Extract to let binding + + + + Extract to literal + Extract to literal + F# Disposable Values (locals) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index b0764e21902..e30e122c27b 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -1,614 +1,170 @@ module FSharp.Editor.Tests.Refactors.ExtractLetBindingTests +open System + +open Microsoft.CodeAnalysis.Text + open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks open Xunit -open FSharp.Editor.Tests.Helpers open FSharp.Editor.Tests.Refactors.RefactorTestFramework -[] let private extractToLetBinding = "Extract to let binding" - -[] let private extractToLiteral = "Extract to literal" -[] -let private bothTitles = extractToLetBinding + ";" + extractToLiteral - -[] -let private area = - """ -let area w h = - printfn "%d" (w * h + 1) -""" - -[] -let private greet = - """ -let greet name = - printfn "Hello %s" name -""" - -[] -let private greetWithLiteral = - """ -[] -let ExtractedConstant = "Hello %s" - -let greet name = - printfn ExtractedConstant name -""" - -[] -let private moduleConstant = - """ -let f () = g 42 -""" - -[] -let private mapIncrement = - """ -let f xs = - xs |> List.map (fun x -> x + 1) -""" - -[] -let private mapIncrementExtracted = - """ -let f xs = - let extracted: int -> int = fun x -> x + 1 - xs |> List.map extracted -""" - -let private extractedAt title code span = - use context = TestContext.CreateWithCode code - refactoredText (new FSharpExtractLetBindingRefactoring()) context title code span +let private selectionOf (code: string) (selected: string) = + TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) -let private titlesAt code span = +let private extracted (title: string) (code: string) (selected: string) = use context = TestContext.CreateWithCode code - offeredTitles (new FSharpExtractLetBindingRefactoring()) context code span -let private assertTypeChecks (code: string) = - use context = TestContext.CreateWithCode code + let document = + refactorSpan code (selectionOf code selected) title context (new FSharpExtractLetBindingRefactoring()) - let _, checkResults = - (RoslynTestHelpers.GetLastDocument context.Solution).GetFSharpParseAndCheckResultsAsync "test" + let parseResults = + document.GetFSharpParseResultsAsync "test" |> CancellableTask.runSynchronouslyWithoutCancellation - Assert.Empty(checkResults.Diagnostics) + Assert.Empty(parseResults.Diagnostics) + (document.GetTextAsync() |> GetTaskResult).ToString() -[] -[] -[] -[] -[ List.filter (fun i -> i > acc) - |> List.sum - total -""", - """items - |> List.filter (fun i -> i > acc) - |> List.sum""", - """ -let run items = - let mutable acc = 0 - let extracted = - items - |> List.filter (fun i -> i > acc) - |> List.sum - let total = - extracted - total -""")>] -[ List.filter (fun i -> i > acc) - |> List.sum - - total -""", - """ items - |> List.filter (fun i -> i > acc) - |> List.sum -""", - """ -let run items = - let mutable acc = 0 - - let extracted = - items - |> List.filter (fun i -> i > acc) - |> List.sum - let total = - extracted - - total -""")>] -[ v * 2 + offset - | None -> 0 -""", - "v * 2 + offset", - """ -let f x offset = - match x with - | Some v -> - let extracted = v * 2 + offset - extracted - | None -> 0 -""")>] -[] -[ List.length } -""", - "parse raw |> List.length", - """ -let load id = async { - let! raw = fetch id - let extracted = parse raw |> List.length - return extracted } -""")>] -[ List.map (fun x -> x + 1) -""", - "x + 1", - """ -let r = xs |> List.map (fun x -> - let extracted = x + 1 - extracted) -""")>] -[ 1 + 2 -""", - "1 + 2", - """ -let f x = match x with _ -> - let extracted = 1 + 2 - extracted -""")>] -[] -[] -[] -[] -[] -[] -[] -[] -[] -let ExtractedConstant = -1 - -let f () = g ExtractedConstant -""")>] -let ``Selection is extracted`` (_scenario: string, title: string, code: string, selected: string, expected: string) = - let code = fileOf code - Assert.Equal(fileOf expected, extractedAt title code (selectionOf code selected)) +let private titlesFor (code: string) (selected: string) = + use context = TestContext.CreateWithCode code -[] -[] -let ExtractedConstant = 1.5m - -let f () = id ExtractedConstant -""")>] -[] -let ExtractedConstant = 42 - -let x = ExtractedConstant -""")>] -[ int = (fun x -> x.Length) -""", - "(fun x -> x.Length)", - """ -let f: string -> int = - let extracted: string -> int = fun x -> x.Length - extracted -""")>] -[ List.map (fun x -> [ x ]) -""", - "(fun x -> [ x ])", - """ -let f (xs: 'T list) = - let extracted: 'T -> 'T list = fun x -> [ x ] - xs |> List.map extracted -""")>] -[ List.map (fun x -> [ x ]) -""", - "(fun x -> [ x ])", - """ -let f xs = - let extracted = fun x -> [ x ] - xs |> List.map extracted -""")>] -[ int = (function Some x -> x.Length | None -> 0) -""", - "(function Some x -> x.Length | None -> 0)", - """ -let f: string option -> int = - let extracted: string option -> int = function Some x -> x.Length | None -> 0 - extracted -""")>] -[ List.map (fun x -> x.Length) -""", - "fun x -> x.Length", - """ -let f (xs: string list) = - let extracted: string -> int = fun x -> x.Length - xs |> List.map (extracted) -""")>] -[) = xs.ConvertAll(fun x -> x.Length) -""", - "(fun x -> x.Length)", - """ -let f (xs: System.Collections.Generic.List) = - let extracted: string -> int = fun x -> x.Length - xs.ConvertAll(extracted) -""")>] -let ``Extracted code type checks`` (_scenario: string, title: string, code: string, selected: string, expected: string) = - let code = fileOf code - let result = extractedAt title code (selectionOf code selected) - - Assert.Equal(fileOf expected, result) - assertTypeChecks result + tryGetRefactoringActionsForSpan code (selectionOf code selected) context (new FSharpExtractLetBindingRefactoring()) + |> Seq.map _.Title + |> List.ofSeq [] -[] -[", mapIncrementExtracted)>] -[ x", mapIncrementExtracted)>] -[] -[] -[] -[] -let ``Caret extracts the lambda or constant around it`` (_scenario: string, title: string, code: string, marker: string, expected: string) = - let code = fileOf code - Assert.Equal(fileOf expected, extractedAt title code (caretAt code marker)) +[] +[] +let ``Expression in a statement is bound in front of it`` (selected: string, body: string) = + let code = "module M\n\nlet area w h =\n printfn \"%d\" (w * h + 1)\n" + Assert.Equal($"module M\n\nlet area w h =\n{body}", extracted extractToLetBinding code selected) + +[] +let ``Parentheses of a method call stay`` () = + let code = + "module M\n\nlet append (sb: System.Text.StringBuilder) =\n sb.Append(1 + 2)\n" -[] -[] -[] -[] -let ``Name does not collide with an assignment target from another file`` (_scenario: string, declaration: string, assignment: string) = - let dependency = - """ -module Dependency + let expected = + "module M\n\nlet append (sb: System.Text.StringBuilder) =\n let extracted = 1 + 2\n sb.Append(extracted)\n" -type Box = { mutable Value: int } + Assert.Equal(expected, extracted extractToLetBinding code "(1 + 2)") -DECLARATION -""" - .Replace("DECLARATION", declaration) +[] +let ``Multi-line right-hand side is bound in front of its let`` () = + let code = + "module M\n\nlet run items =\n let mutable acc = 0\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n total\n" + let expected = + "module M\n\nlet run items =\n let mutable acc = 0\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n total\n" + + Assert.Equal(expected, extracted extractToLetBinding code "items\n |> List.filter (fun i -> i > acc)\n |> List.sum") + +[] +let ``Match clause body on the arrow line moves to its own lines`` () = let code = - """ -module M + "module M\n\nlet f x offset =\n match x with\n | Some v -> v * 2 + offset\n | None -> 0\n" -open Dependency + let expected = + "module M\n\nlet f x offset =\n match x with\n | Some v ->\n let extracted = v * 2 + offset\n extracted\n | None -> 0\n" + + Assert.Equal(expected, extracted extractToLetBinding code "v * 2 + offset") -let f () = - ASSIGNMENT({ Value = 1 }).Value -""" - .Replace("ASSIGNMENT", assignment) +[] +let ``Right-hand side on the equals line keeps its trailing comment`` () = + let code = "module M\n\nlet r = compute a b // slow\n" let expected = - """ -module M + "module M\n\nlet r =\n let extracted = compute a b\n extracted // slow\n" -open Dependency + Assert.Equal(expected, extracted extractToLetBinding code "compute a b") -let f () = - let extracted1 = { Value = 1 } - ASSIGNMENTextracted1.Value -""" - .Replace("ASSIGNMENT", assignment) +[] +let ``Expression in a computation expression is bound in front of its statement`` () = + let code = + "module M\n\nlet load id = async {\n let! raw = fetch id\n return parse raw |> List.length }\n" - use context = TestContext.CreateWithCodeAndDependency code dependency + let expected = + "module M\n\nlet load id = async {\n let! raw = fetch id\n let extracted = parse raw |> List.length\n return extracted }\n" - Assert.Equal( - expected, - refactoredText (new FSharpExtractLetBindingRefactoring()) context extractToLetBinding code (selectionOf code "({ Value = 1 })") - ) + Assert.Equal(expected, extracted extractToLetBinding code "parse raw |> List.length") -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] -[ 0 -> v - | _ -> 0 -""", - "v > 0", - "")>] -[] -[] -[] -[] -[ 0", "s.Length > 0", "")>] -[] -[] -type S = - val mutable X: int - new(x) = { X = x } - member s.Inc() = s.X <- s.X + 1 - -let f () = - let mutable s = S(1) - (s).Inc() - s.X -""", - "(s)", - "")>] -[] -[] -let ``Actions offered for a selection`` (_scenario: string, code: string, selected: string, titles: string) = - let code = fileOf code - Assert.Equal(titles, titlesAt code (selectionOf code selected)) +[] +let ``Lambda body on the arrow line moves to its own lines`` () = + let code = "module M\n\nlet r = xs |> List.map (fun x -> x + 1)\n" + + let expected = + "module M\n\nlet r = xs |> List.map (fun x ->\n let extracted = x + 1\n extracted)\n" + + Assert.Equal(expected, extracted extractToLetBinding code "x + 1") + +[] +let ``Name does not collide with an existing identifier`` () = + let code = + "module M\n\nlet extracted = 0\n\nlet f x =\n printfn \"%d\" (x + 1)\n" + + let expected = + "module M\n\nlet extracted = 0\n\nlet f x =\n let extracted1 = x + 1\n printfn \"%d\" (extracted1)\n" + + Assert.Equal(expected, extracted extractToLetBinding code "x + 1") + +[] +let ``Line breaks of the file are kept`` () = + let code = "module M\r\n\r\nlet f x =\r\n printfn \"%d\" (x + 1)\r\n" + + let expected = + "module M\r\n\r\nlet f x =\r\n let extracted = x + 1\r\n printfn \"%d\" (extracted)\r\n" + + Assert.Equal(expected, extracted extractToLetBinding code "x + 1") + +[] +let ``Lines inside a multi-line string are not re-indented`` () = + let code = + "module M\n\nlet f name =\n printfn \"%s\" (String.Format(\"\"\"Hello\n{0}\"\"\", name))\n" + + let expected = + "module M\n\nlet f name =\n let extracted =\n String.Format(\"\"\"Hello\n{0}\"\"\", name)\n printfn \"%s\" (extracted)\n" + + Assert.Equal(expected, extracted extractToLetBinding code "String.Format(\"\"\"Hello\n{0}\"\"\", name)") + +[] +let ``Constant in a module-level declaration becomes a literal in front of it`` () = + let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" + + let expected = + "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" + + Assert.Equal(expected, extracted extractToLiteral code "\"Hello %s\"") + +[] +let ``Negative number becomes a literal`` () = + let code = "module M\n\nlet f () = g -1\n" + + let expected = + "module M\n\n[]\nlet ExtractedConstant = -1\n\nlet f () = g ExtractedConstant\n" + + Assert.Equal(expected, extracted extractToLiteral code "-1") + +[] +let ``Literal is offered only for constants outside types`` () = + Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor "module M\n\nlet f () = g 42\n" "42") + Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\ntype T() =\n member _.M() = 42\n" "42") + Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\nlet f x = g (x + 1)\n" "x + 1") [] -[] -[", extractToLetBinding)>] -[ x", extractToLetBinding)>] -[] -[] -[] -[] -[] -[ x + 1", "fun", "")>] -[] -[] -[] -[] -[] -[] -let ``Actions offered at a caret`` (_scenario: string, code: string, marker: string, titles: string) = - let code = fileOf code - Assert.Equal(titles, titlesAt code (caretAt code marker)) +[] +[] +[] +[] +[ 0 -> v\n | _ -> 0\n", "v > 0")>] +[] +[] +[] +[] +[ 0\n", "s.Length > 0")>] +[] +let ``No action`` (code: string, selected: string) = Assert.Empty(titlesFor code selected) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs index 2a75ccb9085..4aad2b130d1 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs @@ -1,17 +1,13 @@ -module FSharp.Editor.Tests.Refactors.RefactorTestFramework +module FSharp.Editor.Tests.Refactors.RefactorTestFramework open System +open System.Linq open System.Collections.Generic open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text -open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks -open Xunit - -open FSharp.Test.ProjectGeneration - open FSharp.Editor.Tests.Helpers open Microsoft.CodeAnalysis.CodeRefactorings open Microsoft.CodeAnalysis.CodeActions @@ -35,19 +31,11 @@ type TestContext(Solution: Solution) = new TestContext(solution) static member CreateWithCodeAndDependency (code: string) (codeForPreviousFile: string) = - let project = - { SyntheticProject.Create( - { sourceFile "First" [] with - Source = codeForPreviousFile - }, - { sourceFile "Second" [ "First" ] with - Source = code - } - ) with - AutoAddModules = false - } - - let solution, _ = RoslynTestHelpers.CreateSolution project + let mutable solution = RoslynTestHelpers.CreateSolution(codeForPreviousFile) + + let firstProject = solution.Projects.First() + solution <- solution.AddDocument(DocumentId.CreateNewId(firstProject.Id), "test2.fs", code, filePath = "C:\\test2.fs") + new TestContext(solution) let tryRefactor (code: string) (cursorPosition) (context: TestContext) (refactorProvider: 'T :> CodeRefactoringProvider) = @@ -100,7 +88,7 @@ let tryGetRefactoringActions (code: string) (cursorPosition) (context: TestConte |> CancellableTask.startWithoutCancellation |> fun task -> task.Result -let tryGetRefactoringActionsForSpan (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: CodeRefactoringProvider) = +let private refactoringActionsAt (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: CodeRefactoringProvider) = let actions = List() let existingDocument = RoslynTestHelpers.GetLastDocument context.Solution context.Solution <- context.Solution.WithDocumentText(existingDocument.Id, SourceText.From(code)) @@ -112,9 +100,12 @@ let tryGetRefactoringActionsForSpan (code: string) (span: TextSpan) (context: Te refactorProvider.ComputeRefactoringsAsync(refactoringContext).GetAwaiter().GetResult() actions -let refactorSpan (code: string) (span: TextSpan) (title: string) (context: TestContext) (refactorProvider: CodeRefactoringProvider) = +let tryGetRefactoringActionsForSpan (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: #CodeRefactoringProvider) = + refactoringActionsAt code span context refactorProvider + +let refactorSpan (code: string) (span: TextSpan) (title: string) (context: TestContext) (refactorProvider: #CodeRefactoringProvider) = let action = - tryGetRefactoringActionsForSpan code span context refactorProvider + refactoringActionsAt code span context refactorProvider |> Seq.find (fun action -> String.Equals(action.Title, title, StringComparison.Ordinal)) for operation in action.GetOperationsAsync(context.CancellationToken) |> GetTaskResult do @@ -123,37 +114,3 @@ let refactorSpan (code: string) (span: TextSpan) (title: string) (context: TestC context.Solution <- applyChanges.ChangedSolution RoslynTestHelpers.GetLastDocument context.Solution - -let selectionOf (code: string) (selected: string) = - TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) - -let caretAt (code: string) (marker: string) = - let offset = max 0 (marker.IndexOf '|') - TextSpan(code.IndexOf(marker.Replace("|", ""), StringComparison.Ordinal) + offset, 0) - -let fileOf (declarations: string) = - let text = declarations.Trim('\r', '\n') - - if - text.StartsWith("module ", StringComparison.Ordinal) - || text.StartsWith("namespace ", StringComparison.Ordinal) - then - declarations - else - let lineBreak = if declarations.IndexOf '\r' >= 0 then "\r\n" else "\n" - $"{lineBreak}module M{lineBreak}{lineBreak}{text}{lineBreak}" - -let refactoredText (provider: CodeRefactoringProvider) (context: TestContext) (title: string) (code: string) (span: TextSpan) = - let document = refactorSpan code span title context provider - - let parseResults = - document.GetFSharpParseResultsAsync "test" - |> CancellableTask.runSynchronouslyWithoutCancellation - - Assert.Empty(parseResults.Diagnostics) - (document.GetTextAsync() |> GetTaskResult).ToString() - -let offeredTitles (provider: CodeRefactoringProvider) (context: TestContext) (code: string) (span: TextSpan) = - tryGetRefactoringActionsForSpan code span context provider - |> Seq.map _.Title - |> String.concat ";" From a0bed841a8b75864641f1fdd0d75dedcce1ec5d4 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 13:23:10 +0200 Subject: [PATCH 14/31] Offer Extract to literal on a caret inside a constant, as C# does 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) --- .../Refactor/ExtractLetBinding.fs | 25 +++++---- .../Refactor/RefactoringHelpers.fs | 30 +++++++++++ .../Refactors/ExtractLetBindingTests.fs | 54 +++++++++++++++++-- 3 files changed, 96 insertions(+), 13 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 947e5c4871a..8233a79ae43 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -42,12 +42,18 @@ type internal FSharpExtractLetBindingRefactoring [] () = cancellableTask { let document = context.Document - if not (context.Span.IsEmpty || document.IsFSharpSignatureFile) then + if not document.IsFSharpSignatureFile then let! cancellationToken = CancellableTask.getCancellationToken () let! sourceText = document.GetTextAsync cancellationToken let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractLetBindingRefactoring) - match tryExtractionTarget sourceText parseResults.ParseTree context.Span with + let target = + if context.Span.IsEmpty then + tryConstantAtCaret sourceText parseResults.ParseTree context.Span.Start + else + tryExtractionTarget sourceText parseResults.ParseTree context.Span + + match target with | ValueNone -> () | ValueSome target -> let! options = document.GetOptionsAsync cancellationToken @@ -58,14 +64,15 @@ type internal FSharpExtractLetBindingRefactoring [] () = let names = usedNames parseResults.ParseTree let literalLines = linesInsideLiterals parseResults.ParseTree - match anchorsOf target.Expr target.Path with - | anchor :: _ -> - let name = uniqueName "extracted" names + if not context.Span.IsEmpty then + match anchorsOf target.Expr target.Path with + | anchor :: _ -> + let name = uniqueName "extracted" names - match tryDeclareInFront sourceText target anchor $"let {name}" name indentSize literalLines with - | ValueSome changes -> register context sourceText (SR.ExtractToLetBinding()) "let" changes - | ValueNone -> () - | [] -> () + match tryDeclareInFront sourceText target anchor $"let {name}" name indentSize literalLines with + | ValueSome changes -> register context sourceText (SR.ExtractToLetBinding()) "let" changes + | ValueNone -> () + | [] -> () let constant = match target.Expr with diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index f69c579a364..525a4a555cb 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -371,6 +371,36 @@ let isLiteralConstant (expr: SynExpr) = _) -> true | _ -> false +/// The literal constant the caret is in or touches. +let tryConstantAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = + let position = positionOf sourceText caret + + let constant = + (position, parseTree) + ||> ParsedInput.tryPickLast (fun path node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Const(range = m) as expr) when + isLiteralConstant expr + && Position.posGeq position m.Start + && Position.posGeq m.End position + && not (isInExcludedContext expr path) + -> + Some(expr, path) + | _ -> None) + + match constant with + | Some(expr, path) -> + let span = textSpanOf sourceText expr.Range + + ValueSome + { + Expr = expr + Path = path + Content = span + Replaced = span + } + | None -> ValueNone + /// The module-level let declaration containing the path, with its first binding. let tryEnclosingModuleLet (path: SyntaxVisitorPath) = path diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index e30e122c27b..bb356ad5fa5 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -17,11 +17,14 @@ let private extractToLiteral = "Extract to literal" let private selectionOf (code: string) (selected: string) = TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) -let private extracted (title: string) (code: string) (selected: string) = +let private caretAt (code: string) (marker: string) = + TextSpan(code.IndexOf(marker, StringComparison.Ordinal), 0) + +let private extractedAt (title: string) (code: string) (span: TextSpan) = use context = TestContext.CreateWithCode code let document = - refactorSpan code (selectionOf code selected) title context (new FSharpExtractLetBindingRefactoring()) + refactorSpan code span title context (new FSharpExtractLetBindingRefactoring()) let parseResults = document.GetFSharpParseResultsAsync "test" @@ -30,13 +33,19 @@ let private extracted (title: string) (code: string) (selected: string) = Assert.Empty(parseResults.Diagnostics) (document.GetTextAsync() |> GetTaskResult).ToString() -let private titlesFor (code: string) (selected: string) = +let private extracted (title: string) (code: string) (selected: string) = + extractedAt title code (selectionOf code selected) + +let private titlesAt (code: string) (span: TextSpan) = use context = TestContext.CreateWithCode code - tryGetRefactoringActionsForSpan code (selectionOf code selected) context (new FSharpExtractLetBindingRefactoring()) + tryGetRefactoringActionsForSpan code span context (new FSharpExtractLetBindingRefactoring()) |> Seq.map _.Title |> List.ofSeq +let private titlesFor (code: string) (selected: string) = + titlesAt code (selectionOf code selected) + [] [] [] @@ -64,6 +73,19 @@ let ``Multi-line right-hand side is bound in front of its let`` () = Assert.Equal(expected, extracted extractToLetBinding code "items\n |> List.filter (fun i -> i > acc)\n |> List.sum") +[] +let ``Whole lines selected with their indentation and line break are extracted`` () = + let code = + "module M\n\nlet run items =\n let mutable acc = 0\n\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n\n total\n" + + let expected = + "module M\n\nlet run items =\n let mutable acc = 0\n\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n\n total\n" + + Assert.Equal( + expected, + extracted extractToLetBinding code " items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n" + ) + [] let ``Match clause body on the arrow line moves to its own lines`` () = let code = @@ -155,6 +177,30 @@ let ``Literal is offered only for constants outside types`` () = Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\ntype T() =\n member _.M() = 42\n" "42") Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\nlet f x = g (x + 1)\n" "x + 1") +[] +[] +[] +[] +let ``Constant at the caret becomes a literal without a selection`` (marker: string) = + let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" + + let expected = + "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" + + Assert.Equal(expected, extractedAt extractToLiteral code (caretAt code marker)) + +[] +let ``Only the literal is offered without a selection`` () = + let code = "module M\n\nlet f () = g 42\n" + Assert.Equal([ extractToLiteral ], titlesAt code (caretAt code "2\n")) + +[] +[] +[] +[] +let ``No action without a selection`` (code: string, marker: string) = + Assert.Empty(titlesAt code (caretAt code marker)) + [] [] [] From be8dc391f261544f4df6bc2f19b0653397f3df5b Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 13:24:19 +0200 Subject: [PATCH 15/31] Mention the caret in the Extract to literal release note Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 913f8811175..514a77b782f 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -8,6 +8,7 @@ * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) * **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration. +* **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration, also with just the caret inside the constant. ### Fixed From 19b40b17ddf6f8df82b2f5f5dfe0ec1b32ada136 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 13:33:14 +0200 Subject: [PATCH 16/31] Extract a parenthesized lambda from a caret in its fun ... -> header 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) --- .../Refactor/ExtractLetBinding.fs | 16 ++++++++++---- .../Refactor/RefactoringHelpers.fs | 20 +++++++++++++++++ .../Refactors/ExtractLetBindingTests.fs | 22 +++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 8233a79ae43..4bc595bf49d 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -47,11 +47,19 @@ type internal FSharpExtractLetBindingRefactoring [] () = let! sourceText = document.GetTextAsync cancellationToken let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractLetBindingRefactoring) - let target = + let lambdaAtCaret = if context.Span.IsEmpty then - tryConstantAtCaret sourceText parseResults.ParseTree context.Span.Start + tryParenthesizedLambdaAtCaret sourceText parseResults.ParseTree context.Span.Start else - tryExtractionTarget sourceText parseResults.ParseTree context.Span + ValueNone + + let isSelected = not context.Span.IsEmpty || lambdaAtCaret.IsSome + + let target = + match lambdaAtCaret with + | ValueSome _ -> lambdaAtCaret + | ValueNone when context.Span.IsEmpty -> tryConstantAtCaret sourceText parseResults.ParseTree context.Span.Start + | ValueNone -> tryExtractionTarget sourceText parseResults.ParseTree context.Span match target with | ValueNone -> () @@ -64,7 +72,7 @@ type internal FSharpExtractLetBindingRefactoring [] () = let names = usedNames parseResults.ParseTree let literalLines = linesInsideLiterals parseResults.ParseTree - if not context.Span.IsEmpty then + if isSelected then match anchorsOf target.Expr target.Path with | anchor :: _ -> let name = uniqueName "extracted" names diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index 525a4a555cb..b6cd11425cf 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -401,6 +401,26 @@ let tryConstantAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: } | None -> ValueNone +/// The parenthesized lambda whose `fun ->` the caret is in, as if it were selected with its parentheses. +let tryParenthesizedLambdaAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = + let position = positionOf sourceText caret + + let parenthesized = + (position, parseTree) + ||> ParsedInput.tryPickLast (fun _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Paren( + expr = SynExpr.Lambda(parsedData = Some _; trivia = { ArrowRange = Some arrow }) as lambda; range = m)) when + Position.posGeq position lambda.Range.Start + && Position.posGeq arrow.End position + -> + Some m + | _ -> None) + + match parenthesized with + | Some m -> tryExtractionTarget sourceText parseTree (textSpanOf sourceText m) + | None -> ValueNone + /// The module-level let declaration containing the path, with its first binding. let tryEnclosingModuleLet (path: SyntaxVisitorPath) = path diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index bb356ad5fa5..7974f2ba4b8 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -177,6 +177,28 @@ let ``Literal is offered only for constants outside types`` () = Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\ntype T() =\n member _.M() = 42\n" "42") Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\nlet f x = g (x + 1)\n" "x + 1") +[] +[] +[")>] +[ x")>] +[] +let ``Caret in the header of a parenthesized lambda extracts the lambda`` (marker: string) = + let code = "module M\n\nlet f xs =\n xs |> List.map (fun x -> x + 1)\n" + + let expected = + "module M\n\nlet f xs =\n let extracted = fun x -> x + 1\n xs |> List.map extracted\n" + + Assert.Equal([ extractToLetBinding ], titlesAt code (caretAt code marker)) + Assert.Equal(expected, extractedAt extractToLetBinding code (caretAt code marker)) + +[] +[ List.map (fun x -> x + 1)\n", "x + 1)")>] +[ List.map (fun x -> x + 1)\n", "+ 1)")>] +[ List.map (fun x -> x + 1)\n", "(fun")>] +[ x + 1\n", "fun")>] +let ``No action without a selection outside a parenthesized lambda header`` (code: string, marker: string) = + Assert.Empty(titlesAt code (caretAt code marker)) + [] [] [] From 6cbaac778f947e5352dd0db49c69540cddb052c3 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 15:30:50 +0200 Subject: [PATCH 17/31] Write the refactoring test code as multi-line strings Co-Authored-By: Claude Opus 5 (1M context) --- .../Refactors/ExtractLetBindingTests.fs | 452 +++++++++++++++--- 1 file changed, 377 insertions(+), 75 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index 7974f2ba4b8..50078b08db0 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -46,91 +46,246 @@ let private titlesAt (code: string) (span: TextSpan) = let private titlesFor (code: string) (selected: string) = titlesAt code (selectionOf code selected) +let private area = + """ +module M + +let area w h = + printfn "%d" (w * h + 1) +""" + [] -[] -[] -let ``Expression in a statement is bound in front of it`` (selected: string, body: string) = - let code = "module M\n\nlet area w h =\n printfn \"%d\" (w * h + 1)\n" - Assert.Equal($"module M\n\nlet area w h =\n{body}", extracted extractToLetBinding code selected) +[] +[] +let ``Expression in a statement is bound in front of it`` (selected: string, expected: string) = + Assert.Equal(expected, extracted extractToLetBinding area selected) [] let ``Parentheses of a method call stay`` () = let code = - "module M\n\nlet append (sb: System.Text.StringBuilder) =\n sb.Append(1 + 2)\n" + """ +module M + +let append (sb: System.Text.StringBuilder) = + sb.Append(1 + 2) +""" let expected = - "module M\n\nlet append (sb: System.Text.StringBuilder) =\n let extracted = 1 + 2\n sb.Append(extracted)\n" + """ +module M + +let append (sb: System.Text.StringBuilder) = + let extracted = 1 + 2 + sb.Append(extracted) +""" Assert.Equal(expected, extracted extractToLetBinding code "(1 + 2)") +let private run = + """ +module M + +let run items = + let mutable acc = 0 + let total = + items + |> List.filter (fun i -> i > acc) + |> List.sum + total +""" + [] let ``Multi-line right-hand side is bound in front of its let`` () = - let code = - "module M\n\nlet run items =\n let mutable acc = 0\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n total\n" - let expected = - "module M\n\nlet run items =\n let mutable acc = 0\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n total\n" + """ +module M - Assert.Equal(expected, extracted extractToLetBinding code "items\n |> List.filter (fun i -> i > acc)\n |> List.sum") +let run items = + let mutable acc = 0 + let extracted = + items + |> List.filter (fun i -> i > acc) + |> List.sum + let total = + extracted + total +""" + + let start = run.LastIndexOf("items", StringComparison.Ordinal) + + let finish = + run.IndexOf("|> List.sum", start, StringComparison.Ordinal) + + "|> List.sum".Length + + Assert.Equal(expected, extractedAt extractToLetBinding run (TextSpan.FromBounds(start, finish))) [] let ``Whole lines selected with their indentation and line break are extracted`` () = let code = - "module M\n\nlet run items =\n let mutable acc = 0\n\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n\n total\n" + """ +module M + +let run items = + let mutable acc = 0 + + let total = + items + |> List.filter (fun i -> i > acc) + |> List.sum + + total +""" let expected = - "module M\n\nlet run items =\n let mutable acc = 0\n\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n\n total\n" + """ +module M + +let run items = + let mutable acc = 0 + + let extracted = + items + |> List.filter (fun i -> i > acc) + |> List.sum + let total = + extracted - Assert.Equal( - expected, - extracted extractToLetBinding code " items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n" - ) + total +""" + + let lineStart = code.LastIndexOf(" items", StringComparison.Ordinal) + + let afterLineBreak = + code.IndexOf('\n', code.IndexOf("|> List.sum", lineStart, StringComparison.Ordinal)) + + 1 + + Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan.FromBounds(lineStart, afterLineBreak))) [] let ``Match clause body on the arrow line moves to its own lines`` () = let code = - "module M\n\nlet f x offset =\n match x with\n | Some v -> v * 2 + offset\n | None -> 0\n" + """ +module M + +let f x offset = + match x with + | Some v -> v * 2 + offset + | None -> 0 +""" let expected = - "module M\n\nlet f x offset =\n match x with\n | Some v ->\n let extracted = v * 2 + offset\n extracted\n | None -> 0\n" + """ +module M + +let f x offset = + match x with + | Some v -> + let extracted = v * 2 + offset + extracted + | None -> 0 +""" Assert.Equal(expected, extracted extractToLetBinding code "v * 2 + offset") [] let ``Right-hand side on the equals line keeps its trailing comment`` () = - let code = "module M\n\nlet r = compute a b // slow\n" + let code = + """ +module M + +let r = compute a b // slow +""" let expected = - "module M\n\nlet r =\n let extracted = compute a b\n extracted // slow\n" + """ +module M + +let r = + let extracted = compute a b + extracted // slow +""" Assert.Equal(expected, extracted extractToLetBinding code "compute a b") [] let ``Expression in a computation expression is bound in front of its statement`` () = let code = - "module M\n\nlet load id = async {\n let! raw = fetch id\n return parse raw |> List.length }\n" + """ +module M + +let load id = async { + let! raw = fetch id + return parse raw |> List.length } +""" let expected = - "module M\n\nlet load id = async {\n let! raw = fetch id\n let extracted = parse raw |> List.length\n return extracted }\n" + """ +module M + +let load id = async { + let! raw = fetch id + let extracted = parse raw |> List.length + return extracted } +""" Assert.Equal(expected, extracted extractToLetBinding code "parse raw |> List.length") [] let ``Lambda body on the arrow line moves to its own lines`` () = - let code = "module M\n\nlet r = xs |> List.map (fun x -> x + 1)\n" + let code = + """ +module M + +let r = xs |> List.map (fun x -> x + 1) +""" let expected = - "module M\n\nlet r = xs |> List.map (fun x ->\n let extracted = x + 1\n extracted)\n" + """ +module M + +let r = xs |> List.map (fun x -> + let extracted = x + 1 + extracted) +""" Assert.Equal(expected, extracted extractToLetBinding code "x + 1") [] let ``Name does not collide with an existing identifier`` () = let code = - "module M\n\nlet extracted = 0\n\nlet f x =\n printfn \"%d\" (x + 1)\n" + """ +module M + +let extracted = 0 + +let f x = + printfn "%d" (x + 1) +""" let expected = - "module M\n\nlet extracted = 0\n\nlet f x =\n let extracted1 = x + 1\n printfn \"%d\" (extracted1)\n" + """ +module M + +let extracted = 0 + +let f x = + let extracted1 = x + 1 + printfn "%d" (extracted1) +""" Assert.Equal(expected, extracted extractToLetBinding code "x + 1") @@ -145,6 +300,7 @@ let ``Line breaks of the file are kept`` () = [] let ``Lines inside a multi-line string are not re-indented`` () = + // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. let code = "module M\n\nlet f name =\n printfn \"%s\" (String.Format(\"\"\"Hello\n{0}\"\"\", name))\n" @@ -153,29 +309,85 @@ let ``Lines inside a multi-line string are not re-indented`` () = Assert.Equal(expected, extracted extractToLetBinding code "String.Format(\"\"\"Hello\n{0}\"\"\", name)") -[] -let ``Constant in a module-level declaration becomes a literal in front of it`` () = - let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" +let private greet = + """ +module M - let expected = - "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" +let greet name = + printfn "Hello %s" name +""" - Assert.Equal(expected, extracted extractToLiteral code "\"Hello %s\"") +let private greetWithLiteral = + """ +module M + +[] +let ExtractedConstant = "Hello %s" + +let greet name = + printfn ExtractedConstant name +""" + +[] +let ``Constant in a module-level declaration becomes a literal in front of it`` () = + Assert.Equal(greetWithLiteral, extracted extractToLiteral greet "\"Hello %s\"") [] let ``Negative number becomes a literal`` () = - let code = "module M\n\nlet f () = g -1\n" + let code = + """ +module M + +let f () = g -1 +""" let expected = - "module M\n\n[]\nlet ExtractedConstant = -1\n\nlet f () = g ExtractedConstant\n" + """ +module M + +[] +let ExtractedConstant = -1 + +let f () = g ExtractedConstant +""" Assert.Equal(expected, extracted extractToLiteral code "-1") +let private moduleConstant = + """ +module M + +let f () = g 42 +""" + [] let ``Literal is offered only for constants outside types`` () = - Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor "module M\n\nlet f () = g 42\n" "42") - Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\ntype T() =\n member _.M() = 42\n" "42") - Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\nlet f x = g (x + 1)\n" "x + 1") + let memberConstant = + """ +module M + +type T() = + member _.M() = 42 +""" + + let expression = + """ +module M + +let f x = g (x + 1) +""" + + Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor moduleConstant "42") + Assert.Equal([ extractToLetBinding ], titlesFor memberConstant "42") + Assert.Equal([ extractToLetBinding ], titlesFor expression "x + 1") + +let private mapIncrement = + """ +module M + +let f xs = + xs |> List.map (fun x -> x + 1) +""" [] [] @@ -183,56 +395,146 @@ let ``Literal is offered only for constants outside types`` () = [ x")>] [] let ``Caret in the header of a parenthesized lambda extracts the lambda`` (marker: string) = - let code = "module M\n\nlet f xs =\n xs |> List.map (fun x -> x + 1)\n" - let expected = - "module M\n\nlet f xs =\n let extracted = fun x -> x + 1\n xs |> List.map extracted\n" + """ +module M - Assert.Equal([ extractToLetBinding ], titlesAt code (caretAt code marker)) - Assert.Equal(expected, extractedAt extractToLetBinding code (caretAt code marker)) +let f xs = + let extracted = fun x -> x + 1 + xs |> List.map extracted +""" -[] -[ List.map (fun x -> x + 1)\n", "x + 1)")>] -[ List.map (fun x -> x + 1)\n", "+ 1)")>] -[ List.map (fun x -> x + 1)\n", "(fun")>] -[ x + 1\n", "fun")>] -let ``No action without a selection outside a parenthesized lambda header`` (code: string, marker: string) = - Assert.Empty(titlesAt code (caretAt code marker)) + Assert.Equal([ extractToLetBinding ], titlesAt mapIncrement (caretAt mapIncrement marker)) + Assert.Equal(expected, extractedAt extractToLetBinding mapIncrement (caretAt mapIncrement marker)) [] -[] -[] -[] -let ``Constant at the caret becomes a literal without a selection`` (marker: string) = - let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" +[] +[] +[] +let ``No action without a selection outside a parenthesized lambda header`` (marker: string) = + Assert.Empty(titlesAt mapIncrement (caretAt mapIncrement marker)) - let expected = - "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" +[] +let ``No action without a selection on a lambda without parentheses`` () = + let code = + """ +module M + +let f = fun x -> x + 1 +""" - Assert.Equal(expected, extractedAt extractToLiteral code (caretAt code marker)) + Assert.Empty(titlesAt code (caretAt code "fun")) + +[] +[] +[] +[] +let ``Constant at the caret becomes a literal without a selection`` (marker: string, offset: int) = + let caret = TextSpan(greet.IndexOf(marker, StringComparison.Ordinal) + offset, 0) + Assert.Equal(greetWithLiteral, extractedAt extractToLiteral greet caret) [] let ``Only the literal is offered without a selection`` () = - let code = "module M\n\nlet f () = g 42\n" - Assert.Equal([ extractToLiteral ], titlesAt code (caretAt code "2\n")) + let caret = TextSpan(moduleConstant.IndexOf("42", StringComparison.Ordinal) + 1, 0) + + Assert.Equal([ extractToLiteral ], titlesAt moduleConstant caret) [] -[] -[] -[] +[] +[] +[] let ``No action without a selection`` (code: string, marker: string) = Assert.Empty(titlesAt code (caretAt code marker)) [] -[] -[] -[] -[] -[ 0 -> v\n | _ -> 0\n", "v > 0")>] -[] -[] -[] -[] -[ 0\n", "s.Length > 0")>] -[] +[] +[] +[] +[] +[ 0 -> v + | _ -> 0 +""", + "v > 0")>] +[] +[] +[] +[] +[ 0 +""", + "s.Length > 0")>] +[] let ``No action`` (code: string, selected: string) = Assert.Empty(titlesFor code selected) From 9a2412139826140f94d6cfe10b33ea1534163ae0 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 29 Sep 2026 19:21:29 +0200 Subject: [PATCH 18/31] Keep assignment targets and inactive string lines out of the extraction'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 --- .../Refactor/ExtractLetBinding.fs | 5 +- .../Refactor/RefactoringHelpers.fs | 105 ++++++++++++++++-- .../Refactors/ExtractLetBindingTests.fs | 56 ++++++++++ .../Refactors/RefactorTestFramework.fs | 20 +++- 4 files changed, 169 insertions(+), 17 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 4bc595bf49d..13dca45a187 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -69,8 +69,11 @@ type internal FSharpExtractLetBindingRefactoring [] () = let indentSize = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) + let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpExtractLetBindingRefactoring) let names = usedNames parseResults.ParseTree - let literalLines = linesInsideLiterals parseResults.ParseTree + + let literalLines = + linesInsideLiterals sourceText parseResults.ParseTree defines langVersion if isSelected then match anchorsOf target.Expr target.Path with diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index b6cd11425cf..fc0ba48abb3 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -11,6 +11,7 @@ open Microsoft.CodeAnalysis.Text open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTrivia open FSharp.Compiler.Text +open FSharp.Compiler.Tokenization /// A place in front of which a declaration used by the selected expression can be inserted. [] @@ -76,18 +77,98 @@ let restOfLineIsClosers (sourceText: SourceText) (position: pos) = code |> Seq.forall (fun c -> Char.IsWhiteSpace c || c = ')' || c = ']' || c = '}' || c = '|') -let linesInsideLiterals (parseTree: ParsedInput) = - (HashSet(), parseTree) - ||> ParsedInput.fold (fun lines _ node -> - match node with - | SyntaxNode.SynExpr(SynExpr.Const(range = m)) - | SyntaxNode.SynExpr(SynExpr.InterpolatedString(range = m)) - | SyntaxNode.SynPat(SynPat.Const(range = m)) -> - for line in m.StartLine + 1 .. m.EndLine do - lines.Add(Line.toZ line) |> ignore - | _ -> () +/// Whether a line lexed from this state starts inside a string literal. +let private continuesString (state: FSharpTokenizerColorState) = + match state with + | FSharpTokenizerColorState.InitialState + | FSharpTokenizerColorState.Token + | FSharpTokenizerColorState.IfDefSkip + | FSharpTokenizerColorState.EndLineThenSkip + | FSharpTokenizerColorState.EndLineThenToken + | FSharpTokenizerColorState.Comment + | FSharpTokenizerColorState.SingleLineComment + | FSharpTokenizerColorState.StringInComment + | FSharpTokenizerColorState.VerbatimStringInComment + | FSharpTokenizerColorState.TripleQuoteStringInComment -> false + // String, VerbatimString, TripleQuoteString and the extended interpolated string state, which the public enum does not name. + | _ -> true + +let private isSkipped (state: FSharpTokenizerLexState) = + match FSharpLineTokenizer.ColorStateOfLexState state with + | FSharpTokenizerColorState.IfDefSkip + | FSharpTokenizerColorState.EndLineThenSkip -> true + | _ -> false + +let private startsWithDirective (code: ReadOnlySpan) (keyword: string) = + code.StartsWith(keyword.AsSpan(), StringComparison.Ordinal) + && (code.Length = keyword.Length + || Char.IsWhiteSpace code[keyword.Length] + || code[keyword.Length] = '/') + +/// Whether the line is a directive the lexer recognizes while it skips an inactive branch. +let private isSkippedDirective (line: string) = + let code = line.AsSpan().TrimStart() + + startsWithDirective code "#if" + || startsWithDirective code "#elif" + || startsWithDirective code "#else" + || startsWithDirective code "#endif" + +let private scanLine (tokenizer: FSharpSourceTokenizer) (line: string) (state: FSharpTokenizerLexState) = + let lineTokenizer = tokenizer.CreateLineTokenizer line + + let rec loop state = + match lineTokenizer.ScanToken state with + | Some _, next -> loop next + | None, next -> next + + loop state + +/// Lines inside multi-line string literals of the branches of `#if` the defines leave inactive. Each run of inactive +/// lines between two directives is lexed as the code it is when its branch is taken. +let private linesInsideInactiveLiterals (sourceText: SourceText) (defines: string list) (langVersion: string) (lines: HashSet) = + let fileTokenizer = FSharpSourceTokenizer(defines, None, Some langVersion) + let branchTokenizer = FSharpSourceTokenizer([], None, Some langVersion) + let mutable fileState = FSharpTokenizerLexState.Initial + let mutable branchState = FSharpTokenizerLexState.Initial + + for line in sourceText.Lines do + let text = line.ToString() + + if isSkipped fileState && not (isSkippedDirective text) then + if continuesString (FSharpLineTokenizer.ColorStateOfLexState branchState) then + lines.Add line.LineNumber |> ignore + + branchState <- scanLine branchTokenizer text branchState + else + branchState <- FSharpTokenizerLexState.Initial + + fileState <- scanLine fileTokenizer text fileState + +/// Lines inside multi-line string literals, including those in branches of `#if` the defines leave inactive. +let linesInsideLiterals (sourceText: SourceText) (parseTree: ParsedInput) (defines: string list) (langVersion: string) = + let lines = + (HashSet(), parseTree) + ||> ParsedInput.fold (fun lines _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Const(range = m)) + | SyntaxNode.SynExpr(SynExpr.InterpolatedString(range = m)) + | SyntaxNode.SynPat(SynPat.Const(range = m)) -> + for line in m.StartLine + 1 .. m.EndLine do + lines.Add(Line.toZ line) |> ignore + | _ -> () + + lines) + + let directives = + match parseTree with + | ParsedInput.ImplFile file -> file.Trivia.ConditionalDirectives + | ParsedInput.SigFile file -> file.Trivia.ConditionalDirectives + + if not directives.IsEmpty then + linesInsideInactiveLiterals sourceText defines langVersion lines - lines) + lines let usedNames (parseTree: ParsedInput) = (HashSet(StringComparer.Ordinal), parseTree) @@ -95,6 +176,8 @@ let usedNames (parseTree: ParsedInput) = match node with | SyntaxNode.SynExpr(SynExpr.Ident ident) | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _))) + | SyntaxNode.SynExpr(SynExpr.LongIdentSet(longDotId = SynLongIdent(id = ident :: _))) + | SyntaxNode.SynExpr(SynExpr.NamedIndexedPropertySet(longDotId = SynLongIdent(id = ident :: _))) | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = ident :: _))) -> names.Add ident.idText |> ignore | _ -> () diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index 50078b08db0..536a77ce202 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -309,6 +309,62 @@ let ``Lines inside a multi-line string are not re-indented`` () = Assert.Equal(expected, extracted extractToLetBinding code "String.Format(\"\"\"Hello\n{0}\"\"\", name)") +[] +let ``Lines inside a multi-line string in an inactive branch are not re-indented`` () = + // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. + let code = + "module M\n\nlet r = combine 1 [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" + + let expected = + "module M\n\nlet r =\n let extracted = 1\n combine extracted [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" + + Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan(code.IndexOf("1 [", StringComparison.Ordinal), 1))) + +[] +[] +[] +[] +let ``Name does not collide with an assignment target from another file`` (declaration: string, assignment: string) = + let dependency = + """ +module Dependency + +type Box = { mutable Value: int } + +DECLARATION +""" + .Replace("DECLARATION", declaration) + + let code = + """ +module M + +open Dependency + +let f () = + ASSIGNMENT({ Value = 1 }).Value +""" + .Replace("ASSIGNMENT", assignment) + + let expected = + """ +module M + +open Dependency + +let f () = + let extracted1 = { Value = 1 } + ASSIGNMENTextracted1.Value +""" + .Replace("ASSIGNMENT", assignment) + + use context = TestContext.CreateWithCodeAndDependency code dependency + + let document = + refactorSpan code (selectionOf code "({ Value = 1 })") extractToLetBinding context (new FSharpExtractLetBindingRefactoring()) + + Assert.Equal(expected, (document.GetTextAsync() |> GetTaskResult).ToString()) + let private greet = """ module M diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs index 4aad2b130d1..cd385130efe 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs @@ -8,6 +8,8 @@ open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks +open FSharp.Test.ProjectGeneration + open FSharp.Editor.Tests.Helpers open Microsoft.CodeAnalysis.CodeRefactorings open Microsoft.CodeAnalysis.CodeActions @@ -31,11 +33,19 @@ type TestContext(Solution: Solution) = new TestContext(solution) static member CreateWithCodeAndDependency (code: string) (codeForPreviousFile: string) = - let mutable solution = RoslynTestHelpers.CreateSolution(codeForPreviousFile) - - let firstProject = solution.Projects.First() - solution <- solution.AddDocument(DocumentId.CreateNewId(firstProject.Id), "test2.fs", code, filePath = "C:\\test2.fs") - + let project = + { SyntheticProject.Create( + { sourceFile "First" [] with + Source = codeForPreviousFile + }, + { sourceFile "Second" [ "First" ] with + Source = code + } + ) with + AutoAddModules = false + } + + let solution, _ = RoslynTestHelpers.CreateSolution project new TestContext(solution) let tryRefactor (code: string) (cursorPosition) (context: TestContext) (refactorProvider: 'T :> CodeRefactoringProvider) = From f71b0ca7768b3907a627c602df486fc893e7e7a7 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 01:49:33 +0200 Subject: [PATCH 19/31] Add refactorings that extract a selected expression into a local function, a module function or a private member The values the selection reads from the enclosing member or module-level declaration become parameters in the order they are first used; module values, members and 'this' stay where they are. A member that uses 'this' or 'base' can extract into a private member called on its self identifier. Parameter type annotations follow a new Code Fixes option: always, only for receivers of member and indexer accesses and operator operands, or never. A selection that assigns to a captured mutable local, or captures a byref-like value, is not offered. Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../src/FSharp.Editor/FSharp.Editor.fsproj | 1 + .../src/FSharp.Editor/FSharp.Editor.resx | 9 + .../FSharp.Editor/Options/EditorOptions.fs | 20 +- .../FSharp.Editor/Refactor/ExtractFunction.fs | 352 ++++++++++++++++++ .../Refactor/RefactoringHelpers.fs | 2 +- .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 15 + .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 15 + .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 15 + .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 15 + .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 15 + .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 15 + .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 15 + .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 15 + .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 15 + .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 15 + .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 15 + .../xlf/FSharp.Editor.zh-Hans.xlf | 15 + .../xlf/FSharp.Editor.zh-Hant.xlf | 15 + .../CodeFixesOptionControl.xaml | 7 + .../FSharp.UIResources/Strings.Designer.cs | 36 ++ .../src/FSharp.UIResources/Strings.resx | 12 + .../src/FSharp.UIResources/xlf/Strings.cs.xlf | 20 + .../src/FSharp.UIResources/xlf/Strings.de.xlf | 20 + .../src/FSharp.UIResources/xlf/Strings.es.xlf | 20 + .../src/FSharp.UIResources/xlf/Strings.fr.xlf | 20 + .../src/FSharp.UIResources/xlf/Strings.it.xlf | 20 + .../src/FSharp.UIResources/xlf/Strings.ja.xlf | 20 + .../src/FSharp.UIResources/xlf/Strings.ko.xlf | 20 + .../src/FSharp.UIResources/xlf/Strings.pl.xlf | 20 + .../FSharp.UIResources/xlf/Strings.pt-BR.xlf | 20 + .../src/FSharp.UIResources/xlf/Strings.ru.xlf | 20 + .../src/FSharp.UIResources/xlf/Strings.tr.xlf | 20 + .../xlf/Strings.zh-Hans.xlf | 20 + .../xlf/Strings.zh-Hant.xlf | 20 + .../FSharp.Editor.Tests.fsproj | 1 + .../Refactors/ExtractFunctionTests.fs | 127 +++++++ 37 files changed, 1021 insertions(+), 2 deletions(-) create mode 100644 vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 514a77b782f..28bf444bad6 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -9,6 +9,7 @@ * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) * **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration. * **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration, also with just the caret inside the constant. +* **Extract to local function**, **Extract to module function** and **Extract to private member** refactorings for a selected expression. Values the selection reads from the enclosing function, member or lambda become parameters in the order they are first used, typed as the new **Parameter types in Extract to function** option under **Code Fixes** asks. A selection that assigns to a captured mutable local, or reads a byref or byref-like value from outside, is not offered. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 7c41a67c593..4f04b2bcd08 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -111,6 +111,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx index c10dcdea2dd..b3f9c235ef8 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx @@ -379,4 +379,13 @@ Use live (unsaved) buffers for analysis Extract to literal + + Extract to local function + + + Extract to module function + + + Extract to private member + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs b/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs index 4b0f885e892..68b507b7170 100644 --- a/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs +++ b/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs @@ -16,6 +16,12 @@ type EnterKeySetting = | NewlineOnCompleteWord | AlwaysNewline +[] +type ParameterAnnotationSetting = + | Always + | WhenNeeded + | Never + // CLIMutable to make the record work also as a view model [] type IntelliSenseOptions = @@ -68,6 +74,7 @@ type CodeFixesOptions = UnusedDeclarations: bool SuggestNamesForErrors: bool RemoveParens: bool + ExtractFunctionParameterAnnotations: ParameterAnnotationSetting } static member Default = @@ -79,6 +86,7 @@ type CodeFixesOptions = UnusedDeclarations = true SuggestNamesForErrors = true RemoveParens = false + ExtractFunctionParameterAnnotations = ParameterAnnotationSetting.Always } [] @@ -213,7 +221,14 @@ module internal OptionsUI = [] type internal CodeFixesOptionPage() = inherit AbstractOptionPage() - override this.CreateView() = upcast CodeFixesOptionControl() + + override this.CreateView() = + let view = CodeFixesOptionControl() + let path = nameof CodeFixesOptions.Default.ExtractFunctionParameterAnnotations + bindRadioButton view.annotateAlways path ParameterAnnotationSetting.Always + bindRadioButton view.annotateWhenNeeded path ParameterAnnotationSetting.WhenNeeded + bindRadioButton view.annotateNever path ParameterAnnotationSetting.Never + upcast view [] type internal LanguageServicePerformanceOptionPage() = @@ -263,6 +278,9 @@ module EditorOptionsExtensions = member this.IsFsharpRemoveParensEnabled = this.EditorOptions.CodeFixes.RemoveParens + member this.FSharpExtractFunctionParameterAnnotations = + this.EditorOptions.CodeFixes.ExtractFunctionParameterAnnotations + member this.IsFSharpCodeFixesSuggestNamesForErrorsEnabled = this.EditorOptions.CodeFixes.SuggestNamesForErrors diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs new file mode 100644 index 00000000000..e2c3ba3858b --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs @@ -0,0 +1,352 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Collections.Generic +open System.Composition +open System.Threading + +open Microsoft.CodeAnalysis.CodeActions +open Microsoft.CodeAnalysis.CodeRefactorings +open Microsoft.CodeAnalysis.Formatting +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor.Telemetry + +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text + +open RefactoringHelpers +open CancellableTasks + +[] +module private FunctionExtraction = + + [] + type Parameter = + { + Name: string + Type: string + Uses: range list + } + + let private padding (width: int) = String(' ', width) + + /// The declaration whose locals the extracted code may capture: the enclosing member or module-level declaration. + let enclosingScope (path: SyntaxVisitorPath) = + path + |> List.tryPick (function + | SyntaxNode.SynMemberDefn(SynMemberDefn.Member(range = m) | SynMemberDefn.LetBindings(range = m)) + | SyntaxNode.SynModule(SynModuleDecl.Let(range = m) | SynModuleDecl.Expr(range = m)) -> Some m + | _ -> None) + + let private isByRefLike (fullType: FSharpType) = + fullType.HasTypeDefinition + && (fullType.TypeDefinition.IsByRef + || fullType.TypeDefinition.Attributes + |> Seq.exists (fun attribute -> + String.Equals(attribute.AttributeType.CompiledName, "IsByRefLikeAttribute", StringComparison.Ordinal))) + + /// The values the selection reads from its scope, in order of first use, and whether it uses this or base; + /// ValueNone when a captured value has no type or a byref-like one. + let tryCaptures (checkResults: FSharpCheckFileResults) (selection: range) (scope: range) (cancellationToken: CancellationToken) = + let parameters = Dictionary(Range.comparer) + let mutable usesThis = false + let mutable usesBase = false + let mutable capturable = true + + for symbolUse in checkResults.GetAllUsesOfAllSymbolsInFile cancellationToken do + if + not symbolUse.IsFromDefinition + && Range.rangeContainsRange selection symbolUse.Range + then + match symbolUse.Symbol with + | :? FSharpMemberOrFunctionOrValue as value when value.IsMemberThisValue || value.IsConstructorThisValue -> usesThis <- true + | :? FSharpMemberOrFunctionOrValue as value when value.IsBaseValue -> usesBase <- true + | :? FSharpMemberOrFunctionOrValue as value when not value.IsModuleValueOrMember -> + match symbolUse.Symbol.DeclarationLocation with + | Some declaration when + Range.rangeContainsRange scope declaration + && not (Range.rangeContainsRange selection declaration) + -> + match parameters.TryGetValue declaration, value.FullTypeSafe with + | (true, parameter), _ -> + parameters[declaration] <- + { parameter with + Uses = symbolUse.Range :: parameter.Uses + } + | (false, _), Some fullType when not (isByRefLike fullType) -> + parameters[declaration] <- + { + Name = value.DisplayName + Type = fullType.FormatWithConstraints symbolUse.DisplayContext + Uses = [ symbolUse.Range ] + } + | (false, _), _ -> capturable <- false + | _ -> () + | _ -> () + + if capturable then + let firstUse (parameter: Parameter) = + parameter.Uses + |> List.map (fun m -> struct (m.StartLine, m.StartColumn)) + |> List.min + + ValueSome(struct (parameters.Values |> Seq.sortBy firstUse |> List.ofSeq, usesThis, usesBase)) + else + ValueNone + + /// Whether the selection assigns to one of the captured values, which a parameter could not carry back. + let assignsParameter (expr: SynExpr) (parameters: Parameter list) = + let assigned = + (HashSet(), [ SyntaxNode.SynExpr expr ]) + ||> SyntaxNodes.fold (fun positions _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.LongIdentSet(longDotId = SynLongIdent(id = [ ident ]))) + | SyntaxNode.SynExpr(SynExpr.Set(targetExpr = SynExpr.Ident ident)) -> positions.Add ident.idRange.Start |> ignore + | _ -> () + + positions) + + parameters + |> List.exists (fun parameter -> parameter.Uses |> List.exists (fun m -> assigned.Contains m.Start)) + + /// Positions of identifiers whose type inference cannot tell from their use alone: receivers of a member or indexer + /// access and operands of an operator. + let positionsNeedingType (expr: SynExpr) = + (HashSet(), [ SyntaxNode.SynExpr expr ]) + ||> SyntaxNodes.fold (fun positions _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = head :: _ :: _))) + | SyntaxNode.SynExpr(SynExpr.DotGet(expr = SynExpr.Ident head)) + | SyntaxNode.SynExpr(SynExpr.DotIndexedGet(objectExpr = SynExpr.Ident head)) + | SyntaxNode.SynExpr(SynExpr.App( + flag = ExprAtomicFlag.Atomic; funcExpr = SynExpr.Ident head; argExpr = SynExpr.ArrayOrListComputed _)) + | SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true; argExpr = SynExpr.Ident head))) + | SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true); argExpr = SynExpr.Ident head)) -> + positions.Add head.idRange.Start |> ignore + | _ -> () + + positions) + + let private declared (annotate: Parameter -> bool) (parameter: Parameter) = + if annotate parameter then + $"{parameter.Name}: {parameter.Type}" + else + parameter.Name + + let curriedParameters (parameters: Parameter list) (annotate: Parameter -> bool) = + match parameters with + | [] -> "()" + | _ -> + parameters + |> List.map (fun parameter -> + if annotate parameter then + $"({declared annotate parameter})" + else + parameter.Name) + |> String.concat " " + + let curriedArguments (parameters: Parameter list) = + match parameters with + | [] -> "()" + | _ -> parameters |> List.map _.Name |> String.concat " " + + let tupledParameters (parameters: Parameter list) (annotate: Parameter -> bool) = + let declarations = parameters |> List.map (declared annotate) |> String.concat ", " + $"({declarations})" + + let tupledArguments (parameters: Parameter list) = + let arguments = parameters |> List.map _.Name |> String.concat ", " + $"({arguments})" + + /// Whether a function application put where the selection was needs parentheses to stay one argument. + let needsParentheses (target: ExtractionTarget) = + match target.Expr, target.Path with + | SynExpr.Paren _, _ when target.Replaced = target.Content -> false + | _, SyntaxNode.SynExpr(SynExpr.App _ | SynExpr.DotGet _ | SynExpr.DotIndexedGet _ | SynExpr.TypeApp _) :: _ -> true + | _ -> false + + let isInRecursiveModuleLet (path: SyntaxVisitorPath) = + path + |> List.exists (function + | SyntaxNode.SynModule(SynModuleDecl.Let(isRecursive = true)) -> true + | _ -> false) + + /// The member of a class, record or union that contains the selection, outside interface implementations and + /// object expressions. + let tryEnclosingMember (path: SyntaxVisitorPath) = + let rec loop (path: SyntaxVisitorPath) = + match path with + | SyntaxNode.SynMemberDefn(SynMemberDefn.Member(memberDefn = binding; range = memberRange)) :: SyntaxNode.SynTypeDefn(SynTypeDefn( + typeInfo = info)) :: _ -> ValueSome(struct (binding, memberRange, info)) + | (SyntaxNode.SynMemberDefn(SynMemberDefn.Interface _) | SyntaxNode.SynExpr(SynExpr.ObjExpr _)) :: _ + | [] -> ValueNone + | _ :: rest -> loop rest + + loop path + + /// The start of the new member's declaration and the receiver it is called on: the self identifier of an instance + /// member, or the name of a non-generic type for a static member. + let tryMemberCallee (binding: SynBinding) (info: SynComponentInfo) = + match binding, info with + | SynBinding(headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = [ self; _ ]))), _ when + not (self.idText.StartsWith("_", StringComparison.Ordinal)) + -> + ValueSome(struct ($"member private {self.idText}.", self.idText)) + | SynBinding(headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = [ _ ]))), SynComponentInfo(typeParams = None) -> + ValueSome(struct ("static member private ", (List.last info.LongIdent).idText)) + | _ -> ValueNone + + /// Changes that declare `header = ` right after the member and call it where the selection was. + let tryMemberChanges + (sourceText: SourceText) + (target: ExtractionTarget) + (memberRange: range) + (header: string) + (call: string) + (indentSize: int) + (literalLines: HashSet) + = + let lines = sourceText.Lines + let firstLine = lines[Line.toZ memberRange.StartLine] + let lastLine = lines[Line.toZ memberRange.EndLine] + let indent = leadingSpaces sourceText firstLine + let lineBreak = lineBreakOf sourceText + + tryDeclaration sourceText target.Content header (indent + indentSize) literalLines lineBreak + |> ValueOption.map (fun declaration -> + let insertion = + if lastLine.EndIncludingLineBreak > lastLine.End then + TextChange(TextSpan(lastLine.EndIncludingLineBreak, 0), $"{padding indent}{declaration}{lineBreak}") + else + TextChange(TextSpan(lastLine.End, 0), $"{lineBreak}{padding indent}{declaration}") + + [ TextChange(target.Replaced, call); insertion ]) + +[] +type internal FSharpExtractFunctionRefactoring [] () = + inherit CodeRefactoringProvider() + + static let register + (context: CodeRefactoringContext) + (sourceText: SourceText) + (title: string) + (kind: string) + (changes: TextChange list) + = + let changedDocument = + cancellableTask { + TelemetryReporter.ReportSingleEvent( + TelemetryEvents.RefactoringActivated, + [| "name", box (nameof FSharpExtractFunctionRefactoring); "kind", box kind |] + ) + + return context.Document.WithText(sourceText.WithChanges changes) + } + + context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + + override _.ComputeRefactoringsAsync context = + cancellableTask { + let document = context.Document + + if not (context.Span.IsEmpty || document.IsFSharpSignatureFile) then + let! cancellationToken = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync cancellationToken + let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractFunctionRefactoring) + + match tryExtractionTarget sourceText parseResults.ParseTree context.Span with + | ValueNone -> () + | ValueSome target -> + match FunctionExtraction.enclosingScope target.Path with + | None -> () + | Some scope -> + let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof FSharpExtractFunctionRefactoring) + + let selection = + Range.mkRange + document.FilePath + (positionOf sourceText target.Content.Start) + (positionOf sourceText target.Content.End) + + match FunctionExtraction.tryCaptures checkResults selection scope cancellationToken with + | ValueSome(struct (parameters, usesThis, usesBase)) when + not (FunctionExtraction.assignsParameter target.Expr parameters) + -> + let! options = document.GetOptionsAsync cancellationToken + + let indentSize = + options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) + + let literalLines = linesInsideLiterals parseResults.ParseTree + let names = usedNames parseResults.ParseTree + let needingType = FunctionExtraction.positionsNeedingType target.Expr + let setting = document.Project.FSharpExtractFunctionParameterAnnotations + + let annotate (parameter: FunctionExtraction.Parameter) = + match setting with + | ParameterAnnotationSetting.Always -> true + | ParameterAnnotationSetting.WhenNeeded -> + parameter.Uses |> List.exists (fun m -> needingType.Contains m.Start) + | ParameterAnnotationSetting.Never -> false + + let functionName = uniqueName "extractedFunction" names + + let header = + $"{functionName} {FunctionExtraction.curriedParameters parameters annotate}" + + let application = $"{functionName} {FunctionExtraction.curriedArguments parameters}" + + let call = + if FunctionExtraction.needsParentheses target then + $"({application})" + else + application + + if not usesBase then + match anchorsOf target.Expr target.Path with + | anchor :: _ -> + match tryDeclareInFront sourceText target anchor $"let {header}" call indentSize literalLines with + | ValueSome changes -> register context sourceText (SR.ExtractToLocalFunction()) "local" changes + | ValueNone -> () + | [] -> () + + if not (usesThis || usesBase || FunctionExtraction.isInRecursiveModuleLet target.Path) then + match + tryDeclareInFrontOfModuleLet sourceText target [] $"let private {header}" call indentSize literalLines + with + | ValueSome changes -> register context sourceText (SR.ExtractToModuleFunction()) "module" changes + | ValueNone -> () + + match FunctionExtraction.tryEnclosingMember target.Path with + | ValueSome(struct (binding, memberRange, info)) -> + match FunctionExtraction.tryMemberCallee binding info with + | ValueSome(struct (prefix, receiver)) -> + let memberName = uniqueName "ExtractedMethod" names + + let memberHeader = + $"{prefix}{memberName}{FunctionExtraction.tupledParameters parameters annotate}" + + let memberCall = + $"{receiver}.{memberName}{FunctionExtraction.tupledArguments parameters}" + + match + FunctionExtraction.tryMemberChanges + sourceText + target + memberRange + memberHeader + memberCall + indentSize + literalLines + with + | ValueSome changes -> register context sourceText (SR.ExtractToPrivateMember()) "member" changes + | ValueNone -> () + | ValueNone -> () + | ValueNone -> () + | _ -> () + } + |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index fc0ba48abb3..e38179a548f 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -514,7 +514,7 @@ let tryEnclosingModuleLet (path: SyntaxVisitorPath) = let private padding (width: int) = String(' ', width) /// `header = `, with a multi-line content starting on its own line at bodyColumn. -let private tryDeclaration (sourceText: SourceText) (content: TextSpan) (header: string) (bodyColumn: int) literalLines lineBreak = +let tryDeclaration (sourceText: SourceText) (content: TextSpan) (header: string) (bodyColumn: int) literalLines lineBreak = tryIndentedText sourceText content bodyColumn literalLines |> ValueOption.map (fun body -> let lines = sourceText.Lines diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index 387434eef97..ee91306e8c9 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -121,6 +121,21 @@ Navrhnout názvy pro nerozpoznané identifikátory; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) Uvolnitelné hodnoty jazyka F# (místní) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf index 08cdadae187..3da69d80fff 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -121,6 +121,21 @@ Namen für nicht aufgelöste Bezeichner vorschlagen; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) Disposable-Werte in F# (lokal) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index dc34e8940c3..87c04dc4893 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -121,6 +121,21 @@ Sugerir nombres para identificadores sin resolver; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) Valores de F# descartables (locales) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index 5ecccd93816..34eac7d40be 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -121,6 +121,21 @@ Suggérer des noms pour les identificateurs non résolus ; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) Valeurs F# pouvant être supprimées (variables locales) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index 28eefa81c2c..13485b7fbeb 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -121,6 +121,21 @@ Suggerisci i nomi per gli identificatori non risolti; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) Valori eliminabili F# (variabili locali) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index 8289471a2ee..0a1c6d80bf6 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -121,6 +121,21 @@ Suggest names for unresolved identifiers; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) F# の破棄可能な値 (ローカル) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index a2007f1e21d..641d1532bbd 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -121,6 +121,21 @@ Suggest names for unresolved identifiers; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) F# 삭제 가능한 값(로컬) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index 3d31b1823cf..b0a39958122 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -121,6 +121,21 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) Wartości możliwe do likwidacji języka F# (lokalne) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf index 533186ebaa4..7aa3304364b 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf @@ -121,6 +121,21 @@ Sugerir nomes para identificadores não resolvidos; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) Valores F# Descartáveis (locais) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 6fa7d93f52f..820d1a83b65 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -121,6 +121,21 @@ Suggest names for unresolved identifiers; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) Освобождаемые значения F# (локальные) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index dc2fd65cde1..6a5e5538f2d 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -121,6 +121,21 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) F# Atılabilir Değerleri (yereller) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf index 3926317e5ed..a95893afe2f 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf @@ -121,6 +121,21 @@ Suggest names for unresolved identifiers; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) F# 可释放值(局部值) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf index b23cdd33971..7f4c4d80ff3 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf @@ -121,6 +121,21 @@ Suggest names for unresolved identifiers; Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + F# Disposable Values (locals) F# 可處置的值 (區域) diff --git a/vsintegration/src/FSharp.UIResources/CodeFixesOptionControl.xaml b/vsintegration/src/FSharp.UIResources/CodeFixesOptionControl.xaml index 211e17fd3ce..c69d7074e7b 100644 --- a/vsintegration/src/FSharp.UIResources/CodeFixesOptionControl.xaml +++ b/vsintegration/src/FSharp.UIResources/CodeFixesOptionControl.xaml @@ -35,6 +35,13 @@ Content="{x:Static local:Strings.Remove_parens_code_fix}"/> + + + + + + + diff --git a/vsintegration/src/FSharp.UIResources/Strings.Designer.cs b/vsintegration/src/FSharp.UIResources/Strings.Designer.cs index e47f10e1045..d0621fa2a95 100644 --- a/vsintegration/src/FSharp.UIResources/Strings.Designer.cs +++ b/vsintegration/src/FSharp.UIResources/Strings.Designer.cs @@ -87,6 +87,33 @@ public static string Analyze_full_solution_on_background { } } + /// + /// 查找类似 Annotate every parameter 的本地化字符串。 + /// + public static string Annotate_parameters_always { + get { + return ResourceManager.GetString("Annotate_parameters_always", resourceCulture); + } + } + + /// + /// 查找类似 Leave parameter types to type inference 的本地化字符串。 + /// + public static string Annotate_parameters_never { + get { + return ResourceManager.GetString("Annotate_parameters_never", resourceCulture); + } + } + + /// + /// 查找类似 Annotate parameters whose type inference cannot determine from the extracted code 的本地化字符串。 + /// + public static string Annotate_parameters_when_needed { + get { + return ResourceManager.GetString("Annotate_parameters_when_needed", resourceCulture); + } + } + /// /// 查找类似 Background analysis 的本地化字符串。 /// @@ -249,6 +276,15 @@ public static string Enter_Key_Rule { } } + /// + /// 查找类似 Parameter types in Extract to function 的本地化字符串。 + /// + public static string Extract_function_parameter_annotations { + get { + return ResourceManager.GetString("Extract_function_parameter_annotations", resourceCulture); + } + } + /// /// 查找类似 Find References Performance Options 的本地化字符串。 /// diff --git a/vsintegration/src/FSharp.UIResources/Strings.resx b/vsintegration/src/FSharp.UIResources/Strings.resx index 12e5b87aae8..4040b112bdf 100644 --- a/vsintegration/src/FSharp.UIResources/Strings.resx +++ b/vsintegration/src/FSharp.UIResources/Strings.resx @@ -315,4 +315,16 @@ Generate default implementation body for overridden method + + Parameter types in Extract to function + + + Annotate every parameter + + + Annotate parameters whose type inference cannot determine from the extracted code + + + Leave parameter types to type inference + \ No newline at end of file diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.cs.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.cs.xlf index e55f176a39b..f106b7f80ae 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.cs.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.cs.xlf @@ -17,6 +17,21 @@ Nadále analyzovat celé řešení pro diagnostiku jako úlohu na pozadí s nízkou prioritou (vyžaduje restartování) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis Analýza na pozadí @@ -42,6 +57,11 @@ Povolit částečnou kontrolu typu + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options Najít možnosti výkonu odkazů diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.de.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.de.xlf index b07e4473e52..4b1c70ae34d 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.de.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.de.xlf @@ -17,6 +17,21 @@ Analyse der gesamten Projektmappe zur Diagnose als Hintergrundaufgabe mit niedriger Priorität fortsetzen (Neustart erforderlich) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis Hintergrundanalyse @@ -42,6 +57,11 @@ Aktivieren der partiellen Typüberprüfung + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options Leistungsoptionen für Verweise suchen diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.es.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.es.xlf index aa652aa8723..3af5c185d32 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.es.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.es.xlf @@ -17,6 +17,21 @@ Siga analizando toda la solución para los diagnósticos como una tarea en segundo plano de prioridad baja (requiere un reinicio) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis Análisis en segundo plano @@ -42,6 +57,11 @@ Habilitar la comprobación parcial de tipos + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options Buscar opciones de rendimiento de referencias diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.fr.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.fr.xlf index db51d37fb99..0203ff89e6f 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.fr.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.fr.xlf @@ -17,6 +17,21 @@ Continuez à analyser l'intégralité de la solution pour les diagnostics en tant que tâche en arrière-plan de basse priorité (nécessite un redémarrage) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis Analyse de fond @@ -42,6 +57,11 @@ Activer la vérification de type partielle + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options Options de performances de recherche de références diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.it.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.it.xlf index d220a899907..dcb8eddf184 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.it.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.it.xlf @@ -17,6 +17,21 @@ Continua ad analizzare l'intera soluzione per la diagnostica come attività in background a priorità bassa (richiede il riavvio) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis Analisi in background @@ -42,6 +57,11 @@ Abilita il controllo parziale dei tipi + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options Trovare opzioni prestazioni riferimenti diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.ja.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.ja.xlf index 9328453eba8..0f360088933 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.ja.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.ja.xlf @@ -17,6 +17,21 @@ 診断のソリューション全体を低優先度のバックグラウンド タスクとして分析し続ける (再起動が必要) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis バックグラウンド分析 @@ -42,6 +57,11 @@ 部分型チェックを有効にする + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options 参照の検索のパフォーマンス オプション diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.ko.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.ko.xlf index a31cd3ee3e2..1e770d500d8 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.ko.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.ko.xlf @@ -17,6 +17,21 @@ 진단에 대한 전체 솔루션을 낮은 우선 순위 백그라운드 작업으로 계속 분석(다시 시작해야 함) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis 백그라운드 분석 @@ -42,6 +57,11 @@ 부분 형식 검사 사용 + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options 참조 성능 옵션 찾기 diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.pl.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.pl.xlf index af20566c256..02641c0560a 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.pl.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.pl.xlf @@ -17,6 +17,21 @@ Analizuj całe rozwiązanie pod kątem diagnostyki jako zadanie w tle o niskim priorytecie (wymaga ponownego uruchomienia) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis Analiza w tle @@ -42,6 +57,11 @@ Włącz kontrolę typu częściowego + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options Opcje wydajności znajdowania odwołań diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.pt-BR.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.pt-BR.xlf index d0bc2aebb17..735afa156ec 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.pt-BR.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.pt-BR.xlf @@ -17,6 +17,21 @@ Continuar analisando toda a solução para diagnóstico como uma tarefa em segundo plano de baixa prioridade (requer reinicialização) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis Análise em segundo plano @@ -42,6 +57,11 @@ Habilitar verificação parcial de tipo + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options Opções de Localizar Referências de Desempenho diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.ru.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.ru.xlf index 170dff1df34..92519c3bc9c 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.ru.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.ru.xlf @@ -17,6 +17,21 @@ Продолжать анализ всего решения для диагностики в качестве низкоприоритетной фоновой задачи (требуется перезагрузка) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis Фоновый анализ @@ -42,6 +57,11 @@ Включить частичную проверку типов + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options Параметры производительности поиска ссылок diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.tr.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.tr.xlf index 1a8bea80cc1..47b5c829181 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.tr.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.tr.xlf @@ -17,6 +17,21 @@ Düşük öncelikli arka plan görevi olarak tanılama için çözümün tamamını analiz etmeye devam et (yeniden başlatma gerekir) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis Arka plan analizi @@ -42,6 +57,11 @@ Kısmi tür denetlemeyi etkinleştir + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options Başvuruları Bul Performans Seçenekleri diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hans.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hans.xlf index 7e573f64315..5ac83524b10 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hans.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hans.xlf @@ -17,6 +17,21 @@ 继续分析整个解决方案以将诊断作为低优先级后台任务(需要重启) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis 后台分析 @@ -42,6 +57,11 @@ 启用分部类型检查 + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options 查找引用性能选项 diff --git a/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hant.xlf b/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hant.xlf index 868be56e015..28a4a3af1cd 100644 --- a/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hant.xlf +++ b/vsintegration/src/FSharp.UIResources/xlf/Strings.zh-Hant.xlf @@ -17,6 +17,21 @@ 將分析整個解決方案以進行診斷保持為低優先順序背景工作 (需要重新開機) + + Annotate every parameter + Annotate every parameter + + + + Leave parameter types to type inference + Leave parameter types to type inference + + + + Annotate parameters whose type inference cannot determine from the extracted code + Annotate parameters whose type inference cannot determine from the extracted code + + Background analysis 背景分析 @@ -42,6 +57,11 @@ 啟用部分型別檢查 + + Parameter types in Extract to function + Parameter types in Extract to function + + Find References Performance Options 尋找參考效能選項 diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index dfae9f2c44e..4b914390fa1 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -80,6 +80,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs new file mode 100644 index 00000000000..b53b0753241 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs @@ -0,0 +1,127 @@ +module FSharp.Editor.Tests.Refactors.ExtractFunctionTests + +open System + +open Microsoft.CodeAnalysis.Text + +open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks + +open Xunit + +open FSharp.Editor.Tests.Helpers +open FSharp.Editor.Tests.Refactors.RefactorTestFramework + +let private extractToLocalFunction = "Extract to local function" +let private extractToModuleFunction = "Extract to module function" +let private extractToPrivateMember = "Extract to private member" + +let private selectionOf (code: string) (selected: string) = + TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) + +let private contextFor (setting: ParameterAnnotationSetting) (code: string) = + let options = + { CodeFixesOptions.Default with + ExtractFunctionParameterAnnotations = setting + } + + new TestContext(RoslynTestHelpers.CreateSolution(code, editorOptions = options)) + +let private extractedWith (setting: ParameterAnnotationSetting) (title: string) (code: string) (selected: string) = + use context = contextFor setting code + + let document = + refactorSpan code (selectionOf code selected) title context (new FSharpExtractFunctionRefactoring()) + + let parseResults = + document.GetFSharpParseResultsAsync "test" + |> CancellableTask.runSynchronouslyWithoutCancellation + + Assert.Empty(parseResults.Diagnostics) + (document.GetTextAsync() |> GetTaskResult).ToString() + +let private extracted = extractedWith ParameterAnnotationSetting.Always + +let private titlesFor (code: string) (selected: string) = + use context = contextFor ParameterAnnotationSetting.Always code + + tryGetRefactoringActionsForSpan code (selectionOf code selected) context (new FSharpExtractFunctionRefactoring()) + |> Seq.map _.Title + |> List.ofSeq + +let private area = + "module M\n\nlet area (w: int) (h: int) =\n printfn \"%d\" (w * h + 1)\n" + +[] +let ``Captured parameters become parameters of a local function`` () = + let expected = + "module M\n\nlet area (w: int) (h: int) =\n let extractedFunction (w: int) (h: int) = w * h + 1\n printfn \"%d\" (extractedFunction w h)\n" + + Assert.Equal(expected, extracted extractToLocalFunction area "w * h + 1") + +[] +let ``Module function is declared in front of the declaration using it`` () = + let expected = + "module M\n\nlet private extractedFunction (w: int) (h: int) = w * h + 1\n\nlet area (w: int) (h: int) =\n printfn \"%d\" (extractedFunction w h)\n" + + Assert.Equal(expected, extracted extractToModuleFunction area "w * h + 1") + +[] +let ``Call keeps its parentheses where the selection lost them`` () = + let expected = + "module M\n\nlet area (w: int) (h: int) =\n let extractedFunction (w: int) (h: int) = w * h + 1\n printfn \"%d\" (extractedFunction w h)\n" + + Assert.Equal(expected, extracted extractToLocalFunction area "(w * h + 1)") + +[] +let ``Selection without captures becomes a function of unit`` () = + let code = "module M\n\nlet f () =\n printfn \"%d\" (1 + 2)\n" + + let expected = + "module M\n\nlet f () =\n let extractedFunction () = 1 + 2\n printfn \"%d\" (extractedFunction ())\n" + + Assert.Equal(expected, extracted extractToLocalFunction code "1 + 2") + +[] +let ``Selection using this becomes a private member`` () = + let code = + "module M\n\ntype Order(lines: int list) =\n member this.Rate = 3\n member this.Total = lines |> List.sumBy (fun l -> l * this.Rate)\n" + + let expected = + "module M\n\ntype Order(lines: int list) =\n member this.Rate = 3\n member this.Total = lines |> List.sumBy (fun l -> this.ExtractedMethod(l))\n member private this.ExtractedMethod(l: int) = l * this.Rate\n" + + Assert.Equal(expected, extracted extractToPrivateMember code "l * this.Rate") + +[] +[ ParameterAnnotationSetting.Always + | "WhenNeeded" -> ParameterAnnotationSetting.WhenNeeded + | _ -> ParameterAnnotationSetting.Never + + let code = + "module M\n\nlet shout (s: string) (n: int) =\n printfn \"%s\" (s.ToUpper() + string n)\n" + + let expected = + $"module M\n\nlet shout (s: string) (n: int) =\n {header} s.ToUpper() + string n\n printfn \"%%s\" (extractedFunction s n)\n" + + Assert.Equal(expected, extractedWith setting extractToLocalFunction code "s.ToUpper() + string n") + +[] +let ``Variants follow what the selection uses`` () = + Assert.Equal([ extractToLocalFunction; extractToModuleFunction ], titlesFor area "w * h + 1") + + let derived = + "module M\n\ntype Derived() =\n inherit System.Object()\n override this.ToString() = base.ToString() + \"!\"\n" + + Assert.Equal([ extractToPrivateMember ], titlesFor derived "base.ToString() + \"!\"") + +[] +[] +[) =\n x <- x + 1\n", "x + 1")>] +let ``No action`` (code: string, selected: string) = Assert.Empty(titlesFor code selected) From 7389765cf4d8efe33843acdebc968f93855c187a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 13:33:14 +0200 Subject: [PATCH 20/31] Extract a parenthesized lambda from a caret in its fun ... -> header 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) --- .../Refactor/ExtractLetBinding.fs | 5 +- .../Refactors/ExtractLetBindingTests.fs | 504 +++--------------- 2 files changed, 74 insertions(+), 435 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 13dca45a187..4bc595bf49d 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -69,11 +69,8 @@ type internal FSharpExtractLetBindingRefactoring [] () = let indentSize = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) - let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpExtractLetBindingRefactoring) let names = usedNames parseResults.ParseTree - - let literalLines = - linesInsideLiterals sourceText parseResults.ParseTree defines langVersion + let literalLines = linesInsideLiterals parseResults.ParseTree if isSelected then match anchorsOf target.Expr target.Path with diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index 536a77ce202..7974f2ba4b8 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -46,246 +46,91 @@ let private titlesAt (code: string) (span: TextSpan) = let private titlesFor (code: string) (selected: string) = titlesAt code (selectionOf code selected) -let private area = - """ -module M - -let area w h = - printfn "%d" (w * h + 1) -""" - [] -[] -[] -let ``Expression in a statement is bound in front of it`` (selected: string, expected: string) = - Assert.Equal(expected, extracted extractToLetBinding area selected) +[] +[] +let ``Expression in a statement is bound in front of it`` (selected: string, body: string) = + let code = "module M\n\nlet area w h =\n printfn \"%d\" (w * h + 1)\n" + Assert.Equal($"module M\n\nlet area w h =\n{body}", extracted extractToLetBinding code selected) [] let ``Parentheses of a method call stay`` () = let code = - """ -module M - -let append (sb: System.Text.StringBuilder) = - sb.Append(1 + 2) -""" + "module M\n\nlet append (sb: System.Text.StringBuilder) =\n sb.Append(1 + 2)\n" let expected = - """ -module M - -let append (sb: System.Text.StringBuilder) = - let extracted = 1 + 2 - sb.Append(extracted) -""" + "module M\n\nlet append (sb: System.Text.StringBuilder) =\n let extracted = 1 + 2\n sb.Append(extracted)\n" Assert.Equal(expected, extracted extractToLetBinding code "(1 + 2)") -let private run = - """ -module M - -let run items = - let mutable acc = 0 - let total = - items - |> List.filter (fun i -> i > acc) - |> List.sum - total -""" - [] let ``Multi-line right-hand side is bound in front of its let`` () = - let expected = - """ -module M - -let run items = - let mutable acc = 0 - let extracted = - items - |> List.filter (fun i -> i > acc) - |> List.sum - let total = - extracted - total -""" - - let start = run.LastIndexOf("items", StringComparison.Ordinal) + let code = + "module M\n\nlet run items =\n let mutable acc = 0\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n total\n" - let finish = - run.IndexOf("|> List.sum", start, StringComparison.Ordinal) - + "|> List.sum".Length + let expected = + "module M\n\nlet run items =\n let mutable acc = 0\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n total\n" - Assert.Equal(expected, extractedAt extractToLetBinding run (TextSpan.FromBounds(start, finish))) + Assert.Equal(expected, extracted extractToLetBinding code "items\n |> List.filter (fun i -> i > acc)\n |> List.sum") [] let ``Whole lines selected with their indentation and line break are extracted`` () = let code = - """ -module M - -let run items = - let mutable acc = 0 - - let total = - items - |> List.filter (fun i -> i > acc) - |> List.sum - - total -""" + "module M\n\nlet run items =\n let mutable acc = 0\n\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n\n total\n" let expected = - """ -module M - -let run items = - let mutable acc = 0 - - let extracted = - items - |> List.filter (fun i -> i > acc) - |> List.sum - let total = - extracted + "module M\n\nlet run items =\n let mutable acc = 0\n\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n\n total\n" - total -""" - - let lineStart = code.LastIndexOf(" items", StringComparison.Ordinal) - - let afterLineBreak = - code.IndexOf('\n', code.IndexOf("|> List.sum", lineStart, StringComparison.Ordinal)) - + 1 - - Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan.FromBounds(lineStart, afterLineBreak))) + Assert.Equal( + expected, + extracted extractToLetBinding code " items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n" + ) [] let ``Match clause body on the arrow line moves to its own lines`` () = let code = - """ -module M - -let f x offset = - match x with - | Some v -> v * 2 + offset - | None -> 0 -""" + "module M\n\nlet f x offset =\n match x with\n | Some v -> v * 2 + offset\n | None -> 0\n" let expected = - """ -module M - -let f x offset = - match x with - | Some v -> - let extracted = v * 2 + offset - extracted - | None -> 0 -""" + "module M\n\nlet f x offset =\n match x with\n | Some v ->\n let extracted = v * 2 + offset\n extracted\n | None -> 0\n" Assert.Equal(expected, extracted extractToLetBinding code "v * 2 + offset") [] let ``Right-hand side on the equals line keeps its trailing comment`` () = - let code = - """ -module M - -let r = compute a b // slow -""" + let code = "module M\n\nlet r = compute a b // slow\n" let expected = - """ -module M - -let r = - let extracted = compute a b - extracted // slow -""" + "module M\n\nlet r =\n let extracted = compute a b\n extracted // slow\n" Assert.Equal(expected, extracted extractToLetBinding code "compute a b") [] let ``Expression in a computation expression is bound in front of its statement`` () = let code = - """ -module M - -let load id = async { - let! raw = fetch id - return parse raw |> List.length } -""" + "module M\n\nlet load id = async {\n let! raw = fetch id\n return parse raw |> List.length }\n" let expected = - """ -module M - -let load id = async { - let! raw = fetch id - let extracted = parse raw |> List.length - return extracted } -""" + "module M\n\nlet load id = async {\n let! raw = fetch id\n let extracted = parse raw |> List.length\n return extracted }\n" Assert.Equal(expected, extracted extractToLetBinding code "parse raw |> List.length") [] let ``Lambda body on the arrow line moves to its own lines`` () = - let code = - """ -module M - -let r = xs |> List.map (fun x -> x + 1) -""" + let code = "module M\n\nlet r = xs |> List.map (fun x -> x + 1)\n" let expected = - """ -module M - -let r = xs |> List.map (fun x -> - let extracted = x + 1 - extracted) -""" + "module M\n\nlet r = xs |> List.map (fun x ->\n let extracted = x + 1\n extracted)\n" Assert.Equal(expected, extracted extractToLetBinding code "x + 1") [] let ``Name does not collide with an existing identifier`` () = let code = - """ -module M - -let extracted = 0 - -let f x = - printfn "%d" (x + 1) -""" + "module M\n\nlet extracted = 0\n\nlet f x =\n printfn \"%d\" (x + 1)\n" let expected = - """ -module M - -let extracted = 0 - -let f x = - let extracted1 = x + 1 - printfn "%d" (extracted1) -""" + "module M\n\nlet extracted = 0\n\nlet f x =\n let extracted1 = x + 1\n printfn \"%d\" (extracted1)\n" Assert.Equal(expected, extracted extractToLetBinding code "x + 1") @@ -300,7 +145,6 @@ let ``Line breaks of the file are kept`` () = [] let ``Lines inside a multi-line string are not re-indented`` () = - // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. let code = "module M\n\nlet f name =\n printfn \"%s\" (String.Format(\"\"\"Hello\n{0}\"\"\", name))\n" @@ -310,140 +154,28 @@ let ``Lines inside a multi-line string are not re-indented`` () = Assert.Equal(expected, extracted extractToLetBinding code "String.Format(\"\"\"Hello\n{0}\"\"\", name)") [] -let ``Lines inside a multi-line string in an inactive branch are not re-indented`` () = - // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. - let code = - "module M\n\nlet r = combine 1 [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" - - let expected = - "module M\n\nlet r =\n let extracted = 1\n combine extracted [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" - - Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan(code.IndexOf("1 [", StringComparison.Ordinal), 1))) - -[] -[] -[] -[] -let ``Name does not collide with an assignment target from another file`` (declaration: string, assignment: string) = - let dependency = - """ -module Dependency - -type Box = { mutable Value: int } - -DECLARATION -""" - .Replace("DECLARATION", declaration) - - let code = - """ -module M - -open Dependency - -let f () = - ASSIGNMENT({ Value = 1 }).Value -""" - .Replace("ASSIGNMENT", assignment) +let ``Constant in a module-level declaration becomes a literal in front of it`` () = + let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" let expected = - """ -module M - -open Dependency - -let f () = - let extracted1 = { Value = 1 } - ASSIGNMENTextracted1.Value -""" - .Replace("ASSIGNMENT", assignment) - - use context = TestContext.CreateWithCodeAndDependency code dependency - - let document = - refactorSpan code (selectionOf code "({ Value = 1 })") extractToLetBinding context (new FSharpExtractLetBindingRefactoring()) + "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" - Assert.Equal(expected, (document.GetTextAsync() |> GetTaskResult).ToString()) - -let private greet = - """ -module M - -let greet name = - printfn "Hello %s" name -""" - -let private greetWithLiteral = - """ -module M - -[] -let ExtractedConstant = "Hello %s" - -let greet name = - printfn ExtractedConstant name -""" - -[] -let ``Constant in a module-level declaration becomes a literal in front of it`` () = - Assert.Equal(greetWithLiteral, extracted extractToLiteral greet "\"Hello %s\"") + Assert.Equal(expected, extracted extractToLiteral code "\"Hello %s\"") [] let ``Negative number becomes a literal`` () = - let code = - """ -module M - -let f () = g -1 -""" + let code = "module M\n\nlet f () = g -1\n" let expected = - """ -module M - -[] -let ExtractedConstant = -1 - -let f () = g ExtractedConstant -""" + "module M\n\n[]\nlet ExtractedConstant = -1\n\nlet f () = g ExtractedConstant\n" Assert.Equal(expected, extracted extractToLiteral code "-1") -let private moduleConstant = - """ -module M - -let f () = g 42 -""" - [] let ``Literal is offered only for constants outside types`` () = - let memberConstant = - """ -module M - -type T() = - member _.M() = 42 -""" - - let expression = - """ -module M - -let f x = g (x + 1) -""" - - Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor moduleConstant "42") - Assert.Equal([ extractToLetBinding ], titlesFor memberConstant "42") - Assert.Equal([ extractToLetBinding ], titlesFor expression "x + 1") - -let private mapIncrement = - """ -module M - -let f xs = - xs |> List.map (fun x -> x + 1) -""" + Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor "module M\n\nlet f () = g 42\n" "42") + Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\ntype T() =\n member _.M() = 42\n" "42") + Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\nlet f x = g (x + 1)\n" "x + 1") [] [] @@ -451,146 +183,56 @@ let f xs = [ x")>] [] let ``Caret in the header of a parenthesized lambda extracts the lambda`` (marker: string) = - let expected = - """ -module M + let code = "module M\n\nlet f xs =\n xs |> List.map (fun x -> x + 1)\n" -let f xs = - let extracted = fun x -> x + 1 - xs |> List.map extracted -""" + let expected = + "module M\n\nlet f xs =\n let extracted = fun x -> x + 1\n xs |> List.map extracted\n" - Assert.Equal([ extractToLetBinding ], titlesAt mapIncrement (caretAt mapIncrement marker)) - Assert.Equal(expected, extractedAt extractToLetBinding mapIncrement (caretAt mapIncrement marker)) + Assert.Equal([ extractToLetBinding ], titlesAt code (caretAt code marker)) + Assert.Equal(expected, extractedAt extractToLetBinding code (caretAt code marker)) [] -[] -[] -[] -let ``No action without a selection outside a parenthesized lambda header`` (marker: string) = - Assert.Empty(titlesAt mapIncrement (caretAt mapIncrement marker)) - -[] -let ``No action without a selection on a lambda without parentheses`` () = - let code = - """ -module M +[ List.map (fun x -> x + 1)\n", "x + 1)")>] +[ List.map (fun x -> x + 1)\n", "+ 1)")>] +[ List.map (fun x -> x + 1)\n", "(fun")>] +[ x + 1\n", "fun")>] +let ``No action without a selection outside a parenthesized lambda header`` (code: string, marker: string) = + Assert.Empty(titlesAt code (caretAt code marker)) -let f = fun x -> x + 1 -""" +[] +[] +[] +[] +let ``Constant at the caret becomes a literal without a selection`` (marker: string) = + let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" - Assert.Empty(titlesAt code (caretAt code "fun")) + let expected = + "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" -[] -[] -[] -[] -let ``Constant at the caret becomes a literal without a selection`` (marker: string, offset: int) = - let caret = TextSpan(greet.IndexOf(marker, StringComparison.Ordinal) + offset, 0) - Assert.Equal(greetWithLiteral, extractedAt extractToLiteral greet caret) + Assert.Equal(expected, extractedAt extractToLiteral code (caretAt code marker)) [] let ``Only the literal is offered without a selection`` () = - let caret = TextSpan(moduleConstant.IndexOf("42", StringComparison.Ordinal) + 1, 0) - - Assert.Equal([ extractToLiteral ], titlesAt moduleConstant caret) + let code = "module M\n\nlet f () = g 42\n" + Assert.Equal([ extractToLiteral ], titlesAt code (caretAt code "2\n")) [] -[] -[] -[] +[] +[] +[] let ``No action without a selection`` (code: string, marker: string) = Assert.Empty(titlesAt code (caretAt code marker)) [] -[] -[] -[] -[] -[ 0 -> v - | _ -> 0 -""", - "v > 0")>] -[] -[] -[] -[] -[ 0 -""", - "s.Length > 0")>] -[] +[] +[] +[] +[] +[ 0 -> v\n | _ -> 0\n", "v > 0")>] +[] +[] +[] +[] +[ 0\n", "s.Length > 0")>] +[] let ``No action`` (code: string, selected: string) = Assert.Empty(titlesFor code selected) From a73e1d323f3bc9758b63c8d81ee58a12932930fe Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 15:33:57 +0200 Subject: [PATCH 21/31] Write the extract function test code as multi-line strings Co-Authored-By: Claude Opus 5 (1M context) --- .../Refactors/ExtractFunctionTests.fs | 166 +++++++++++++++--- 1 file changed, 140 insertions(+), 26 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs index b53b0753241..5cc166c0a99 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs @@ -27,11 +27,14 @@ let private contextFor (setting: ParameterAnnotationSetting) (code: string) = new TestContext(RoslynTestHelpers.CreateSolution(code, editorOptions = options)) -let private extractedWith (setting: ParameterAnnotationSetting) (title: string) (code: string) (selected: string) = +let private caretAt (code: string) (marker: string) = + TextSpan(code.IndexOf(marker, StringComparison.Ordinal), 0) + +let private extractedAtWith (setting: ParameterAnnotationSetting) (title: string) (code: string) (span: TextSpan) = use context = contextFor setting code let document = - refactorSpan code (selectionOf code selected) title context (new FSharpExtractFunctionRefactoring()) + refactorSpan code span title context (new FSharpExtractFunctionRefactoring()) let parseResults = document.GetFSharpParseResultsAsync "test" @@ -40,55 +43,122 @@ let private extractedWith (setting: ParameterAnnotationSetting) (title: string) Assert.Empty(parseResults.Diagnostics) (document.GetTextAsync() |> GetTaskResult).ToString() +let private extractedWith (setting: ParameterAnnotationSetting) (title: string) (code: string) (selected: string) = + extractedAtWith setting title code (selectionOf code selected) + let private extracted = extractedWith ParameterAnnotationSetting.Always -let private titlesFor (code: string) (selected: string) = +let private titlesAt (code: string) (span: TextSpan) = use context = contextFor ParameterAnnotationSetting.Always code - tryGetRefactoringActionsForSpan code (selectionOf code selected) context (new FSharpExtractFunctionRefactoring()) + tryGetRefactoringActionsForSpan code span context (new FSharpExtractFunctionRefactoring()) |> Seq.map _.Title |> List.ofSeq +let private titlesFor (code: string) (selected: string) = + titlesAt code (selectionOf code selected) + +[] +let ``Caret in the header of a parenthesized lambda extracts it as if it were selected`` () = + let code = + """ +module M + +let f (xs: int list) (n: int) = + xs |> List.map (fun x -> x + n) +""" + + let lambda = "(fun x -> x + n)" + let titles = titlesFor code lambda + + Assert.NotEmpty(titles) + Assert.Equal(titles, titlesAt code (caretAt code "x ->")) + + for title in titles do + Assert.Equal(extracted title code lambda, extractedAtWith ParameterAnnotationSetting.Always title code (caretAt code "x ->")) + + Assert.Empty(titlesAt code (caretAt code "x + n")) + let private area = - "module M\n\nlet area (w: int) (h: int) =\n printfn \"%d\" (w * h + 1)\n" + """ +module M + +let area (w: int) (h: int) = + printfn "%d" (w * h + 1) +""" + +let private areaWithLocalFunction = + """ +module M + +let area (w: int) (h: int) = + let extractedFunction (w: int) (h: int) = w * h + 1 + printfn "%d" (extractedFunction w h) +""" [] let ``Captured parameters become parameters of a local function`` () = - let expected = - "module M\n\nlet area (w: int) (h: int) =\n let extractedFunction (w: int) (h: int) = w * h + 1\n printfn \"%d\" (extractedFunction w h)\n" - - Assert.Equal(expected, extracted extractToLocalFunction area "w * h + 1") + Assert.Equal(areaWithLocalFunction, extracted extractToLocalFunction area "w * h + 1") [] let ``Module function is declared in front of the declaration using it`` () = let expected = - "module M\n\nlet private extractedFunction (w: int) (h: int) = w * h + 1\n\nlet area (w: int) (h: int) =\n printfn \"%d\" (extractedFunction w h)\n" + """ +module M + +let private extractedFunction (w: int) (h: int) = w * h + 1 + +let area (w: int) (h: int) = + printfn "%d" (extractedFunction w h) +""" Assert.Equal(expected, extracted extractToModuleFunction area "w * h + 1") [] let ``Call keeps its parentheses where the selection lost them`` () = - let expected = - "module M\n\nlet area (w: int) (h: int) =\n let extractedFunction (w: int) (h: int) = w * h + 1\n printfn \"%d\" (extractedFunction w h)\n" - - Assert.Equal(expected, extracted extractToLocalFunction area "(w * h + 1)") + Assert.Equal(areaWithLocalFunction, extracted extractToLocalFunction area "(w * h + 1)") [] let ``Selection without captures becomes a function of unit`` () = - let code = "module M\n\nlet f () =\n printfn \"%d\" (1 + 2)\n" + let code = + """ +module M + +let f () = + printfn "%d" (1 + 2) +""" let expected = - "module M\n\nlet f () =\n let extractedFunction () = 1 + 2\n printfn \"%d\" (extractedFunction ())\n" + """ +module M + +let f () = + let extractedFunction () = 1 + 2 + printfn "%d" (extractedFunction ()) +""" Assert.Equal(expected, extracted extractToLocalFunction code "1 + 2") [] let ``Selection using this becomes a private member`` () = let code = - "module M\n\ntype Order(lines: int list) =\n member this.Rate = 3\n member this.Total = lines |> List.sumBy (fun l -> l * this.Rate)\n" + """ +module M + +type Order(lines: int list) = + member this.Rate = 3 + member this.Total = lines |> List.sumBy (fun l -> l * this.Rate) +""" let expected = - "module M\n\ntype Order(lines: int list) =\n member this.Rate = 3\n member this.Total = lines |> List.sumBy (fun l -> this.ExtractedMethod(l))\n member private this.ExtractedMethod(l: int) = l * this.Rate\n" + """ +module M + +type Order(lines: int list) = + member this.Rate = 3 + member this.Total = lines |> List.sumBy (fun l -> this.ExtractedMethod(l)) + member private this.ExtractedMethod(l: int) = l * this.Rate +""" Assert.Equal(expected, extracted extractToPrivateMember code "l * this.Rate") @@ -104,10 +174,21 @@ let ``Parameter annotations follow the option`` (setting: string, header: string | _ -> ParameterAnnotationSetting.Never let code = - "module M\n\nlet shout (s: string) (n: int) =\n printfn \"%s\" (s.ToUpper() + string n)\n" + """ +module M + +let shout (s: string) (n: int) = + printfn "%s" (s.ToUpper() + string n) +""" let expected = - $"module M\n\nlet shout (s: string) (n: int) =\n {header} s.ToUpper() + string n\n printfn \"%%s\" (extractedFunction s n)\n" + $""" +module M + +let shout (s: string) (n: int) = + {header} s.ToUpper() + string n + printfn "%%s" (extractedFunction s n) +""" Assert.Equal(expected, extractedWith setting extractToLocalFunction code "s.ToUpper() + string n") @@ -116,12 +197,45 @@ let ``Variants follow what the selection uses`` () = Assert.Equal([ extractToLocalFunction; extractToModuleFunction ], titlesFor area "w * h + 1") let derived = - "module M\n\ntype Derived() =\n inherit System.Object()\n override this.ToString() = base.ToString() + \"!\"\n" + """ +module M + +type Derived() = + inherit System.Object() + override this.ToString() = base.ToString() + "!" +""" Assert.Equal([ extractToPrivateMember ], titlesFor derived "base.ToString() + \"!\"") -[] -[] -[) =\n x <- x + 1\n", "x + 1")>] -let ``No action`` (code: string, selected: string) = Assert.Empty(titlesFor code selected) +[] +let ``No action when the selection assigns to a captured mutable local`` () = + let code = + """ +module M + +let counter () = + let mutable n = 0 + for i in 1 .. 3 do + n <- n + i + n +""" + + let start = code.IndexOf("for i in", StringComparison.Ordinal) + + let finish = + code.IndexOf("n <- n + i", start, StringComparison.Ordinal) + + "n <- n + i".Length + + Assert.Empty(titlesAt code (TextSpan.FromBounds(start, finish))) + +[] +let ``No action when the selection reads a byref parameter`` () = + let code = + """ +module M + +let incr (x: byref) = + x <- x + 1 +""" + + Assert.Empty(titlesFor code "x + 1") From 4c3b23dd1051c875b1f1dadf3f2f0a812dccea4b Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 21:36:11 +0200 Subject: [PATCH 22/31] Link the release note to the pull request and move it to a random line of its section Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 28bf444bad6..e1d99d66562 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -4,6 +4,7 @@ * Add maintainer-requested F# Apex Visual Studio integration tests on DartLab-1ES CloudTest, using PR pipeline definitions pinned to an explicitly requested commit SHA. ([PR #20625](https://github.com/dotnet/fsharp/pull/20625)) * **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration, also with just the caret inside the constant. ([PR #20537](https://github.com/dotnet/fsharp/pull/20537)) * Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880)) +* **Extract to local function**, **Extract to module function** and **Extract to private member** refactorings for a selected expression. Values the selection reads from the enclosing function, member or lambda become parameters in the order they are first used, typed as the new **Parameter types in Extract to function** option under **Code Fixes** asks. A selection that assigns to a captured mutable local, or reads a byref or byref-like value from outside, is not offered. ([Issue #14449](https://github.com/dotnet/fsharp/issues/14449), [PR #20538](https://github.com/dotnet/fsharp/pull/20538)) * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) From 2995490949c0174ee330126f72acdc9234a41ff4 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 29 Sep 2026 19:26:06 +0200 Subject: [PATCH 23/31] Pass the defines to the extract function literal lines and test the new names Extract to function uses the same name collector and reindentation as Extract to let binding; it now protects string lines of inactive #if branches too, and tests cover assignment targets named extractedFunction and ExtractedMethod. Co-Authored-By: Claude Opus 5.5 --- .../FSharp.Editor/Refactor/ExtractFunction.fs | 6 +- .../Refactors/ExtractFunctionTests.fs | 82 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs index e2c3ba3858b..b8eba727ae0 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs @@ -281,7 +281,11 @@ type internal FSharpExtractFunctionRefactoring [] () = let indentSize = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) - let literalLines = linesInsideLiterals parseResults.ParseTree + let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpExtractFunctionRefactoring) + + let literalLines = + linesInsideLiterals sourceText parseResults.ParseTree defines langVersion + let names = usedNames parseResults.ParseTree let needingType = FunctionExtraction.positionsNeedingType target.Expr let setting = document.Project.FSharpExtractFunctionParameterAnnotations diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs index 5cc166c0a99..0f8b5bbfd41 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs @@ -239,3 +239,85 @@ let incr (x: byref) = """ Assert.Empty(titlesFor code "x + 1") + +[] +let ``Lines inside a multi-line string in an inactive branch are not re-indented`` () = + // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. + let code = + "module M\n\nlet combine (n: int) (xs: string list) = n\n\nlet r = combine 1 [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" + + let expected = + "module M\n\nlet combine (n: int) (xs: string list) = n\n\nlet r =\n let extractedFunction () = 1\n combine (extractedFunction ()) [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" + + let selection = TextSpan(code.IndexOf("1 [", StringComparison.Ordinal), 1) + Assert.Equal(expected, extractedAtWith ParameterAnnotationSetting.Always extractToLocalFunction code selection) + +let private assignedDependency = + """ +module Dependency + +type Box = { mutable Value: int } + +let extractedFunction = { Value = 0 } +let ExtractedMethod = { Value = 0 } +""" + +let private extractedWithDependency (title: string) (code: string) (selected: string) = + use context = TestContext.CreateWithCodeAndDependency code assignedDependency + + let document = + refactorSpan code (selectionOf code selected) title context (new FSharpExtractFunctionRefactoring()) + + (document.GetTextAsync() |> GetTaskResult).ToString() + +[] +let ``Local function name does not collide with an assignment target from another file`` () = + let code = + """ +module M + +open Dependency + +let f () = + extractedFunction.Value <- ({ Value = 1 }).Value +""" + + let expected = + """ +module M + +open Dependency + +let f () = + let extractedFunction1 () = { Value = 1 } + extractedFunction.Value <- (extractedFunction1 ()).Value +""" + + Assert.Equal(expected, extractedWithDependency extractToLocalFunction code "({ Value = 1 })") + +[] +let ``Member name does not collide with an assignment target from another file`` () = + let code = + """ +module M + +open Dependency + +type T() = + member this.Reset() = + ExtractedMethod.Value <- ({ Value = 1 }).Value +""" + + let expected = + """ +module M + +open Dependency + +type T() = + member this.Reset() = + ExtractedMethod.Value <- this.ExtractedMethod1().Value + member private this.ExtractedMethod1() = { Value = 1 } +""" + + Assert.Equal(expected, extractedWithDependency extractToPrivateMember code "({ Value = 1 })") From a0a36e407d76e0eef62e4c34ee424ef08abec69c Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Fri, 2 Oct 2026 17:22:24 +0200 Subject: [PATCH 24/31] Carry over what the extract function branch had on top of the extract let binding branch Rebasing the stack onto main replayed the branch's own commits over the rebased extract let binding work; this brings the files back to what the branch had before the rebase. Co-Authored-By: Claude Opus 5.5 --- .../FSharp.Editor/Refactor/ExtractFunction.fs | 10 +- .../Refactor/ExtractLetBinding.fs | 5 +- .../Refactors/ExtractLetBindingTests.fs | 504 +++++++++++++++--- 3 files changed, 443 insertions(+), 76 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs index b8eba727ae0..c99e1d68cbb 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs @@ -253,12 +253,18 @@ type internal FSharpExtractFunctionRefactoring [] () = cancellableTask { let document = context.Document - if not (context.Span.IsEmpty || document.IsFSharpSignatureFile) then + if not document.IsFSharpSignatureFile then let! cancellationToken = CancellableTask.getCancellationToken () let! sourceText = document.GetTextAsync cancellationToken let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractFunctionRefactoring) - match tryExtractionTarget sourceText parseResults.ParseTree context.Span with + let target = + if context.Span.IsEmpty then + tryParenthesizedLambdaAtCaret sourceText parseResults.ParseTree context.Span.Start + else + tryExtractionTarget sourceText parseResults.ParseTree context.Span + + match target with | ValueNone -> () | ValueSome target -> match FunctionExtraction.enclosingScope target.Path with diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 4bc595bf49d..13dca45a187 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -69,8 +69,11 @@ type internal FSharpExtractLetBindingRefactoring [] () = let indentSize = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) + let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpExtractLetBindingRefactoring) let names = usedNames parseResults.ParseTree - let literalLines = linesInsideLiterals parseResults.ParseTree + + let literalLines = + linesInsideLiterals sourceText parseResults.ParseTree defines langVersion if isSelected then match anchorsOf target.Expr target.Path with diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index 7974f2ba4b8..536a77ce202 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -46,91 +46,246 @@ let private titlesAt (code: string) (span: TextSpan) = let private titlesFor (code: string) (selected: string) = titlesAt code (selectionOf code selected) +let private area = + """ +module M + +let area w h = + printfn "%d" (w * h + 1) +""" + [] -[] -[] -let ``Expression in a statement is bound in front of it`` (selected: string, body: string) = - let code = "module M\n\nlet area w h =\n printfn \"%d\" (w * h + 1)\n" - Assert.Equal($"module M\n\nlet area w h =\n{body}", extracted extractToLetBinding code selected) +[] +[] +let ``Expression in a statement is bound in front of it`` (selected: string, expected: string) = + Assert.Equal(expected, extracted extractToLetBinding area selected) [] let ``Parentheses of a method call stay`` () = let code = - "module M\n\nlet append (sb: System.Text.StringBuilder) =\n sb.Append(1 + 2)\n" + """ +module M + +let append (sb: System.Text.StringBuilder) = + sb.Append(1 + 2) +""" let expected = - "module M\n\nlet append (sb: System.Text.StringBuilder) =\n let extracted = 1 + 2\n sb.Append(extracted)\n" + """ +module M + +let append (sb: System.Text.StringBuilder) = + let extracted = 1 + 2 + sb.Append(extracted) +""" Assert.Equal(expected, extracted extractToLetBinding code "(1 + 2)") +let private run = + """ +module M + +let run items = + let mutable acc = 0 + let total = + items + |> List.filter (fun i -> i > acc) + |> List.sum + total +""" + [] let ``Multi-line right-hand side is bound in front of its let`` () = - let code = - "module M\n\nlet run items =\n let mutable acc = 0\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n total\n" - let expected = - "module M\n\nlet run items =\n let mutable acc = 0\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n total\n" + """ +module M + +let run items = + let mutable acc = 0 + let extracted = + items + |> List.filter (fun i -> i > acc) + |> List.sum + let total = + extracted + total +""" + + let start = run.LastIndexOf("items", StringComparison.Ordinal) + + let finish = + run.IndexOf("|> List.sum", start, StringComparison.Ordinal) + + "|> List.sum".Length - Assert.Equal(expected, extracted extractToLetBinding code "items\n |> List.filter (fun i -> i > acc)\n |> List.sum") + Assert.Equal(expected, extractedAt extractToLetBinding run (TextSpan.FromBounds(start, finish))) [] let ``Whole lines selected with their indentation and line break are extracted`` () = let code = - "module M\n\nlet run items =\n let mutable acc = 0\n\n let total =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n\n total\n" + """ +module M + +let run items = + let mutable acc = 0 + + let total = + items + |> List.filter (fun i -> i > acc) + |> List.sum + + total +""" let expected = - "module M\n\nlet run items =\n let mutable acc = 0\n\n let extracted =\n items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n let total =\n extracted\n\n total\n" + """ +module M + +let run items = + let mutable acc = 0 + + let extracted = + items + |> List.filter (fun i -> i > acc) + |> List.sum + let total = + extracted - Assert.Equal( - expected, - extracted extractToLetBinding code " items\n |> List.filter (fun i -> i > acc)\n |> List.sum\n" - ) + total +""" + + let lineStart = code.LastIndexOf(" items", StringComparison.Ordinal) + + let afterLineBreak = + code.IndexOf('\n', code.IndexOf("|> List.sum", lineStart, StringComparison.Ordinal)) + + 1 + + Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan.FromBounds(lineStart, afterLineBreak))) [] let ``Match clause body on the arrow line moves to its own lines`` () = let code = - "module M\n\nlet f x offset =\n match x with\n | Some v -> v * 2 + offset\n | None -> 0\n" + """ +module M + +let f x offset = + match x with + | Some v -> v * 2 + offset + | None -> 0 +""" let expected = - "module M\n\nlet f x offset =\n match x with\n | Some v ->\n let extracted = v * 2 + offset\n extracted\n | None -> 0\n" + """ +module M + +let f x offset = + match x with + | Some v -> + let extracted = v * 2 + offset + extracted + | None -> 0 +""" Assert.Equal(expected, extracted extractToLetBinding code "v * 2 + offset") [] let ``Right-hand side on the equals line keeps its trailing comment`` () = - let code = "module M\n\nlet r = compute a b // slow\n" + let code = + """ +module M + +let r = compute a b // slow +""" let expected = - "module M\n\nlet r =\n let extracted = compute a b\n extracted // slow\n" + """ +module M + +let r = + let extracted = compute a b + extracted // slow +""" Assert.Equal(expected, extracted extractToLetBinding code "compute a b") [] let ``Expression in a computation expression is bound in front of its statement`` () = let code = - "module M\n\nlet load id = async {\n let! raw = fetch id\n return parse raw |> List.length }\n" + """ +module M + +let load id = async { + let! raw = fetch id + return parse raw |> List.length } +""" let expected = - "module M\n\nlet load id = async {\n let! raw = fetch id\n let extracted = parse raw |> List.length\n return extracted }\n" + """ +module M + +let load id = async { + let! raw = fetch id + let extracted = parse raw |> List.length + return extracted } +""" Assert.Equal(expected, extracted extractToLetBinding code "parse raw |> List.length") [] let ``Lambda body on the arrow line moves to its own lines`` () = - let code = "module M\n\nlet r = xs |> List.map (fun x -> x + 1)\n" + let code = + """ +module M + +let r = xs |> List.map (fun x -> x + 1) +""" let expected = - "module M\n\nlet r = xs |> List.map (fun x ->\n let extracted = x + 1\n extracted)\n" + """ +module M + +let r = xs |> List.map (fun x -> + let extracted = x + 1 + extracted) +""" Assert.Equal(expected, extracted extractToLetBinding code "x + 1") [] let ``Name does not collide with an existing identifier`` () = let code = - "module M\n\nlet extracted = 0\n\nlet f x =\n printfn \"%d\" (x + 1)\n" + """ +module M + +let extracted = 0 + +let f x = + printfn "%d" (x + 1) +""" let expected = - "module M\n\nlet extracted = 0\n\nlet f x =\n let extracted1 = x + 1\n printfn \"%d\" (extracted1)\n" + """ +module M + +let extracted = 0 + +let f x = + let extracted1 = x + 1 + printfn "%d" (extracted1) +""" Assert.Equal(expected, extracted extractToLetBinding code "x + 1") @@ -145,6 +300,7 @@ let ``Line breaks of the file are kept`` () = [] let ``Lines inside a multi-line string are not re-indented`` () = + // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. let code = "module M\n\nlet f name =\n printfn \"%s\" (String.Format(\"\"\"Hello\n{0}\"\"\", name))\n" @@ -154,28 +310,140 @@ let ``Lines inside a multi-line string are not re-indented`` () = Assert.Equal(expected, extracted extractToLetBinding code "String.Format(\"\"\"Hello\n{0}\"\"\", name)") [] -let ``Constant in a module-level declaration becomes a literal in front of it`` () = - let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" +let ``Lines inside a multi-line string in an inactive branch are not re-indented`` () = + // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. + let code = + "module M\n\nlet r = combine 1 [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" let expected = - "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" + "module M\n\nlet r =\n let extracted = 1\n combine extracted [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" + + Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan(code.IndexOf("1 [", StringComparison.Ordinal), 1))) - Assert.Equal(expected, extracted extractToLiteral code "\"Hello %s\"") +[] +[] +[] +[] +let ``Name does not collide with an assignment target from another file`` (declaration: string, assignment: string) = + let dependency = + """ +module Dependency + +type Box = { mutable Value: int } + +DECLARATION +""" + .Replace("DECLARATION", declaration) + + let code = + """ +module M + +open Dependency + +let f () = + ASSIGNMENT({ Value = 1 }).Value +""" + .Replace("ASSIGNMENT", assignment) + + let expected = + """ +module M + +open Dependency + +let f () = + let extracted1 = { Value = 1 } + ASSIGNMENTextracted1.Value +""" + .Replace("ASSIGNMENT", assignment) + + use context = TestContext.CreateWithCodeAndDependency code dependency + + let document = + refactorSpan code (selectionOf code "({ Value = 1 })") extractToLetBinding context (new FSharpExtractLetBindingRefactoring()) + + Assert.Equal(expected, (document.GetTextAsync() |> GetTaskResult).ToString()) + +let private greet = + """ +module M + +let greet name = + printfn "Hello %s" name +""" + +let private greetWithLiteral = + """ +module M + +[] +let ExtractedConstant = "Hello %s" + +let greet name = + printfn ExtractedConstant name +""" + +[] +let ``Constant in a module-level declaration becomes a literal in front of it`` () = + Assert.Equal(greetWithLiteral, extracted extractToLiteral greet "\"Hello %s\"") [] let ``Negative number becomes a literal`` () = - let code = "module M\n\nlet f () = g -1\n" + let code = + """ +module M + +let f () = g -1 +""" let expected = - "module M\n\n[]\nlet ExtractedConstant = -1\n\nlet f () = g ExtractedConstant\n" + """ +module M + +[] +let ExtractedConstant = -1 + +let f () = g ExtractedConstant +""" Assert.Equal(expected, extracted extractToLiteral code "-1") +let private moduleConstant = + """ +module M + +let f () = g 42 +""" + [] let ``Literal is offered only for constants outside types`` () = - Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor "module M\n\nlet f () = g 42\n" "42") - Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\ntype T() =\n member _.M() = 42\n" "42") - Assert.Equal([ extractToLetBinding ], titlesFor "module M\n\nlet f x = g (x + 1)\n" "x + 1") + let memberConstant = + """ +module M + +type T() = + member _.M() = 42 +""" + + let expression = + """ +module M + +let f x = g (x + 1) +""" + + Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor moduleConstant "42") + Assert.Equal([ extractToLetBinding ], titlesFor memberConstant "42") + Assert.Equal([ extractToLetBinding ], titlesFor expression "x + 1") + +let private mapIncrement = + """ +module M + +let f xs = + xs |> List.map (fun x -> x + 1) +""" [] [] @@ -183,56 +451,146 @@ let ``Literal is offered only for constants outside types`` () = [ x")>] [] let ``Caret in the header of a parenthesized lambda extracts the lambda`` (marker: string) = - let code = "module M\n\nlet f xs =\n xs |> List.map (fun x -> x + 1)\n" - let expected = - "module M\n\nlet f xs =\n let extracted = fun x -> x + 1\n xs |> List.map extracted\n" + """ +module M - Assert.Equal([ extractToLetBinding ], titlesAt code (caretAt code marker)) - Assert.Equal(expected, extractedAt extractToLetBinding code (caretAt code marker)) +let f xs = + let extracted = fun x -> x + 1 + xs |> List.map extracted +""" -[] -[ List.map (fun x -> x + 1)\n", "x + 1)")>] -[ List.map (fun x -> x + 1)\n", "+ 1)")>] -[ List.map (fun x -> x + 1)\n", "(fun")>] -[ x + 1\n", "fun")>] -let ``No action without a selection outside a parenthesized lambda header`` (code: string, marker: string) = - Assert.Empty(titlesAt code (caretAt code marker)) + Assert.Equal([ extractToLetBinding ], titlesAt mapIncrement (caretAt mapIncrement marker)) + Assert.Equal(expected, extractedAt extractToLetBinding mapIncrement (caretAt mapIncrement marker)) [] -[] -[] -[] -let ``Constant at the caret becomes a literal without a selection`` (marker: string) = - let code = "module M\n\nlet greet name =\n printfn \"Hello %s\" name\n" +[] +[] +[] +let ``No action without a selection outside a parenthesized lambda header`` (marker: string) = + Assert.Empty(titlesAt mapIncrement (caretAt mapIncrement marker)) - let expected = - "module M\n\n[]\nlet ExtractedConstant = \"Hello %s\"\n\nlet greet name =\n printfn ExtractedConstant name\n" +[] +let ``No action without a selection on a lambda without parentheses`` () = + let code = + """ +module M + +let f = fun x -> x + 1 +""" - Assert.Equal(expected, extractedAt extractToLiteral code (caretAt code marker)) + Assert.Empty(titlesAt code (caretAt code "fun")) + +[] +[] +[] +[] +let ``Constant at the caret becomes a literal without a selection`` (marker: string, offset: int) = + let caret = TextSpan(greet.IndexOf(marker, StringComparison.Ordinal) + offset, 0) + Assert.Equal(greetWithLiteral, extractedAt extractToLiteral greet caret) [] let ``Only the literal is offered without a selection`` () = - let code = "module M\n\nlet f () = g 42\n" - Assert.Equal([ extractToLiteral ], titlesAt code (caretAt code "2\n")) + let caret = TextSpan(moduleConstant.IndexOf("42", StringComparison.Ordinal) + 1, 0) + + Assert.Equal([ extractToLiteral ], titlesAt moduleConstant caret) [] -[] -[] -[] +[] +[] +[] let ``No action without a selection`` (code: string, marker: string) = Assert.Empty(titlesAt code (caretAt code marker)) [] -[] -[] -[] -[] -[ 0 -> v\n | _ -> 0\n", "v > 0")>] -[] -[] -[] -[] -[ 0\n", "s.Length > 0")>] -[] +[] +[] +[] +[] +[ 0 -> v + | _ -> 0 +""", + "v > 0")>] +[] +[] +[] +[] +[ 0 +""", + "s.Length > 0")>] +[] let ``No action`` (code: string, selected: string) = Assert.Empty(titlesFor code selected) From 02efe21d8acea54edaac6009f3b34c628bd405e6 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 02:19:56 +0200 Subject: [PATCH 25/31] Close the gaps reviewers found in Extract to let binding and Extract 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 --- .../Refactor/ExtractLetBinding.fs | 29 +- .../Refactor/RefactoringHelpers.fs | 92 +++++-- .../Refactors/ExtractLetBindingTests.fs | 257 +++++++++++++++++- 3 files changed, 348 insertions(+), 30 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 13dca45a187..257a72b3199 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -70,31 +70,44 @@ type internal FSharpExtractLetBindingRefactoring [] () = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpExtractLetBindingRefactoring) + let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof FSharpExtractLetBindingRefactoring) let names = usedNames parseResults.ParseTree let literalLines = linesInsideLiterals sourceText parseResults.ParseTree defines langVersion + let content = + match target.Expr with + | SynExpr.Paren(expr = inner) -> inner + | expr -> expr + if isSelected then match anchorsOf target.Expr target.Path with | anchor :: _ -> let name = uniqueName "extracted" names - match tryDeclareInFront sourceText target anchor $"let {name}" name indentSize literalLines with + let functionType = + match content with + | SynExpr.Lambda _ + | SynExpr.MatchLambda _ -> tryTypeText checkResults target.Expr.Range + | _ -> ValueNone + + let header = + match functionType with + | ValueSome functionType -> $"let {name}: {functionType}" + | ValueNone -> $"let {name}" + + match tryDeclareInFront sourceText target anchor header name indentSize literalLines with | ValueSome changes -> register context sourceText (SR.ExtractToLetBinding()) "let" changes | ValueNone -> () | [] -> () - let constant = - match target.Expr with - | SynExpr.Paren(expr = inner) -> inner - | expr -> expr - - if isLiteralConstant constant then + if isLiteralConstant content then let name = uniqueName "ExtractedConstant" names + let attribute = literalAttributeAt checkResults target.Expr.Range.Start match - tryDeclareInFrontOfModuleLet sourceText target [ "[]" ] $"let {name}" name indentSize literalLines + tryDeclareInFrontOfModuleLet sourceText target [ $"[<{attribute}>]" ] $"let {name}" name indentSize literalLines with | ValueSome changes -> register context sourceText (SR.ExtractToLiteral()) "literal" changes | ValueNone -> () diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index e38179a548f..bcad586920c 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -8,6 +8,8 @@ open System.Text open Microsoft.CodeAnalysis.Text +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTrivia open FSharp.Compiler.Text @@ -18,8 +20,9 @@ open FSharp.Compiler.Tokenization type Anchor = /// An expression that is a statement of its block. | Statement of start: pos - /// The body of a binding, match clause, branch or lambda, after the keyword ending at keywordEnd. - | Clause of keywordEnd: pos * body: range + /// The body of a binding, match clause, branch or lambda, after the keyword ending at keywordEnd. No line of the + /// body can start left of column limit, where the construct that sets its offside line starts. + | Clause of keywordEnd: pos * body: range * limit: int [] type ExtractionTarget = @@ -178,8 +181,10 @@ let usedNames (parseTree: ParsedInput) = | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _))) | SyntaxNode.SynExpr(SynExpr.LongIdentSet(longDotId = SynLongIdent(id = ident :: _))) | SyntaxNode.SynExpr(SynExpr.NamedIndexedPropertySet(longDotId = SynLongIdent(id = ident :: _))) - | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) - | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = ident :: _))) -> names.Add ident.idText |> ignore + | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) -> names.Add ident.idText |> ignore + | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = idents))) -> + for ident in idents do + names.Add ident.idText |> ignore | _ -> () names) @@ -263,7 +268,7 @@ let private isMethodArgument (path: SyntaxVisitorPath) = | SyntaxNode.SynExpr(SynExpr.Tuple _) :: SyntaxNode.SynExpr(SynExpr.Paren _) :: call -> isCall call | _ -> false -let private isExtractableShape (expr: SynExpr) (path: SyntaxVisitorPath) = +let rec private isExtractableShape (expr: SynExpr) (path: SyntaxVisitorPath) = match expr with | SynExpr.App(isInfix = true) | SynExpr.Ident _ @@ -296,6 +301,7 @@ let private isExtractableShape (expr: SynExpr) (path: SyntaxVisitorPath) = | SynExpr.DotIndexedSet _ | SynExpr.NamedIndexedPropertySet _ | SynExpr.DotNamedIndexedPropertySet _ -> false + | SynExpr.Paren(expr = inner) -> isExtractableShape inner (SyntaxNode.SynExpr expr :: path) | SynExpr.LetOrUse letOrUse -> not letOrUse.IsBang | SynExpr.Tuple _ -> not (isMethodArgument path) | SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true; funcExpr = equals; argExpr = SynExpr.Ident _)) when @@ -308,11 +314,15 @@ let private isInExcludedContext (expr: SynExpr) (path: SyntaxVisitorPath) = let rec loop (child: SyntaxNode) (path: SyntaxVisitorPath) = match path, child with | [], _ -> false - | SyntaxNode.SynExpr(SynExpr.InterpolatedString _ | SynExpr.Quote _ | SynExpr.Lazy _) :: _, _ -> true + | SyntaxNode.SynExpr(SynExpr.InterpolatedString _ | SynExpr.Quote _ | SynExpr.Lazy _ | SynExpr.Assert _) :: _, _ -> true | SyntaxNode.SynExpr(SynExpr.While(whileExpr = condition) | SynExpr.WhileBang(whileExpr = condition)) :: _, SyntaxNode.SynExpr expr when isSame condition expr -> true + | SyntaxNode.SynExpr(SynExpr.App(funcExpr = SynExpr.Ident operator; argExpr = operand)) :: _, SyntaxNode.SynExpr expr when + hasName "nameof" operator && isSame operand expr + -> + true | SyntaxNode.SynMatchClause(SynMatchClause(whenExpr = Some guard)) :: _, SyntaxNode.SynExpr expr when isSame guard expr -> true | SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true; funcExpr = operator); argExpr = right)) :: _, SyntaxNode.SynExpr expr when @@ -401,7 +411,7 @@ let private isFunctionBinding (binding: SynBinding) = | SynBinding(headPat = SynPat.LongIdent(argPats = SynArgPats.Pats(_ :: _))) -> true | _ -> false -let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: SyntaxNode voption) = +let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: SyntaxNode voption) (limit: int) = match parent, child with | SyntaxNode.SynExpr(SynExpr.Sequential _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse), SyntaxNode.SynExpr expr when isSame letOrUse.Body expr -> @@ -409,28 +419,44 @@ let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: Synt | 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)), SyntaxNode.SynExpr expr when isSame body expr -> ValueSome(Anchor.Statement expr.Range.Start) + | SyntaxNode.SynExpr(SynExpr.TryFinally(finallyExpr = body)), SyntaxNode.SynExpr expr when isSame body expr -> + ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynModule(SynModuleDecl.Expr _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynBinding(SynBinding(expr = rhs; trivia = trivia) as binding), SyntaxNode.SynExpr expr when isSame rhs expr -> match grandparent, trivia.EqualsRange with | ValueSome(SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse)), _ when not letOrUse.IsRecursive && not (isFunctionBinding binding) -> ValueSome(Anchor.Statement letOrUse.Range.Start) - | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range)) + | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range, limit)) | _ -> ValueNone | SyntaxNode.SynMatchClause(SynMatchClause(resultExpr = result; trivia = trivia)), SyntaxNode.SynExpr expr when isSame result expr -> match trivia.ArrowRange with - | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range)) + | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range, limit)) | None -> ValueNone | SyntaxNode.SynExpr(SynExpr.IfThenElse(thenExpr = thenExpr; elseExpr = elseExpr; trivia = trivia)), SyntaxNode.SynExpr expr -> match elseExpr, trivia.ElseKeyword with - | _ when isSame thenExpr expr -> ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range)) - | Some elseExpr, Some elseKeyword when isSame elseExpr expr -> ValueSome(Anchor.Clause(elseKeyword.End, expr.Range)) + | _ when isSame thenExpr expr -> ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range, limit)) + | Some elseExpr, Some elseKeyword when isSame elseExpr expr -> ValueSome(Anchor.Clause(elseKeyword.End, expr.Range, limit)) | _ -> ValueNone | SyntaxNode.SynExpr(SynExpr.Lambda(parsedData = Some(_, body); trivia = trivia)), SyntaxNode.SynExpr expr when isSame body expr -> match trivia.ArrowRange with - | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range)) + | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range, limit)) | None -> ValueNone | _ -> ValueNone +/// The column where the innermost `let`, `match`, `try`, `if` or loop of the path starts. An `if` after `else` continues +/// the `if` it belongs to. +let rec private limitColumn (path: SyntaxVisitorPath) = + match path with + | [] -> 0 + | SyntaxNode.SynExpr(SynExpr.IfThenElse _ as nested) :: (SyntaxNode.SynExpr(SynExpr.IfThenElse(elseExpr = Some elseExpr)) :: _ as rest) when + isSame elseExpr nested + -> + limitColumn rest + | SyntaxNode.SynBinding(SynBinding(trivia = trivia)) :: _ -> trivia.LeadingKeyword.Range.StartColumn + | SyntaxNode.SynExpr(SynExpr.Match(range = m) | SynExpr.MatchBang(range = m) | SynExpr.TryWith(range = m) | SynExpr.TryFinally(range = m) | SynExpr.IfThenElse( + range = m) | SynExpr.For(range = m) | SynExpr.ForEach(range = m) | SynExpr.While(range = m)) :: _ -> m.StartColumn + | _ :: rest -> limitColumn rest + /// The places a declaration used by the expression can go, innermost first. let anchorsOf (expr: SynExpr) (path: SyntaxVisitorPath) = let rec loop (child: SyntaxNode) (path: SyntaxVisitorPath) = @@ -442,15 +468,40 @@ let anchorsOf (expr: SynExpr) (path: SyntaxVisitorPath) = | node :: _ -> ValueSome node | [] -> ValueNone - match anchorOf child parent grandparent with + match anchorOf child parent grandparent (limitColumn path) with | ValueSome anchor -> anchor :: loop parent rest | ValueNone -> loop parent rest loop (SyntaxNode.SynExpr expr) path +let rec private hasInferredTypeVariable (ty: FSharpType) = + if ty.IsGenericParameter then + ty.GenericParameter.IsCompilerGenerated + else + not ty.IsMeasureType + && ty.GenericArguments |> Seq.exists hasInferredTypeVariable + +/// The type the checker gave the expression at the range, as the source can write it. Unavailable when the type has a +/// variable that inference introduced, which has no name in the source. +let tryTypeText (checkResults: FSharpCheckFileResults) (m: range) = + match checkResults.TryGetCapturedType m, checkResults.TryGetCapturedDisplayContext m with + | Some ty, Some displayContext when not (hasInferredTypeVariable ty) -> ValueSome(ty.Format displayContext) + | _ -> ValueNone + +/// `Literal`, with its namespace where the name alone does not reach the FSharp.Core attribute from the position. +let literalAttributeAt (checkResults: FSharpCheckFileResults) (position: pos) = + let attribute = + checkResults.ProjectContext.GetReferencedAssemblies() + |> List.tryPick _.Contents.FindEntityByPath([ "Microsoft"; "FSharp"; "Core"; "LiteralAttribute" ]) + + match attribute with + | Some attribute when not (checkResults.IsRelativeNameResolvableFromSymbol(position, [], attribute)) -> + "global.Microsoft.FSharp.Core.Literal" + | _ -> "Literal" + let isLiteralConstant (expr: SynExpr) = match expr with - | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.IntPtr _ | SynConst.UIntPtr _ | SynConst.Single _ | SynConst.Double _ | SynConst.Char _ | SynConst.String _), + | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.Single _ | SynConst.Double _ | SynConst.Decimal _ | SynConst.Char _ | SynConst.String _), _) -> true | _ -> false @@ -552,12 +603,12 @@ let tryDeclareInFront match anchor with | Anchor.Statement start when isLineLeading sourceText start -> inFrontOfLine start - | Anchor.Clause(_, body) when isLineLeading sourceText body.Start -> inFrontOfLine body.Start - | Anchor.Clause(keywordEnd, body) when keywordEnd.Line = body.StartLine && restOfLineIsClosers sourceText body.End -> + | Anchor.Clause(body = body) when isLineLeading sourceText body.Start -> inFrontOfLine body.Start + | Anchor.Clause(keywordEnd, body, limit) when keywordEnd.Line = body.StartLine && restOfLineIsClosers sourceText body.End -> let keywordLine = lines[Line.toZ keywordEnd.Line] let keywordEndOffset = keywordLine.Start + keywordEnd.Column let bodySpan = textSpanOf sourceText body - let newIndent = leadingSpaces sourceText keywordLine + indentSize + let newIndent = max (leadingSpaces sourceText keywordLine) limit + indentSize let shift = newIndent - body.StartColumn let continuation = @@ -604,7 +655,8 @@ let tryDeclareInFront | _ -> ValueNone /// Changes that declare `header = `, preceded by the attribute lines, in front of the module-level let -/// containing the selection, and put replacement where the selection was. +/// containing the selection, and put replacement where the selection was. Unavailable when that let does not start its +/// line, as in `module M = let f () = 1`. let tryDeclareInFrontOfModuleLet (sourceText: SourceText) (target: ExtractionTarget) @@ -615,7 +667,7 @@ let tryDeclareInFrontOfModuleLet (literalLines: HashSet) = match tryEnclosingModuleLet target.Path with - | Some(struct (SynBinding(xmlDoc = xmlDoc), declaration)) -> + | Some(struct (SynBinding(xmlDoc = xmlDoc), declaration)) when isLineLeading sourceText declaration.Start -> let firstLine = if xmlDoc.IsEmpty then declaration.StartLine @@ -637,4 +689,4 @@ let tryDeclareInFrontOfModuleLet TextChange(TextSpan(line.Start, 0), $"{attributeLines}{padding indent}{declaration}{lineBreak}{lineBreak}") TextChange(target.Replaced, replacement) ]) - | None -> ValueNone + | _ -> ValueNone diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index 536a77ce202..fe5952ba413 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -9,6 +9,7 @@ open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks open Xunit +open FSharp.Editor.Tests.Helpers open FSharp.Editor.Tests.Refactors.RefactorTestFramework let private extractToLetBinding = "Extract to let binding" @@ -41,11 +42,20 @@ let private titlesAt (code: string) (span: TextSpan) = tryGetRefactoringActionsForSpan code span context (new FSharpExtractLetBindingRefactoring()) |> Seq.map _.Title - |> List.ofSeq + |> Seq.toList let private titlesFor (code: string) (selected: string) = titlesAt code (selectionOf code selected) +let private assertTypeChecks (code: string) = + use context = TestContext.CreateWithCode code + + let _, checkResults = + (RoslynTestHelpers.GetLastDocument context.Solution).GetFSharpParseAndCheckResultsAsync "test" + |> CancellableTask.runSynchronouslyWithoutCancellation + + Assert.Empty(checkResults.Diagnostics) + let private area = """ module M @@ -264,6 +274,76 @@ let r = xs |> List.map (fun x -> Assert.Equal(expected, extracted extractToLetBinding code "x + 1") +[] +[ 1 + 2 +""", + "1 + 2", + """ +module M + +let f x = match x with _ -> + let extracted = 1 + 2 + extracted +""")>] +[] +[] +let ``Body moved under its keyword is indented from the construct that starts inside the line`` + (code: string, selected: string, expected: string) + = + Assert.Equal(expected, extracted extractToLetBinding code selected) + +[] +let ``Expression in a finally block is bound inside the block`` () = + let code = + """ +module M + +let f (stream: System.IO.Stream) = + try + stream.WriteByte(1uy) + finally + stream.Dispose() +""" + + let expected = + """ +module M + +let f (stream: System.IO.Stream) = + try + stream.WriteByte(1uy) + finally + let extracted = stream.Dispose() + extracted +""" + + Assert.Equal(expected, extracted extractToLetBinding code "stream.Dispose()") + [] let ``Name does not collide with an existing identifier`` () = let code = @@ -437,6 +517,90 @@ let f x = g (x + 1) Assert.Equal([ extractToLetBinding ], titlesFor memberConstant "42") Assert.Equal([ extractToLetBinding ], titlesFor expression "x + 1") +[] +let ``Decimal constant becomes a literal`` () = + let code = + """ +module M + +let f () = id 1.5m +""" + + let expected = + """ +module M + +[] +let ExtractedConstant = 1.5m + +let f () = id ExtractedConstant +""" + + let result = extracted extractToLiteral code "1.5m" + Assert.Equal(expected, result) + assertTypeChecks result + +[] +[] +[] +let ``Native integer does not become a literal`` (constant: string) = + let code = moduleConstant.Replace("42", constant) + + Assert.Equal([ extractToLetBinding ], titlesFor code constant) + Assert.Empty(titlesAt code (caretAt code constant)) + +[] +let ``Literal attribute is qualified where another LiteralAttribute hides it`` () = + let code = + """ +module M + +type LiteralAttribute(required: int) = + inherit System.Attribute() + +let x = 42 +""" + + let expected = + """ +module M + +type LiteralAttribute(required: int) = + inherit System.Attribute() + +[] +let ExtractedConstant = 42 + +let x = ExtractedConstant +""" + + let result = extracted extractToLiteral code "42" + Assert.Equal(expected, result) + assertTypeChecks result + +[] +let ``Literal is not offered where the declaration does not start its line`` () = + let code = + """ +namespace N +module M = let f () = 42 +""" + + Assert.Equal([ extractToLetBinding ], titlesFor code "42") + Assert.Empty(titlesAt code (caretAt code "42")) + +[] +let ``Operand of nameof is not extracted where the nameof expression is`` () = + let code = + """ +module M + +let name = nameof System.String.Empty +""" + + Assert.Empty(titlesFor code "System.String.Empty") + Assert.Equal([ extractToLetBinding ], titlesFor code "nameof System.String.Empty") + let private mapIncrement = """ module M @@ -456,13 +620,72 @@ let ``Caret in the header of a parenthesized lambda extracts the lambda`` (marke module M let f xs = - let extracted = fun x -> x + 1 + let extracted: int -> int = fun x -> x + 1 xs |> List.map extracted """ Assert.Equal([ extractToLetBinding ], titlesAt mapIncrement (caretAt mapIncrement marker)) Assert.Equal(expected, extractedAt extractToLetBinding mapIncrement (caretAt mapIncrement marker)) +[] +[ int = (fun x -> x.Length) +""", + "(fun x -> x.Length)", + """ +module M + +let f: string -> int = + let extracted: string -> int = fun x -> x.Length + extracted +""")>] +[ List.map (fun x -> [ x ]) +""", + "(fun x -> [ x ])", + """ +module M + +let f (xs: 'T list) = + let extracted: 'T -> 'T list = fun x -> [ x ] + xs |> List.map extracted +""")>] +[ List.map (fun x -> [ x ]) +""", + "(fun x -> [ x ])", + """ +module M + +let f xs = + let extracted = fun x -> [ x ] + xs |> List.map extracted +""")>] +[ int = (function Some x -> x.Length | None -> 0) +""", + "(function Some x -> x.Length | None -> 0)", + """ +module M + +let f: string option -> int = + let extracted: string option -> int = function Some x -> x.Length | None -> 0 + extracted +""")>] +let ``Extracted function expression keeps the type its context gave it`` (code: string, selected: string, expected: string) = + let result = extracted extractToLetBinding code selected + + Assert.Equal(expected, result) + assertTypeChecks result + [] [] [] @@ -590,6 +813,36 @@ let f (a: bool) (s: string) = a && s.Length > 0 [] +[] +type S = + val mutable X: int + new(x) = { X = x } + member s.Inc() = s.X <- s.X + 1 + +let f () = + let mutable s = S(1) + (s).Inc() + s.X +""", + "(s)")>] +[] +[] From b17770a21286ed6cc3c2f46e73999710829244cc Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 09:32:26 +0200 Subject: [PATCH 26/31] Annotate an extracted lambda only with a function type, also when selected 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 --- .../Refactor/ExtractLetBinding.fs | 20 +++++--------- .../Refactor/RefactoringHelpers.fs | 22 +++++++++++----- .../Refactors/ExtractLetBindingTests.fs | 26 +++++++++++++++++++ 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 257a72b3199..34c8799df7f 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -76,24 +76,13 @@ type internal FSharpExtractLetBindingRefactoring [] () = let literalLines = linesInsideLiterals sourceText parseResults.ParseTree defines langVersion - let content = - match target.Expr with - | SynExpr.Paren(expr = inner) -> inner - | expr -> expr - if isSelected then match anchorsOf target.Expr target.Path with | anchor :: _ -> let name = uniqueName "extracted" names - let functionType = - match content with - | SynExpr.Lambda _ - | SynExpr.MatchLambda _ -> tryTypeText checkResults target.Expr.Range - | _ -> ValueNone - let header = - match functionType with + match tryFunctionTypeText checkResults target with | ValueSome functionType -> $"let {name}: {functionType}" | ValueNone -> $"let {name}" @@ -102,7 +91,12 @@ type internal FSharpExtractLetBindingRefactoring [] () = | ValueNone -> () | [] -> () - if isLiteralConstant content then + let constant = + match target.Expr with + | SynExpr.Paren(expr = inner) -> inner + | expr -> expr + + if isLiteralConstant constant then let name = uniqueName "ExtractedConstant" names let attribute = literalAttributeAt checkResults target.Expr.Range.Start diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index bcad586920c..cd435f081f3 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -481,12 +481,22 @@ let rec private hasInferredTypeVariable (ty: FSharpType) = not ty.IsMeasureType && ty.GenericArguments |> Seq.exists hasInferredTypeVariable -/// The type the checker gave the expression at the range, as the source can write it. Unavailable when the type has a -/// variable that inference introduced, which has no name in the source. -let tryTypeText (checkResults: FSharpCheckFileResults) (m: range) = - match checkResults.TryGetCapturedType m, checkResults.TryGetCapturedDisplayContext m with - | Some ty, Some displayContext when not (hasInferredTypeVariable ty) -> ValueSome(ty.Format displayContext) - | _ -> ValueNone +/// The type the checker gave the parenthesized lambda or `function` the target is, as the source can write it. The +/// checker records no type for one without parentheses. A type with a variable that inference introduced has no name +/// in the source. +let tryFunctionTypeText (checkResults: FSharpCheckFileResults) (target: ExtractionTarget) = + let parentheses = + match target.Expr, target.Path with + | SynExpr.Paren(expr = SynExpr.Lambda _ | SynExpr.MatchLambda _; range = m), _ + | (SynExpr.Lambda _ | SynExpr.MatchLambda _), SyntaxNode.SynExpr(SynExpr.Paren(range = m)) :: _ -> ValueSome m + | _ -> ValueNone + + parentheses + |> ValueOption.bind (fun m -> + match checkResults.TryGetCapturedType m, checkResults.TryGetCapturedDisplayContext m with + | Some ty, Some displayContext when ty.StripAbbreviations().IsFunctionType && not (hasInferredTypeVariable ty) -> + ValueSome(ty.Format displayContext) + | _ -> ValueNone) /// `Literal`, with its namespace where the name alone does not reach the FSharp.Core attribute from the position. let literalAttributeAt (checkResults: FSharpCheckFileResults) (position: pos) = diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index fe5952ba413..a53e01a46e2 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -680,6 +680,32 @@ let f: string option -> int = let extracted: string option -> int = function Some x -> x.Length | None -> 0 extracted """)>] +[ List.map (fun x -> x.Length) +""", + "fun x -> x.Length", + """ +module M + +let f (xs: string list) = + let extracted: string -> int = fun x -> x.Length + xs |> List.map (extracted) +""")>] +[) = xs.ConvertAll(fun x -> x.Length) +""", + "(fun x -> x.Length)", + """ +module M + +let f (xs: System.Collections.Generic.List) = + let extracted: string -> int = fun x -> x.Length + xs.ConvertAll(extracted) +""")>] let ``Extracted function expression keeps the type its context gave it`` (code: string, selected: string, expected: string) = let result = extracted extractToLetBinding code selected From b0fa138558e140e58bf0c346eb2fe6ab0310b758 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 11:01:51 +0200 Subject: [PATCH 27/31] Close the gaps reviewers found in the Extract to function refactorings - See a byref-like type through a type abbreviation. - Do not offer when the selection reads a generic local function, or a mutable local inside a lambda or other delayed code. - No local function for a selection using this of a struct. - No module function, and no private member for a type parameter of the member, when the selection names a type parameter from outside. - No module function or private member called from an inline function or member that is not private. - No private member where a local of the member hides the self identifier; keep the backticks of the self identifier and type name. - Parenthesize the call that is the receiver of an assignment. - Under WhenNeeded, annotate the arguments of methods and constructors. Co-Authored-By: Claude Opus 5.5 --- .../FSharp.Editor/Refactor/ExtractFunction.fs | 167 ++++++++++++-- .../Refactors/ExtractFunctionTests.fs | 211 +++++++++++++++++- 2 files changed, 352 insertions(+), 26 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs index c99e1d68cbb..25eabfc94fb 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs @@ -29,9 +29,26 @@ module private FunctionExtraction = { Name: string Type: string + IsMutable: bool Uses: range list } + /// What the selection takes from the code around it. + [] + type Captures = + { + /// The values read from the scope, in order of first use. + Parameters: Parameter list + UsesThis: bool + /// The `this` of a struct is a byref, which a closure cannot capture. + UsesStructThis: bool + UsesBase: bool + /// Where the type parameters the selection names are declared. + TypeParameters: range list + /// Where the names of the methods and constructors the selection calls end. + MethodNameEnds: HashSet + } + let private padding (width: int) = String(' ', width) /// The declaration whose locals the extracted code may capture: the enclosing member or module-level declaration. @@ -43,17 +60,28 @@ module private FunctionExtraction = | _ -> None) let private isByRefLike (fullType: FSharpType) = - fullType.HasTypeDefinition - && (fullType.TypeDefinition.IsByRef - || fullType.TypeDefinition.Attributes + let stripped = fullType.StripAbbreviations() + + stripped.HasTypeDefinition + && (stripped.TypeDefinition.IsByRef + || stripped.TypeDefinition.Attributes |> Seq.exists (fun attribute -> String.Equals(attribute.AttributeType.CompiledName, "IsByRefLikeAttribute", StringComparison.Ordinal))) - /// The values the selection reads from its scope, in order of first use, and whether it uses this or base; - /// ValueNone when a captured value has no type or a byref-like one. + let private isStruct (fullType: FSharpType) = + let stripped = fullType.StripAbbreviations() + + stripped.HasTypeDefinition + && (stripped.TypeDefinition.IsByRef || stripped.TypeDefinition.IsValueType) + + /// What the selection takes from its scope; ValueNone when a value it reads cannot become a parameter: one that has + /// no type or a byref-like one, or a generic function, which a parameter would fix to one instance. let tryCaptures (checkResults: FSharpCheckFileResults) (selection: range) (scope: range) (cancellationToken: CancellationToken) = let parameters = Dictionary(Range.comparer) + let typeParameters = ResizeArray() + let methodNameEnds = HashSet() let mutable usesThis = false + let mutable usesStructThis = false let mutable usesBase = false let mutable capturable = true @@ -63,7 +91,9 @@ module private FunctionExtraction = && Range.rangeContainsRange selection symbolUse.Range then match symbolUse.Symbol with - | :? FSharpMemberOrFunctionOrValue as value when value.IsMemberThisValue || value.IsConstructorThisValue -> usesThis <- true + | :? FSharpMemberOrFunctionOrValue as value when value.IsMemberThisValue || value.IsConstructorThisValue -> + usesThis <- true + usesStructThis <- usesStructThis || (value.FullTypeSafe |> Option.exists isStruct) | :? FSharpMemberOrFunctionOrValue as value when value.IsBaseValue -> usesBase <- true | :? FSharpMemberOrFunctionOrValue as value when not value.IsModuleValueOrMember -> match symbolUse.Symbol.DeclarationLocation with @@ -77,15 +107,21 @@ module private FunctionExtraction = { parameter with Uses = symbolUse.Range :: parameter.Uses } - | (false, _), Some fullType when not (isByRefLike fullType) -> + | (false, _), Some fullType when not (isByRefLike fullType) && value.GenericParameters.Count = 0 -> parameters[declaration] <- { Name = value.DisplayName Type = fullType.FormatWithConstraints symbolUse.DisplayContext + IsMutable = value.IsMutable Uses = [ symbolUse.Range ] } | (false, _), _ -> capturable <- false | _ -> () + | :? FSharpMemberOrFunctionOrValue as value when value.IsMember -> methodNameEnds.Add symbolUse.Range.End |> ignore + | :? FSharpGenericParameter -> + match symbolUse.Symbol.DeclarationLocation with + | Some declaration when not (Range.rangeContainsRange selection declaration) -> typeParameters.Add declaration + | _ -> () | _ -> () if capturable then @@ -94,10 +130,39 @@ module private FunctionExtraction = |> List.map (fun m -> struct (m.StartLine, m.StartColumn)) |> List.min - ValueSome(struct (parameters.Values |> Seq.sortBy firstUse |> List.ofSeq, usesThis, usesBase)) + ValueSome + { + Parameters = parameters.Values |> Seq.sortBy firstUse |> Seq.toList + UsesThis = usesThis + UsesStructThis = usesStructThis + UsesBase = usesBase + TypeParameters = Seq.toList typeParameters + MethodNameEnds = methodNameEnds + } else ValueNone + /// Whether the selection reads a captured mutable local inside a function or other delayed code. That code sees the + /// assignments made before it runs, where a parameter would keep the value passed to the call. + let readsMutableLater (expr: SynExpr) (parameters: Parameter list) = + let delayed = + (ResizeArray(), [ SyntaxNode.SynExpr expr ]) + ||> SyntaxNodes.fold (fun delayed _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Lambda(range = m) | SynExpr.DotLambda(range = m) | SynExpr.MatchLambda(range = m) | SynExpr.Lazy( + range = m) | SynExpr.ObjExpr(range = m) | SynExpr.ComputationExpr(range = m)) -> delayed.Add m + | SyntaxNode.SynBinding(SynBinding(headPat = SynPat.LongIdent(argPats = SynArgPats.Pats(_ :: _))) as binding) -> + delayed.Add binding.RangeOfBindingWithRhs + | _ -> () + + delayed) + + parameters + |> List.exists (fun parameter -> + parameter.IsMutable + && parameter.Uses + |> List.exists (fun m -> delayed |> Seq.exists (fun code -> Range.rangeContainsRange code m))) + /// Whether the selection assigns to one of the captured values, which a parameter could not carry back. let assignsParameter (expr: SynExpr) (parameters: Parameter list) = let assigned = @@ -113,12 +178,22 @@ module private FunctionExtraction = parameters |> List.exists (fun parameter -> parameter.Uses |> List.exists (fun m -> assigned.Contains m.Start)) + let rec private identifierArguments (argument: SynExpr) = + match argument with + | SynExpr.Ident ident -> [ ident ] + | SynExpr.Paren(expr = inner) -> identifierArguments inner + | SynExpr.Tuple(exprs = arguments) -> arguments |> List.collect identifierArguments + | _ -> [] + /// Positions of identifiers whose type inference cannot tell from their use alone: receivers of a member or indexer - /// access and operands of an operator. - let positionsNeedingType (expr: SynExpr) = + /// access, operands of an operator and arguments of a method or constructor, which can have overloads. + let positionsNeedingType (expr: SynExpr) (methodNameEnds: HashSet) = (HashSet(), [ SyntaxNode.SynExpr expr ]) ||> SyntaxNodes.fold (fun positions _ node -> match node with + | SyntaxNode.SynExpr(SynExpr.App(funcExpr = method; argExpr = argument)) when methodNameEnds.Contains method.Range.End -> + for ident in identifierArguments argument do + positions.Add ident.idRange.Start |> ignore | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = head :: _ :: _))) | SyntaxNode.SynExpr(SynExpr.DotGet(expr = SynExpr.Ident head)) | SyntaxNode.SynExpr(SynExpr.DotIndexedGet(objectExpr = SynExpr.Ident head)) @@ -162,11 +237,15 @@ module private FunctionExtraction = let arguments = parameters |> List.map _.Name |> String.concat ", " $"({arguments})" - /// Whether a function application put where the selection was needs parentheses to stay one argument. + /// Whether a function application put where the selection was needs parentheses to stay one argument or the + /// receiver of an assignment. let needsParentheses (target: ExtractionTarget) = match target.Expr, target.Path with | SynExpr.Paren _, _ when target.Replaced = target.Content -> false | _, SyntaxNode.SynExpr(SynExpr.App _ | SynExpr.DotGet _ | SynExpr.DotIndexedGet _ | SynExpr.TypeApp _) :: _ -> true + | expr, + SyntaxNode.SynExpr(SynExpr.DotSet(targetExpr = receiver) | SynExpr.DotIndexedSet(objectExpr = receiver) | SynExpr.DotNamedIndexedPropertySet( + targetExpr = receiver)) :: _ -> obj.ReferenceEquals(expr, receiver) | _ -> false let isInRecursiveModuleLet (path: SyntaxVisitorPath) = @@ -175,6 +254,32 @@ module private FunctionExtraction = | SyntaxNode.SynModule(SynModuleDecl.Let(isRecursive = true)) -> true | _ -> false) + let private isPrivate (binding: SynBinding) = + match binding with + | SynBinding(accessibility = Some(SynAccess.Private _)) + | SynBinding(headPat = SynPat.LongIdent(accessibility = Some(SynAccess.Private _))) + | SynBinding(headPat = SynPat.Named(accessibility = Some(SynAccess.Private _))) -> true + | _ -> false + + /// Whether the selection is in an inline module-level function or member that is not private. Its body cannot + /// call anything less accessible than it is. + let rec isInExposedInline (path: SyntaxVisitorPath) = + match path with + | [] -> false + | SyntaxNode.SynBinding(SynBinding(isInline = isInline) as binding) :: (SyntaxNode.SynModule _ | SyntaxNode.SynMemberDefn _) :: _ -> + isInline && not (isPrivate binding) + | _ :: rest -> isInExposedInline rest + + /// Whether a pattern in the member binds the name, which hides a self identifier of that name. + let private bindsName (name: string) (binding: SynBinding) = + (false, [ SyntaxNode.SynBinding binding ]) + ||> SyntaxNodes.fold (fun found _ node -> + match node with + | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) + | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = [ ident ]))) -> + found || String.Equals(ident.idText, name, StringComparison.Ordinal) + | _ -> found) + /// The member of a class, record or union that contains the selection, outside interface implementations and /// object expressions. let tryEnclosingMember (path: SyntaxVisitorPath) = @@ -189,15 +294,17 @@ module private FunctionExtraction = loop path /// The start of the new member's declaration and the receiver it is called on: the self identifier of an instance - /// member, or the name of a non-generic type for a static member. + /// member, unless a local of the member hides it, or the name of a non-generic type for a static member. let tryMemberCallee (binding: SynBinding) (info: SynComponentInfo) = match binding, info with | SynBinding(headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = [ self; _ ]))), _ when not (self.idText.StartsWith("_", StringComparison.Ordinal)) + && not (bindsName self.idText binding) -> - ValueSome(struct ($"member private {self.idText}.", self.idText)) + let self = PrettyNaming.NormalizeIdentifierBackticks self.idText + ValueSome(struct ($"member private {self}.", self)) | SynBinding(headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = [ _ ]))), SynComponentInfo(typeParams = None) -> - ValueSome(struct ("static member private ", (List.last info.LongIdent).idText)) + ValueSome(struct ("static member private ", PrettyNaming.NormalizeIdentifierBackticks (List.last info.LongIdent).idText)) | _ -> ValueNone /// Changes that declare `header = ` right after the member and call it where the selection was. @@ -279,9 +386,11 @@ type internal FSharpExtractFunctionRefactoring [] () = (positionOf sourceText target.Content.End) match FunctionExtraction.tryCaptures checkResults selection scope cancellationToken with - | ValueSome(struct (parameters, usesThis, usesBase)) when - not (FunctionExtraction.assignsParameter target.Expr parameters) + | ValueSome captures when + not (FunctionExtraction.assignsParameter target.Expr captures.Parameters) + && not (FunctionExtraction.readsMutableLater target.Expr captures.Parameters) -> + let parameters = captures.Parameters let! options = document.GetOptionsAsync cancellationToken let indentSize = @@ -293,8 +402,12 @@ type internal FSharpExtractFunctionRefactoring [] () = linesInsideLiterals sourceText parseResults.ParseTree defines langVersion let names = usedNames parseResults.ParseTree - let needingType = FunctionExtraction.positionsNeedingType target.Expr + + let needingType = + FunctionExtraction.positionsNeedingType target.Expr captures.MethodNameEnds + let setting = document.Project.FSharpExtractFunctionParameterAnnotations + let inExposedInline = FunctionExtraction.isInExposedInline target.Path let annotate (parameter: FunctionExtraction.Parameter) = match setting with @@ -316,7 +429,7 @@ type internal FSharpExtractFunctionRefactoring [] () = else application - if not usesBase then + if not (captures.UsesBase || captures.UsesStructThis) then match anchorsOf target.Expr target.Path with | anchor :: _ -> match tryDeclareInFront sourceText target anchor $"let {header}" call indentSize literalLines with @@ -324,7 +437,15 @@ type internal FSharpExtractFunctionRefactoring [] () = | ValueNone -> () | [] -> () - if not (usesThis || usesBase || FunctionExtraction.isInRecursiveModuleLet target.Path) then + if + not ( + captures.UsesThis + || captures.UsesBase + || inExposedInline + || not captures.TypeParameters.IsEmpty + || FunctionExtraction.isInRecursiveModuleLet target.Path + ) + then match tryDeclareInFrontOfModuleLet sourceText target [] $"let private {header}" call indentSize literalLines with @@ -332,7 +453,11 @@ type internal FSharpExtractFunctionRefactoring [] () = | ValueNone -> () match FunctionExtraction.tryEnclosingMember target.Path with - | ValueSome(struct (binding, memberRange, info)) -> + | ValueSome(struct (binding, memberRange, info)) when + not inExposedInline + && captures.TypeParameters + |> List.forall (fun declaration -> not (Range.rangeContainsRange memberRange declaration)) + -> match FunctionExtraction.tryMemberCallee binding info with | ValueSome(struct (prefix, receiver)) -> let memberName = uniqueName "ExtractedMethod" names @@ -356,7 +481,7 @@ type internal FSharpExtractFunctionRefactoring [] () = | ValueSome changes -> register context sourceText (SR.ExtractToPrivateMember()) "member" changes | ValueNone -> () | ValueNone -> () - | ValueNone -> () + | _ -> () | _ -> () } |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs index 0f8b5bbfd41..c921afdf5b2 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs @@ -53,7 +53,7 @@ let private titlesAt (code: string) (span: TextSpan) = tryGetRefactoringActionsForSpan code span context (new FSharpExtractFunctionRefactoring()) |> Seq.map _.Title - |> List.ofSeq + |> Seq.toList let private titlesFor (code: string) (selected: string) = titlesAt code (selectionOf code selected) @@ -228,17 +228,218 @@ let counter () = Assert.Empty(titlesAt code (TextSpan.FromBounds(start, finish))) +[] +[) = + x <- x + 1 +""", + "x + 1")>] +[] +type Window = + val Length: int + +type Buffer = Window + +let size (buffer: Buffer) = + buffer.Length + 1 +""", + "buffer.Length + 1")>] +[] +[ n) + n <- 1 + read () +""", + "(fun () -> n)")>] +[ Seq.map _.Substring(n) + n <- 1 + tails +""", + "xs |> Seq.map _.Substring(n)")>] +let ``No action when a value the selection reads cannot become a parameter`` (code: string, selected: string) = + Assert.Empty(titlesFor code selected) + [] -let ``No action when the selection reads a byref parameter`` () = +let ``Mutable local read at once becomes a parameter`` () = let code = """ module M -let incr (x: byref) = - x <- x + 1 +let f () = + let mutable n = 0 + n <- 1 + printfn "%d" (n + 1) +""" + + Assert.Equal([ extractToLocalFunction; extractToModuleFunction ], titlesFor code "n + 1") + +[] +let ``Type parameters stay in the scope that declares them`` () = + let moduleFunction = + """ +module M + +let f<'T> () = typeof<'T> +""" + + let methodOfGenericType = + """ +module M + +type C<'T>() = + member this.M<'U>() = (typeof<'T>, typeof<'U>) +""" + + Assert.Equal([ extractToLocalFunction ], titlesFor moduleFunction "typeof<'T>") + Assert.Equal([ extractToLocalFunction; extractToPrivateMember ], titlesFor methodOfGenericType "typeof<'T>") + Assert.Equal([ extractToLocalFunction ], titlesFor methodOfGenericType "typeof<'U>") + +[] +let ``No local function for a selection using this of a struct`` () = + let code = + """ +module M + +[] +type S = + val X: int + new(x) = { X = x } + member this.M() = this.X + 1 +""" + + Assert.Equal([ extractToPrivateMember ], titlesFor code "this.X + 1") + +[] +let ``No private member where a local hides the self identifier`` () = + let code = + """ +module M + +type C() = + member this.M() = + let this = "abc" + this.Length + 1 +""" + + Assert.Equal([ extractToLocalFunction ], titlesFor code "this.Length + 1") + +[] +[] +[] +[] +[] +let ``Module function is offered unless a more accessible inline function would call it`` (declaration: string, offered: bool) = + let titles = titlesFor $"module M\n\n{declaration}\n" "x + 1" + + Assert.Equal(offered, List.contains extractToModuleFunction titles) + Assert.Contains(extractToLocalFunction, titles) + +[] +[] +[] +[] +let ``Private member is named and called as its type allows`` (code: string, expected: string) = + Assert.Equal(expected, extracted extractToPrivateMember code "1 + 2") + +[] +let ``Call that is the receiver of an assignment is parenthesized`` () = + let code = + """ +module M + +let f () = + System.Text.StringBuilder("abc").Length <- 0 +""" + + let expected = + """ +module M + +let f () = + let extractedFunction () = System.Text.StringBuilder("abc") + (extractedFunction ()).Length <- 0 +""" + + Assert.Equal(expected, extracted extractToLocalFunction code "System.Text.StringBuilder(\"abc\")") + +[] +let ``Argument of an overloaded method is annotated when needed`` () = + let code = + """ +module M + +let f (x: int64) = System.Math.Abs x +""" + + let expected = + """ +module M + +let f (x: int64) = + let extractedFunction (x: int64) = System.Math.Abs x + extractedFunction x """ - Assert.Empty(titlesFor code "x + 1") + Assert.Equal(expected, extractedWith ParameterAnnotationSetting.WhenNeeded extractToLocalFunction code "System.Math.Abs x") [] let ``Lines inside a multi-line string in an inactive branch are not re-indented`` () = From 6cb331e8a9410e4f802ed3a7e2e3318661b54d1e Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 11:11:04 +0200 Subject: [PATCH 28/31] Cover the inline member that would call an extracted private member Co-Authored-By: Claude Opus 5.5 --- .../Refactors/ExtractFunctionTests.fs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs index c921afdf5b2..bd2dbe51cf6 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs @@ -345,14 +345,19 @@ type C() = Assert.Equal([ extractToLocalFunction ], titlesFor code "this.Length + 1") [] -[] -[] -[] -[] -let ``Module function is offered unless a more accessible inline function would call it`` (declaration: string, offered: bool) = +[] +[] +[] +[] +[] +[] +[] +let ``Private function or member is offered unless a more accessible inline one would call it`` + (declaration: string, title: string, offered: bool) + = let titles = titlesFor $"module M\n\n{declaration}\n" "x + 1" - Assert.Equal(offered, List.contains extractToModuleFunction titles) + Assert.Equal(offered, List.contains title titles) Assert.Contains(extractToLocalFunction, titles) [] From c6fc35182956ae8eb5eafb58e0404c11cb661731 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 15 Oct 2026 03:53:32 +0100 Subject: [PATCH 29/31] Compact the Extract to function code and name what its comments said Replace the explanatory comments with names, give the symbol classification of the captures active patterns instead of five nested matches, split the three variants and the provider into functions that nest at most two levels, and drop the duplicated registration, padding and state gathering in favour of the shared Extraction record. Co-Authored-By: Claude Sonnet 5.5 --- .../FSharp.Editor/Refactor/ExtractFunction.fs | 498 ++++++++---------- 1 file changed, 224 insertions(+), 274 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs index 25eabfc94fb..2d158442235 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractFunction.fs @@ -7,11 +7,8 @@ open System.Collections.Generic open System.Composition open System.Threading -open Microsoft.CodeAnalysis.CodeActions open Microsoft.CodeAnalysis.CodeRefactorings -open Microsoft.CodeAnalysis.Formatting open Microsoft.CodeAnalysis.Text -open Microsoft.VisualStudio.FSharp.Editor.Telemetry open FSharp.Compiler.CodeAnalysis open FSharp.Compiler.Symbols @@ -33,118 +30,141 @@ module private FunctionExtraction = Uses: range list } - /// What the selection takes from the code around it. [] type Captures = { - /// The values read from the scope, in order of first use. Parameters: Parameter list UsesThis: bool - /// The `this` of a struct is a byref, which a closure cannot capture. UsesStructThis: bool UsesBase: bool - /// Where the type parameters the selection names are declared. - TypeParameters: range list - /// Where the names of the methods and constructors the selection calls end. + TypeParameterDeclarations: range list MethodNameEnds: HashSet } - let private padding (width: int) = String(' ', width) - - /// The declaration whose locals the extracted code may capture: the enclosing member or module-level declaration. - let enclosingScope (path: SyntaxVisitorPath) = + let private tryEnclosingDeclaration (path: SyntaxVisitorPath) = path - |> List.tryPick (function + |> Seq.tryPickV (function | SyntaxNode.SynMemberDefn(SynMemberDefn.Member(range = m) | SynMemberDefn.LetBindings(range = m)) - | SyntaxNode.SynModule(SynModuleDecl.Let(range = m) | SynModuleDecl.Expr(range = m)) -> Some m - | _ -> None) + | SyntaxNode.SynModule(SynModuleDecl.Let(range = m) | SynModuleDecl.Expr(range = m)) -> ValueSome m + | _ -> ValueNone) - let private isByRefLike (fullType: FSharpType) = + let tryTargetInDeclaration (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = + trySelectedTarget sourceText parseTree selection + |> ValueOption.bind (fun target -> + tryEnclosingDeclaration target.Path + |> ValueOption.map (fun declaration -> struct (target, declaration))) + + let private tryEntityOf (fullType: FSharpType) = let stripped = fullType.StripAbbreviations() - stripped.HasTypeDefinition - && (stripped.TypeDefinition.IsByRef - || stripped.TypeDefinition.Attributes + if stripped.HasTypeDefinition then + ValueSome stripped.TypeDefinition + else + ValueNone + + let private isByRefLike (fullType: FSharpType) = + tryEntityOf fullType + |> ValueOption.exists (fun entity -> + entity.IsByRef + || entity.Attributes |> Seq.exists (fun attribute -> String.Equals(attribute.AttributeType.CompiledName, "IsByRefLikeAttribute", StringComparison.Ordinal))) let private isStruct (fullType: FSharpType) = - let stripped = fullType.StripAbbreviations() + tryEntityOf fullType + |> ValueOption.exists (fun entity -> entity.IsByRef || entity.IsValueType) + + let private (|CapturedLocal|_|) (selection: range) (scope: range) (symbol: FSharpSymbol) = + match symbol with + | :? FSharpMemberOrFunctionOrValue as value when not value.IsModuleValueOrMember -> + match symbol.DeclarationLocation with + | Some declaration when + Range.rangeContainsRange scope declaration + && not (Range.rangeContainsRange selection declaration) + -> + Some(value, declaration) + | _ -> None + | _ -> None + + let private (|TypeParameterDeclaredOutside|_|) (selection: range) (symbol: FSharpSymbol) = + match symbol with + | :? FSharpGenericParameter -> + symbol.DeclarationLocation + |> Option.filter (fun declaration -> not (Range.rangeContainsRange selection declaration)) + | _ -> None + + let private tryAddParameter + (parameters: Dictionary) + (value: FSharpMemberOrFunctionOrValue) + (symbolUse: FSharpSymbolUse) + (declaration: range) + = + match parameters.TryGetValue declaration, value.FullTypeSafe with + | (true, parameter), _ -> + parameters[declaration] <- + { parameter with + Uses = symbolUse.Range :: parameter.Uses + } + + true + | (false, _), Some fullType when not (isByRefLike fullType) && value.GenericParameters.Count = 0 -> + parameters[declaration] <- + { + Name = value.DisplayName + Type = fullType.FormatWithConstraints symbolUse.DisplayContext + IsMutable = value.IsMutable + Uses = [ symbolUse.Range ] + } + + true + | _ -> false - stripped.HasTypeDefinition - && (stripped.TypeDefinition.IsByRef || stripped.TypeDefinition.IsValueType) + let private firstUse (parameter: Parameter) = + parameter.Uses + |> List.map (fun m -> struct (m.StartLine, m.StartColumn)) + |> List.min - /// What the selection takes from its scope; ValueNone when a value it reads cannot become a parameter: one that has - /// no type or a byref-like one, or a generic function, which a parameter would fix to one instance. let tryCaptures (checkResults: FSharpCheckFileResults) (selection: range) (scope: range) (cancellationToken: CancellationToken) = let parameters = Dictionary(Range.comparer) - let typeParameters = ResizeArray() + let typeParameterDeclarations = ResizeArray() let methodNameEnds = HashSet() let mutable usesThis = false let mutable usesStructThis = false let mutable usesBase = false let mutable capturable = true - for symbolUse in checkResults.GetAllUsesOfAllSymbolsInFile cancellationToken do - if + let usesInSelection = + checkResults.GetAllUsesOfAllSymbolsInFile cancellationToken + |> Seq.filter (fun symbolUse -> not symbolUse.IsFromDefinition - && Range.rangeContainsRange selection symbolUse.Range - then - match symbolUse.Symbol with - | :? FSharpMemberOrFunctionOrValue as value when value.IsMemberThisValue || value.IsConstructorThisValue -> - usesThis <- true - usesStructThis <- usesStructThis || (value.FullTypeSafe |> Option.exists isStruct) - | :? FSharpMemberOrFunctionOrValue as value when value.IsBaseValue -> usesBase <- true - | :? FSharpMemberOrFunctionOrValue as value when not value.IsModuleValueOrMember -> - match symbolUse.Symbol.DeclarationLocation with - | Some declaration when - Range.rangeContainsRange scope declaration - && not (Range.rangeContainsRange selection declaration) - -> - match parameters.TryGetValue declaration, value.FullTypeSafe with - | (true, parameter), _ -> - parameters[declaration] <- - { parameter with - Uses = symbolUse.Range :: parameter.Uses - } - | (false, _), Some fullType when not (isByRefLike fullType) && value.GenericParameters.Count = 0 -> - parameters[declaration] <- - { - Name = value.DisplayName - Type = fullType.FormatWithConstraints symbolUse.DisplayContext - IsMutable = value.IsMutable - Uses = [ symbolUse.Range ] - } - | (false, _), _ -> capturable <- false - | _ -> () - | :? FSharpMemberOrFunctionOrValue as value when value.IsMember -> methodNameEnds.Add symbolUse.Range.End |> ignore - | :? FSharpGenericParameter -> - match symbolUse.Symbol.DeclarationLocation with - | Some declaration when not (Range.rangeContainsRange selection declaration) -> typeParameters.Add declaration - | _ -> () - | _ -> () + && Range.rangeContainsRange selection symbolUse.Range) + + for symbolUse in usesInSelection do + match symbolUse.Symbol with + | :? FSharpMemberOrFunctionOrValue as value when value.IsMemberThisValue || value.IsConstructorThisValue -> + usesThis <- true + usesStructThis <- usesStructThis || (value.FullTypeSafe |> Option.exists isStruct) + | :? FSharpMemberOrFunctionOrValue as value when value.IsBaseValue -> usesBase <- true + | CapturedLocal selection scope (value, declaration) -> + capturable <- capturable && tryAddParameter parameters value symbolUse declaration + | :? FSharpMemberOrFunctionOrValue as value when value.IsMember -> methodNameEnds.Add symbolUse.Range.End |> ignore + | TypeParameterDeclaredOutside selection declaration -> typeParameterDeclarations.Add declaration + | _ -> () if capturable then - let firstUse (parameter: Parameter) = - parameter.Uses - |> List.map (fun m -> struct (m.StartLine, m.StartColumn)) - |> List.min - ValueSome { Parameters = parameters.Values |> Seq.sortBy firstUse |> Seq.toList UsesThis = usesThis UsesStructThis = usesStructThis UsesBase = usesBase - TypeParameters = Seq.toList typeParameters + TypeParameterDeclarations = Seq.toList typeParameterDeclarations MethodNameEnds = methodNameEnds } else ValueNone - /// Whether the selection reads a captured mutable local inside a function or other delayed code. That code sees the - /// assignments made before it runs, where a parameter would keep the value passed to the call. - let readsMutableLater (expr: SynExpr) (parameters: Parameter list) = + let readsMutableInDelayedCode (expr: SynExpr) (parameters: Parameter list) = let delayed = (ResizeArray(), [ SyntaxNode.SynExpr expr ]) ||> SyntaxNodes.fold (fun delayed _ node -> @@ -163,8 +183,7 @@ module private FunctionExtraction = && parameter.Uses |> List.exists (fun m -> delayed |> Seq.exists (fun code -> Range.rangeContainsRange code m))) - /// Whether the selection assigns to one of the captured values, which a parameter could not carry back. - let assignsParameter (expr: SynExpr) (parameters: Parameter list) = + let assignsCapturedValue (expr: SynExpr) (parameters: Parameter list) = let assigned = (HashSet(), [ SyntaxNode.SynExpr expr ]) ||> SyntaxNodes.fold (fun positions _ node -> @@ -185,15 +204,12 @@ module private FunctionExtraction = | SynExpr.Tuple(exprs = arguments) -> arguments |> List.collect identifierArguments | _ -> [] - /// Positions of identifiers whose type inference cannot tell from their use alone: receivers of a member or indexer - /// access, operands of an operator and arguments of a method or constructor, which can have overloads. let positionsNeedingType (expr: SynExpr) (methodNameEnds: HashSet) = (HashSet(), [ SyntaxNode.SynExpr expr ]) ||> SyntaxNodes.fold (fun positions _ node -> match node with | SyntaxNode.SynExpr(SynExpr.App(funcExpr = method; argExpr = argument)) when methodNameEnds.Contains method.Range.End -> - for ident in identifierArguments argument do - positions.Add ident.idRange.Start |> ignore + positions.UnionWith(identifierArguments argument |> Seq.map _.idRange.Start) | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = head :: _ :: _))) | SyntaxNode.SynExpr(SynExpr.DotGet(expr = SynExpr.Ident head)) | SyntaxNode.SynExpr(SynExpr.DotIndexedGet(objectExpr = SynExpr.Ident head)) @@ -206,49 +222,40 @@ module private FunctionExtraction = positions) - let private declared (annotate: Parameter -> bool) (parameter: Parameter) = + let private curried (annotate: Parameter -> bool) (parameter: Parameter) = + if annotate parameter then + $"({parameter.Name}: {parameter.Type})" + else + parameter.Name + + let private tupled (annotate: Parameter -> bool) (parameter: Parameter) = if annotate parameter then $"{parameter.Name}: {parameter.Type}" else parameter.Name - let curriedParameters (parameters: Parameter list) (annotate: Parameter -> bool) = - match parameters with - | [] -> "()" - | _ -> - parameters - |> List.map (fun parameter -> - if annotate parameter then - $"({declared annotate parameter})" - else - parameter.Name) - |> String.concat " " + let private never (_: Parameter) = false - let curriedArguments (parameters: Parameter list) = - match parameters with - | [] -> "()" - | _ -> parameters |> List.map _.Name |> String.concat " " + let private curriedParameters (annotate: Parameter -> bool) (parameters: Parameter list) = + if parameters.IsEmpty then + "()" + else + parameters |> List.map (curried annotate) |> String.concat " " - let tupledParameters (parameters: Parameter list) (annotate: Parameter -> bool) = - let declarations = parameters |> List.map (declared annotate) |> String.concat ", " + let private tupledParameters (annotate: Parameter -> bool) (parameters: Parameter list) = + let declarations = parameters |> List.map (tupled annotate) |> String.concat ", " $"({declarations})" - let tupledArguments (parameters: Parameter list) = - let arguments = parameters |> List.map _.Name |> String.concat ", " - $"({arguments})" - - /// Whether a function application put where the selection was needs parentheses to stay one argument or the - /// receiver of an assignment. - let needsParentheses (target: ExtractionTarget) = + let private callNeedsParentheses (target: ExtractionTarget) = match target.Expr, target.Path with - | SynExpr.Paren _, _ when target.Replaced = target.Content -> false + | SynExpr.Paren _, _ when target.Replaced = target.Extracted -> false | _, SyntaxNode.SynExpr(SynExpr.App _ | SynExpr.DotGet _ | SynExpr.DotIndexedGet _ | SynExpr.TypeApp _) :: _ -> true | expr, SyntaxNode.SynExpr(SynExpr.DotSet(targetExpr = receiver) | SynExpr.DotIndexedSet(objectExpr = receiver) | SynExpr.DotNamedIndexedPropertySet( targetExpr = receiver)) :: _ -> obj.ReferenceEquals(expr, receiver) | _ -> false - let isInRecursiveModuleLet (path: SyntaxVisitorPath) = + let private isInRecursiveModuleLet (path: SyntaxVisitorPath) = path |> List.exists (function | SyntaxNode.SynModule(SynModuleDecl.Let(isRecursive = true)) -> true @@ -261,16 +268,13 @@ module private FunctionExtraction = | SynBinding(headPat = SynPat.Named(accessibility = Some(SynAccess.Private _))) -> true | _ -> false - /// Whether the selection is in an inline module-level function or member that is not private. Its body cannot - /// call anything less accessible than it is. - let rec isInExposedInline (path: SyntaxVisitorPath) = + let rec private isInExposedInline (path: SyntaxVisitorPath) = match path with | [] -> false | SyntaxNode.SynBinding(SynBinding(isInline = isInline) as binding) :: (SyntaxNode.SynModule _ | SyntaxNode.SynMemberDefn _) :: _ -> isInline && not (isPrivate binding) | _ :: rest -> isInExposedInline rest - /// Whether a pattern in the member binds the name, which hides a self identifier of that name. let private bindsName (name: string) (binding: SynBinding) = (false, [ SyntaxNode.SynBinding binding ]) ||> SyntaxNodes.fold (fun found _ node -> @@ -280,9 +284,7 @@ module private FunctionExtraction = found || String.Equals(ident.idText, name, StringComparison.Ordinal) | _ -> found) - /// The member of a class, record or union that contains the selection, outside interface implementations and - /// object expressions. - let tryEnclosingMember (path: SyntaxVisitorPath) = + let private tryEnclosingMember (path: SyntaxVisitorPath) = let rec loop (path: SyntaxVisitorPath) = match path with | SyntaxNode.SynMemberDefn(SynMemberDefn.Member(memberDefn = binding; range = memberRange)) :: SyntaxNode.SynTypeDefn(SynTypeDefn( @@ -293,195 +295,143 @@ module private FunctionExtraction = loop path - /// The start of the new member's declaration and the receiver it is called on: the self identifier of an instance - /// member, unless a local of the member hides it, or the name of a non-generic type for a static member. - let tryMemberCallee (binding: SynBinding) (info: SynComponentInfo) = + let private tryMemberCallee (binding: SynBinding) (info: SynComponentInfo) = match binding, info with | SynBinding(headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = [ self; _ ]))), _ when not (self.idText.StartsWith("_", StringComparison.Ordinal)) && not (bindsName self.idText binding) -> let self = PrettyNaming.NormalizeIdentifierBackticks self.idText - ValueSome(struct ($"member private {self}.", self)) + + ValueSome + struct {| + Declaration = $"member private {self}." + Receiver = self + |} | SynBinding(headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = [ _ ]))), SynComponentInfo(typeParams = None) -> - ValueSome(struct ("static member private ", PrettyNaming.NormalizeIdentifierBackticks (List.last info.LongIdent).idText)) + ValueSome + struct {| + Declaration = "static member private " + Receiver = PrettyNaming.NormalizeIdentifierBackticks (List.last info.LongIdent).idText + |} | _ -> ValueNone - /// Changes that declare `header = ` right after the member and call it where the selection was. - let tryMemberChanges - (sourceText: SourceText) - (target: ExtractionTarget) - (memberRange: range) - (header: string) - (call: string) - (indentSize: int) - (literalLines: HashSet) - = - let lines = sourceText.Lines - let firstLine = lines[Line.toZ memberRange.StartLine] - let lastLine = lines[Line.toZ memberRange.EndLine] - let indent = leadingSpaces sourceText firstLine - let lineBreak = lineBreakOf sourceText + let private tryDeclareAfterMember (extraction: Extraction) (memberRange: range) (header: string) (call: string) = + let sourceText = extraction.SourceText + let lastLine = sourceText.Lines[Line.toZ memberRange.EndLine] + + let indent = + leadingSpaces sourceText sourceText.Lines[Line.toZ memberRange.StartLine] - tryDeclaration sourceText target.Content header (indent + indentSize) literalLines lineBreak + tryDeclaration extraction header (indent + extraction.IndentSize) |> ValueOption.map (fun declaration -> let insertion = if lastLine.EndIncludingLineBreak > lastLine.End then - TextChange(TextSpan(lastLine.EndIncludingLineBreak, 0), $"{padding indent}{declaration}{lineBreak}") + TextChange(TextSpan(lastLine.EndIncludingLineBreak, 0), $"{padding indent}{declaration}{extraction.LineBreak}") else - TextChange(TextSpan(lastLine.End, 0), $"{lineBreak}{padding indent}{declaration}") + TextChange(TextSpan(lastLine.End, 0), $"{extraction.LineBreak}{padding indent}{declaration}") - [ TextChange(target.Replaced, call); insertion ]) + [ TextChange(extraction.Target.Replaced, call); insertion ]) -[] -type internal FSharpExtractFunctionRefactoring [] () = - inherit CodeRefactoringProvider() + let private closureCannotCapture (captures: Captures) = + captures.UsesBase || captures.UsesStructThis - static let register - (context: CodeRefactoringContext) - (sourceText: SourceText) - (title: string) - (kind: string) - (changes: TextChange list) - = - let changedDocument = - cancellableTask { - TelemetryReporter.ReportSingleEvent( - TelemetryEvents.RefactoringActivated, - [| "name", box (nameof FSharpExtractFunctionRefactoring); "kind", box kind |] - ) + let private curriedFunction (extraction: Extraction) (captures: Captures) (annotate: Parameter -> bool) = + let name = uniqueName "extractedFunction" extraction.Names + let application = $"{name} {curriedParameters never captures.Parameters}" + + struct {| + Header = $"{name} {curriedParameters annotate captures.Parameters}" + Call = + if callNeedsParentheses extraction.Target then + $"({application})" + else + application + |} + + let private localFunctionChanges (extraction: Extraction) (captures: Captures) (annotate: Parameter -> bool) = + match anchorsOf extraction.Target.Expr extraction.Target.Path with + | anchor :: _ when not (closureCannotCapture captures) -> + let newFunction = curriedFunction extraction captures annotate + tryDeclareInFront extraction anchor $"let {newFunction.Header}" newFunction.Call + | _ -> ValueNone + + let private moduleFunctionChanges (extraction: Extraction) (captures: Captures) (annotate: Parameter -> bool) = + let path = extraction.Target.Path + + if + captures.UsesThis + || captures.UsesBase + || isInExposedInline path + || not captures.TypeParameterDeclarations.IsEmpty + || isInRecursiveModuleLet path + then + ValueNone + else + let newFunction = curriedFunction extraction captures annotate + tryDeclareInFrontOfModuleLet extraction [] $"let private {newFunction.Header}" newFunction.Call + + let private privateMemberChanges (extraction: Extraction) (captures: Captures) (annotate: Parameter -> bool) = + let declaresNoneOf (memberRange: range) = + captures.TypeParameterDeclarations + |> List.forall (fun declaration -> not (Range.rangeContainsRange memberRange declaration)) - return context.Document.WithText(sourceText.WithChanges changes) - } + match tryEnclosingMember extraction.Target.Path with + | ValueSome(struct (binding, memberRange, info)) when not (isInExposedInline extraction.Target.Path) && declaresNoneOf memberRange -> + tryMemberCallee binding info + |> ValueOption.bind (fun callee -> + let name = uniqueName "ExtractedMethod" extraction.Names - context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + let header = + $"{callee.Declaration}{name}{tupledParameters annotate captures.Parameters}" + + let call = $"{callee.Receiver}.{name}{tupledParameters never captures.Parameters}" + tryDeclareAfterMember extraction memberRange header call) + | _ -> ValueNone + + let private annotationOf (setting: ParameterAnnotationSetting) (needingType: HashSet) (parameter: Parameter) = + match setting with + | ParameterAnnotationSetting.Always -> true + | ParameterAnnotationSetting.WhenNeeded -> parameter.Uses |> List.exists (fun m -> needingType.Contains m.Start) + | ParameterAnnotationSetting.Never -> false + + let offer (extraction: Extraction) (declaration: range) (setting: ParameterAnnotationSetting) = + let target = extraction.Target + + let selection = + RoslynHelpers.TextSpanToFSharpRange(extraction.Context.Document.FilePath, target.Extracted, extraction.SourceText) + + match tryCaptures extraction.CheckResults selection declaration extraction.Context.CancellationToken with + | ValueSome captures when + not (assignsCapturedValue target.Expr captures.Parameters) + && not (readsMutableInDelayedCode target.Expr captures.Parameters) + -> + let annotate = + annotationOf setting (positionsNeedingType target.Expr captures.MethodNameEnds) + + let register = registerRefactoring extraction + register (SR.ExtractToLocalFunction()) "local" (localFunctionChanges extraction captures annotate) + register (SR.ExtractToModuleFunction()) "module" (moduleFunctionChanges extraction captures annotate) + register (SR.ExtractToPrivateMember()) "member" (privateMemberChanges extraction captures annotate) + | _ -> () + +[] +type internal FSharpExtractFunctionRefactoring [] () = + inherit CodeRefactoringProvider() override _.ComputeRefactoringsAsync context = cancellableTask { let document = context.Document if not document.IsFSharpSignatureFile then - let! cancellationToken = CancellableTask.getCancellationToken () - let! sourceText = document.GetTextAsync cancellationToken + let! sourceText = document.GetTextAsync context.CancellationToken let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractFunctionRefactoring) + let parseTree = parseResults.ParseTree - let target = - if context.Span.IsEmpty then - tryParenthesizedLambdaAtCaret sourceText parseResults.ParseTree context.Span.Start - else - tryExtractionTarget sourceText parseResults.ParseTree context.Span - - match target with + match FunctionExtraction.tryTargetInDeclaration sourceText parseTree context.Span with | ValueNone -> () - | ValueSome target -> - match FunctionExtraction.enclosingScope target.Path with - | None -> () - | Some scope -> - let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof FSharpExtractFunctionRefactoring) - - let selection = - Range.mkRange - document.FilePath - (positionOf sourceText target.Content.Start) - (positionOf sourceText target.Content.End) - - match FunctionExtraction.tryCaptures checkResults selection scope cancellationToken with - | ValueSome captures when - not (FunctionExtraction.assignsParameter target.Expr captures.Parameters) - && not (FunctionExtraction.readsMutableLater target.Expr captures.Parameters) - -> - let parameters = captures.Parameters - let! options = document.GetOptionsAsync cancellationToken - - let indentSize = - options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) - - let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpExtractFunctionRefactoring) - - let literalLines = - linesInsideLiterals sourceText parseResults.ParseTree defines langVersion - - let names = usedNames parseResults.ParseTree - - let needingType = - FunctionExtraction.positionsNeedingType target.Expr captures.MethodNameEnds - - let setting = document.Project.FSharpExtractFunctionParameterAnnotations - let inExposedInline = FunctionExtraction.isInExposedInline target.Path - - let annotate (parameter: FunctionExtraction.Parameter) = - match setting with - | ParameterAnnotationSetting.Always -> true - | ParameterAnnotationSetting.WhenNeeded -> - parameter.Uses |> List.exists (fun m -> needingType.Contains m.Start) - | ParameterAnnotationSetting.Never -> false - - let functionName = uniqueName "extractedFunction" names - - let header = - $"{functionName} {FunctionExtraction.curriedParameters parameters annotate}" - - let application = $"{functionName} {FunctionExtraction.curriedArguments parameters}" - - let call = - if FunctionExtraction.needsParentheses target then - $"({application})" - else - application - - if not (captures.UsesBase || captures.UsesStructThis) then - match anchorsOf target.Expr target.Path with - | anchor :: _ -> - match tryDeclareInFront sourceText target anchor $"let {header}" call indentSize literalLines with - | ValueSome changes -> register context sourceText (SR.ExtractToLocalFunction()) "local" changes - | ValueNone -> () - | [] -> () - - if - not ( - captures.UsesThis - || captures.UsesBase - || inExposedInline - || not captures.TypeParameters.IsEmpty - || FunctionExtraction.isInRecursiveModuleLet target.Path - ) - then - match - tryDeclareInFrontOfModuleLet sourceText target [] $"let private {header}" call indentSize literalLines - with - | ValueSome changes -> register context sourceText (SR.ExtractToModuleFunction()) "module" changes - | ValueNone -> () - - match FunctionExtraction.tryEnclosingMember target.Path with - | ValueSome(struct (binding, memberRange, info)) when - not inExposedInline - && captures.TypeParameters - |> List.forall (fun declaration -> not (Range.rangeContainsRange memberRange declaration)) - -> - match FunctionExtraction.tryMemberCallee binding info with - | ValueSome(struct (prefix, receiver)) -> - let memberName = uniqueName "ExtractedMethod" names - - let memberHeader = - $"{prefix}{memberName}{FunctionExtraction.tupledParameters parameters annotate}" - - let memberCall = - $"{receiver}.{memberName}{FunctionExtraction.tupledArguments parameters}" - - match - FunctionExtraction.tryMemberChanges - sourceText - target - memberRange - memberHeader - memberCall - indentSize - literalLines - with - | ValueSome changes -> register context sourceText (SR.ExtractToPrivateMember()) "member" changes - | ValueNone -> () - | ValueNone -> () - | _ -> () - | _ -> () + | ValueSome(struct (target, declaration)) -> + let! extraction = prepareExtraction context (nameof FSharpExtractFunctionRefactoring) sourceText parseTree target + FunctionExtraction.offer extraction declaration document.Project.FSharpExtractFunctionParameterAnnotations } |> CancellableTask.startAsTask context.CancellationToken From 04435bd564791ac2ffef585c86b228befe27443d Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 15 Oct 2026 03:53:32 +0100 Subject: [PATCH 30/31] Fold the Extract to function tests into parameterized theories One theory per kind of assertion with a scenario name per row, on the helpers the Extract to let binding tests share. Every case is kept; tests that asserted several things became one row per assertion, so 35 cases became 38. Co-Authored-By: Claude Sonnet 5.5 --- .../Refactors/ExtractFunctionTests.fs | 619 ++++++++---------- 1 file changed, 257 insertions(+), 362 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs index bd2dbe51cf6..cc1ea365116 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractFunctionTests.fs @@ -1,23 +1,47 @@ module FSharp.Editor.Tests.Refactors.ExtractFunctionTests -open System - -open Microsoft.CodeAnalysis.Text - open Microsoft.VisualStudio.FSharp.Editor -open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks open Xunit open FSharp.Editor.Tests.Helpers open FSharp.Editor.Tests.Refactors.RefactorTestFramework +[] let private extractToLocalFunction = "Extract to local function" + +[] let private extractToModuleFunction = "Extract to module function" + +[] let private extractToPrivateMember = "Extract to private member" -let private selectionOf (code: string) (selected: string) = - TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) +[] +let private localAndModule = extractToLocalFunction + ";" + extractToModuleFunction + +[] +let private localAndMember = extractToLocalFunction + ";" + extractToPrivateMember + +[] +let private area = + """ +let area (w: int) (h: int) = + printfn "%d" (w * h + 1) +""" + +[] +let private areaWithLocalFunction = + """ +let area (w: int) (h: int) = + let extractedFunction (w: int) (h: int) = w * h + 1 + printfn "%d" (extractedFunction w h) +""" + +let private settingOf = + function + | "Always" -> ParameterAnnotationSetting.Always + | "WhenNeeded" -> ParameterAnnotationSetting.WhenNeeded + | _ -> ParameterAnnotationSetting.Never let private contextFor (setting: ParameterAnnotationSetting) (code: string) = let options = @@ -27,218 +51,216 @@ let private contextFor (setting: ParameterAnnotationSetting) (code: string) = new TestContext(RoslynTestHelpers.CreateSolution(code, editorOptions = options)) -let private caretAt (code: string) (marker: string) = - TextSpan(code.IndexOf(marker, StringComparison.Ordinal), 0) - -let private extractedAtWith (setting: ParameterAnnotationSetting) (title: string) (code: string) (span: TextSpan) = +let private extractedAtWith setting title code span = use context = contextFor setting code + refactoredText (new FSharpExtractFunctionRefactoring()) context title code span - let document = - refactorSpan code span title context (new FSharpExtractFunctionRefactoring()) - - let parseResults = - document.GetFSharpParseResultsAsync "test" - |> CancellableTask.runSynchronouslyWithoutCancellation - - Assert.Empty(parseResults.Diagnostics) - (document.GetTextAsync() |> GetTaskResult).ToString() - -let private extractedWith (setting: ParameterAnnotationSetting) (title: string) (code: string) (selected: string) = - extractedAtWith setting title code (selectionOf code selected) - -let private extracted = extractedWith ParameterAnnotationSetting.Always - -let private titlesAt (code: string) (span: TextSpan) = +let private titlesAt code span = use context = contextFor ParameterAnnotationSetting.Always code - - tryGetRefactoringActionsForSpan code span context (new FSharpExtractFunctionRefactoring()) - |> Seq.map _.Title - |> Seq.toList - -let private titlesFor (code: string) (selected: string) = - titlesAt code (selectionOf code selected) + offeredTitles (new FSharpExtractFunctionRefactoring()) context code span [] let ``Caret in the header of a parenthesized lambda extracts it as if it were selected`` () = let code = - """ -module M - + fileOf + """ let f (xs: int list) (n: int) = xs |> List.map (fun x -> x + n) """ - let lambda = "(fun x -> x + n)" - let titles = titlesFor code lambda + let lambda = selectionOf code "(fun x -> x + n)" + let caret = caretAt code "x ->" + let titles = titlesAt code lambda - Assert.NotEmpty(titles) - Assert.Equal(titles, titlesAt code (caretAt code "x ->")) + Assert.True(titles.Length > 0) + Assert.Equal(titles, titlesAt code caret) - for title in titles do - Assert.Equal(extracted title code lambda, extractedAtWith ParameterAnnotationSetting.Always title code (caretAt code "x ->")) + for title in titles.Split ';' do + Assert.Equal( + extractedAtWith ParameterAnnotationSetting.Always title code lambda, + extractedAtWith ParameterAnnotationSetting.Always title code caret + ) Assert.Empty(titlesAt code (caretAt code "x + n")) -let private area = - """ -module M - -let area (w: int) (h: int) = - printfn "%d" (w * h + 1) -""" - -let private areaWithLocalFunction = - """ -module M - -let area (w: int) (h: int) = - let extractedFunction (w: int) (h: int) = w * h + 1 - printfn "%d" (extractedFunction w h) -""" - -[] -let ``Captured parameters become parameters of a local function`` () = - Assert.Equal(areaWithLocalFunction, extracted extractToLocalFunction area "w * h + 1") - -[] -let ``Module function is declared in front of the declaration using it`` () = - let expected = - """ -module M - +[] +[] +[] -let ``Call keeps its parentheses where the selection lost them`` () = - Assert.Equal(areaWithLocalFunction, extracted extractToLocalFunction area "(w * h + 1)") - -[] -let ``Selection without captures becomes a function of unit`` () = - let code = - """ -module M - +""")>] +[] +[] -let ``Selection using this becomes a private member`` () = - let code = - """ -module M - +""")>] +[ List.sumBy (fun l -> l * this.Rate) -""" - - let expected = - """ -module M - +""", + "l * this.Rate", + """ type Order(lines: int list) = member this.Rate = 3 member this.Total = lines |> List.sumBy (fun l -> this.ExtractedMethod(l)) member private this.ExtractedMethod(l: int) = l * this.Rate -""" +""")>] +[] +[] +[] +[] +[] +let ``Selection is extracted`` (_scenario: string, title: string, code: string, selected: string, expected: string) = + let code = fileOf code - Assert.Equal(expected, extracted extractToPrivateMember code "l * this.Rate") + Assert.Equal(fileOf expected, extractedAtWith ParameterAnnotationSetting.Always title code (selectionOf code selected)) [] -[ ParameterAnnotationSetting.Always - | "WhenNeeded" -> ParameterAnnotationSetting.WhenNeeded - | _ -> ParameterAnnotationSetting.Never - - let code = - """ -module M - +[] -let ``Variants follow what the selection uses`` () = - Assert.Equal([ extractToLocalFunction; extractToModuleFunction ], titlesFor area "w * h + 1") + let extractedFunction (s: string) (n: int) = s.ToUpper() + string n + printfn "%s" (extractedFunction s n) +""")>] +[] +[] +[] +let ``Parameter annotations follow the option`` (setting: string, code: string, selected: string, expected: string) = + let code = fileOf code - let derived = - """ -module M + Assert.Equal(fileOf expected, extractedAtWith (settingOf setting) extractToLocalFunction code (selectionOf code selected)) +[] +[] +[([ extractToPrivateMember ], titlesFor derived "base.ToString() + \"!\"") - -[] -let ``No action when the selection assigns to a captured mutable local`` () = - let code = - """ -module M - +""", + "base.ToString() + \"!\"", + extractToPrivateMember)>] +[] -[] +[) = x <- x + 1 """, - "x + 1")>] -[] +[] type Window = val Length: int @@ -248,101 +270,82 @@ type Buffer = Window let size (buffer: Buffer) = buffer.Length + 1 """, - "buffer.Length + 1")>] -[] +[] -[] +[ n) n <- 1 read () """, - "(fun () -> n)")>] -[ n)", + "")>] +[ Seq.map _.Substring(n) n <- 1 tails """, - "xs |> Seq.map _.Substring(n)")>] -let ``No action when a value the selection reads cannot become a parameter`` (code: string, selected: string) = - Assert.Empty(titlesFor code selected) - -[] -let ``Mutable local read at once becomes a parameter`` () = - let code = - """ -module M - + "xs |> Seq.map _.Substring(n)", + "")>] +[([ extractToLocalFunction; extractToModuleFunction ], titlesFor code "n + 1") - -[] -let ``Type parameters stay in the scope that declares them`` () = - let moduleFunction = - """ -module M - -let f<'T> () = typeof<'T> -""" - - let methodOfGenericType = - """ -module M - +""", + "n + 1", + localAndModule)>] +[ () = typeof<'T>", "typeof<'T>", extractToLocalFunction)>] +[() = member this.M<'U>() = (typeof<'T>, typeof<'U>) -""" - - Assert.Equal([ extractToLocalFunction ], titlesFor moduleFunction "typeof<'T>") - Assert.Equal([ extractToLocalFunction; extractToPrivateMember ], titlesFor methodOfGenericType "typeof<'T>") - Assert.Equal([ extractToLocalFunction ], titlesFor methodOfGenericType "typeof<'U>") - -[] -let ``No local function for a selection using this of a struct`` () = - let code = - """ -module M - +""", + "typeof<'T>", + localAndMember)>] +[() = + member this.M<'U>() = (typeof<'T>, typeof<'U>) +""", + "typeof<'U>", + extractToLocalFunction)>] +[] type S = val X: int new(x) = { X = x } member this.M() = this.X + 1 -""" - - Assert.Equal([ extractToPrivateMember ], titlesFor code "this.X + 1") - -[] -let ``No private member where a local hides the self identifier`` () = - let code = - """ -module M - +""", + "this.X + 1", + extractToPrivateMember)>] +[([ extractToLocalFunction ], titlesFor code "this.Length + 1") +""", + "this.Length + 1", + extractToLocalFunction)>] +let ``Actions offered for a selection`` (_scenario: string, code: string, selected: string, titles: string) = + let code = fileOf code + Assert.Equal(titles, titlesAt code (selectionOf code selected)) [] [] @@ -355,109 +358,13 @@ type C() = let ``Private function or member is offered unless a more accessible inline one would call it`` (declaration: string, title: string, offered: bool) = - let titles = titlesFor $"module M\n\n{declaration}\n" "x + 1" + let code = $"module M\n\n{declaration}\n" + let titles = (titlesAt code (selectionOf code "x + 1")).Split ';' - Assert.Equal(offered, List.contains title titles) + Assert.Equal(offered, Array.contains title titles) Assert.Contains(extractToLocalFunction, titles) -[] -[] -[] -[] -let ``Private member is named and called as its type allows`` (code: string, expected: string) = - Assert.Equal(expected, extracted extractToPrivateMember code "1 + 2") - -[] -let ``Call that is the receiver of an assignment is parenthesized`` () = - let code = - """ -module M - -let f () = - System.Text.StringBuilder("abc").Length <- 0 -""" - - let expected = - """ -module M - -let f () = - let extractedFunction () = System.Text.StringBuilder("abc") - (extractedFunction ()).Length <- 0 -""" - - Assert.Equal(expected, extracted extractToLocalFunction code "System.Text.StringBuilder(\"abc\")") - -[] -let ``Argument of an overloaded method is annotated when needed`` () = - let code = - """ -module M - -let f (x: int64) = System.Math.Abs x -""" - - let expected = - """ -module M - -let f (x: int64) = - let extractedFunction (x: int64) = System.Math.Abs x - extractedFunction x -""" - - Assert.Equal(expected, extractedWith ParameterAnnotationSetting.WhenNeeded extractToLocalFunction code "System.Math.Abs x") - -[] -let ``Lines inside a multi-line string in an inactive branch are not re-indented`` () = - // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. - let code = - "module M\n\nlet combine (n: int) (xs: string list) = n\n\nlet r = combine 1 [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" - - let expected = - "module M\n\nlet combine (n: int) (xs: string list) = n\n\nlet r =\n let extractedFunction () = 1\n combine (extractedFunction ()) [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" - - let selection = TextSpan(code.IndexOf("1 [", StringComparison.Ordinal), 1) - Assert.Equal(expected, extractedAtWith ParameterAnnotationSetting.Always extractToLocalFunction code selection) - +[] let private assignedDependency = """ module Dependency @@ -468,28 +375,18 @@ let extractedFunction = { Value = 0 } let ExtractedMethod = { Value = 0 } """ -let private extractedWithDependency (title: string) (code: string) (selected: string) = - use context = TestContext.CreateWithCodeAndDependency code assignedDependency - - let document = - refactorSpan code (selectionOf code selected) title context (new FSharpExtractFunctionRefactoring()) - - (document.GetTextAsync() |> GetTaskResult).ToString() - -[] -let ``Local function name does not collide with an assignment target from another file`` () = - let code = - """ +[] +[] -let ``Member name does not collide with an assignment target from another file`` () = - let code = - """ +""")>] +[] +let ``Name does not collide with an assignment target from another file`` + (_scenario: string, title: string, code: string, expected: string) + = + use context = TestContext.CreateWithCodeAndDependency code assignedDependency - Assert.Equal(expected, extractedWithDependency extractToPrivateMember code "({ Value = 1 })") + Assert.Equal(expected, refactoredText (new FSharpExtractFunctionRefactoring()) context title code (selectionOf code "({ Value = 1 })")) From c064fb1bd39ef08a07a6b7ffc249c7d1c6408dcc Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 29 Oct 2026 22:15:33 +0100 Subject: [PATCH 31/31] Bring the rebased branch to what it had before the rebase Replaying the branch onto main left some files short of the branch's own content; this restores it. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/.VisualStudio/18.vNext.md | 3 - .../Refactor/ExtractLetBinding.fs | 118 +-- .../Refactor/RefactoringHelpers.fs | 601 +++++------ .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 39 +- .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 39 +- .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 39 +- .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 39 +- .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 39 +- .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 39 +- .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 39 +- .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 39 +- .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 39 +- .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 39 +- .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 39 +- .../xlf/FSharp.Editor.zh-Hans.xlf | 39 +- .../xlf/FSharp.Editor.zh-Hant.xlf | 39 +- .../Refactors/ExtractLetBindingTests.fs | 947 +++++++----------- .../Refactors/RefactorTestFramework.fs | 49 +- 18 files changed, 935 insertions(+), 1290 deletions(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index e1d99d66562..a954a908f44 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -8,9 +8,6 @@ * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) -* **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration. -* **Extract to let binding** and **Extract to literal** refactorings for a selected expression. The value is bound in front of the statement that uses it, so it is computed before the code that preceded the selection in that statement; a constant in a module-level declaration becomes a `[]` in front of that declaration, also with just the caret inside the constant. -* **Extract to local function**, **Extract to module function** and **Extract to private member** refactorings for a selected expression. Values the selection reads from the enclosing function, member or lambda become parameters in the order they are first used, typed as the new **Parameter types in Extract to function** option under **Code Fixes** asks. A selection that assigns to a captured mutable local, or reads a byref or byref-like value from outside, is not offered. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs index 34c8799df7f..bab5d714b4f 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ExtractLetBinding.fs @@ -4,11 +4,7 @@ namespace Microsoft.VisualStudio.FSharp.Editor open System.Composition -open Microsoft.CodeAnalysis.CodeActions open Microsoft.CodeAnalysis.CodeRefactorings -open Microsoft.CodeAnalysis.Formatting -open Microsoft.CodeAnalysis.Text -open Microsoft.VisualStudio.FSharp.Editor.Telemetry open FSharp.Compiler.Syntax @@ -19,91 +15,59 @@ open CancellableTasks type internal FSharpExtractLetBindingRefactoring [] () = inherit CodeRefactoringProvider() - static let register - (context: CodeRefactoringContext) - (sourceText: SourceText) - (title: string) - (kind: string) - (changes: TextChange list) - = - let changedDocument = - cancellableTask { - TelemetryReporter.ReportSingleEvent( - TelemetryEvents.RefactoringActivated, - [| "name", box (nameof FSharpExtractLetBindingRefactoring); "kind", box kind |] - ) - - return context.Document.WithText(sourceText.WithChanges changes) - } - - context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + static let letBindingChanges (extraction: Extraction) = + match anchorsOf extraction.Target.Expr extraction.Target.Path with + | [] -> ValueNone + | anchor :: _ -> + let name = uniqueName "extracted" extraction.Names + + let header = + match tryFunctionTypeText extraction.CheckResults extraction.Target with + | ValueSome functionType -> $"let {name}: {functionType}" + | ValueNone -> $"let {name}" + + tryDeclareInFront extraction anchor header name + + static let literalChanges (extraction: Extraction) = + let constant = + match extraction.Target.Expr with + | SynExpr.Paren(expr = inner) -> inner + | expr -> expr + + if isLiteralConstant constant then + let name = uniqueName "ExtractedConstant" extraction.Names + + let attribute = + literalAttributeAt extraction.CheckResults extraction.Target.Expr.Range.Start + + tryDeclareInFrontOfModuleLet extraction [ $"[<{attribute}>]" ] $"let {name}" name + else + ValueNone + + static let offer (isSelected: bool) (extraction: Extraction) = + if isSelected then + registerRefactoring extraction (SR.ExtractToLetBinding()) "let" (letBindingChanges extraction) + + registerRefactoring extraction (SR.ExtractToLiteral()) "literal" (literalChanges extraction) override _.ComputeRefactoringsAsync context = cancellableTask { let document = context.Document if not document.IsFSharpSignatureFile then - let! cancellationToken = CancellableTask.getCancellationToken () - let! sourceText = document.GetTextAsync cancellationToken + let! sourceText = document.GetTextAsync context.CancellationToken let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpExtractLetBindingRefactoring) - - let lambdaAtCaret = - if context.Span.IsEmpty then - tryParenthesizedLambdaAtCaret sourceText parseResults.ParseTree context.Span.Start - else - ValueNone - - let isSelected = not context.Span.IsEmpty || lambdaAtCaret.IsSome + let parseTree = parseResults.ParseTree + let selected = trySelectedTarget sourceText parseTree context.Span let target = - match lambdaAtCaret with - | ValueSome _ -> lambdaAtCaret - | ValueNone when context.Span.IsEmpty -> tryConstantAtCaret sourceText parseResults.ParseTree context.Span.Start - | ValueNone -> tryExtractionTarget sourceText parseResults.ParseTree context.Span + selected + |> ValueOption.orElseWith (fun () -> tryConstantAtCaret sourceText parseTree context.Span) match target with | ValueNone -> () | ValueSome target -> - let! options = document.GetOptionsAsync cancellationToken - - let indentSize = - options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) - - let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpExtractLetBindingRefactoring) - let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof FSharpExtractLetBindingRefactoring) - let names = usedNames parseResults.ParseTree - - let literalLines = - linesInsideLiterals sourceText parseResults.ParseTree defines langVersion - - if isSelected then - match anchorsOf target.Expr target.Path with - | anchor :: _ -> - let name = uniqueName "extracted" names - - let header = - match tryFunctionTypeText checkResults target with - | ValueSome functionType -> $"let {name}: {functionType}" - | ValueNone -> $"let {name}" - - match tryDeclareInFront sourceText target anchor header name indentSize literalLines with - | ValueSome changes -> register context sourceText (SR.ExtractToLetBinding()) "let" changes - | ValueNone -> () - | [] -> () - - let constant = - match target.Expr with - | SynExpr.Paren(expr = inner) -> inner - | expr -> expr - - if isLiteralConstant constant then - let name = uniqueName "ExtractedConstant" names - let attribute = literalAttributeAt checkResults target.Expr.Range.Start - - match - tryDeclareInFrontOfModuleLet sourceText target [ $"[<{attribute}>]" ] $"let {name}" name indentSize literalLines - with - | ValueSome changes -> register context sourceText (SR.ExtractToLiteral()) "literal" changes - | ValueNone -> () + let! extraction = prepareExtraction context (nameof FSharpExtractLetBindingRefactoring) sourceText parseTree target + offer selected.IsSome extraction } |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs index cd435f081f3..8158df27329 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/RefactoringHelpers.fs @@ -6,7 +6,11 @@ open System open System.Collections.Generic open System.Text +open Microsoft.CodeAnalysis.CodeActions +open Microsoft.CodeAnalysis.CodeRefactorings +open Microsoft.CodeAnalysis.Formatting open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor.Telemetry open FSharp.Compiler.CodeAnalysis open FSharp.Compiler.Symbols @@ -15,26 +19,36 @@ open FSharp.Compiler.SyntaxTrivia open FSharp.Compiler.Text open FSharp.Compiler.Tokenization -/// A place in front of which a declaration used by the selected expression can be inserted. +open CancellableTasks + [] type Anchor = - /// An expression that is a statement of its block. | Statement of start: pos - /// The body of a binding, match clause, branch or lambda, after the keyword ending at keywordEnd. No line of the - /// body can start left of column limit, where the construct that sets its offside line starts. - | Clause of keywordEnd: pos * body: range * limit: int + | Clause of keywordEnd: pos * body: range * offsideColumn: int [] type ExtractionTarget = { Expr: SynExpr Path: SyntaxVisitorPath - /// The selection without its parentheses when the selected expression is parenthesized. - Content: TextSpan - /// The text the new name replaces; the parentheses of a method call stay. + Extracted: TextSpan Replaced: TextSpan } +[] +type Extraction = + { + Context: CodeRefactoringContext + Provider: string + SourceText: SourceText + Target: ExtractionTarget + CheckResults: FSharpCheckFileResults + IndentSize: int + LiteralLines: HashSet + LineBreak: string + Names: HashSet + } + let private isSame (expr: SynExpr) (other: SynExpr) = obj.ReferenceEquals(expr, other) let private hasName (name: string) (ident: Ident) = @@ -47,6 +61,8 @@ let positionOf (sourceText: SourceText) (offset: int) = let linePosition = sourceText.Lines.GetLinePosition offset Position.mkPos (Line.fromZ linePosition.Line) linePosition.Character +let padding (width: int) = String(' ', width) + let leadingSpaces (sourceText: SourceText) (line: TextLine) = let mutable position = line.Start @@ -57,44 +73,37 @@ let leadingSpaces (sourceText: SourceText) (line: TextLine) = let lineBreakOf (sourceText: SourceText) = sourceText.Lines - |> Seq.tryFind (fun line -> line.EndIncludingLineBreak > line.End) - |> Option.map (fun line -> sourceText.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak))) - |> Option.defaultValue Environment.NewLine + |> Seq.tryFindV (fun line -> line.EndIncludingLineBreak > line.End) + |> ValueOption.map (fun line -> sourceText.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak))) + |> ValueOption.defaultValue Environment.NewLine -let isLineLeading (sourceText: SourceText) (position: pos) = +let private isLineLeading (sourceText: SourceText) (position: pos) = let line = sourceText.Lines[Line.toZ position.Line] leadingSpaces sourceText line = position.Column -/// Whether only closing brackets and a line comment follow the position on its line. -let restOfLineIsClosers (sourceText: SourceText) (position: pos) = +let private onlyClosersFollow (sourceText: SourceText) (position: pos) = let line = sourceText.Lines[Line.toZ position.Line] - let rest = - sourceText.ToString(TextSpan.FromBounds(line.Start + position.Column, line.End)) + let isCloser c = + Char.IsWhiteSpace c || c = ')' || c = ']' || c = '}' || c = '|' - let code = - match rest.IndexOf("//", StringComparison.Ordinal) with - | -1 -> rest - | comment -> rest.Substring(0, comment) + let mutable offset = line.Start + position.Column - code - |> Seq.forall (fun c -> Char.IsWhiteSpace c || c = ')' || c = ']' || c = '}' || c = '|') + while offset < line.End && isCloser (sourceText[offset]) do + offset <- offset + 1 -/// Whether a line lexed from this state starts inside a string literal. -let private continuesString (state: FSharpTokenizerColorState) = - match state with - | FSharpTokenizerColorState.InitialState - | FSharpTokenizerColorState.Token - | FSharpTokenizerColorState.IfDefSkip - | FSharpTokenizerColorState.EndLineThenSkip - | FSharpTokenizerColorState.EndLineThenToken - | FSharpTokenizerColorState.Comment - | FSharpTokenizerColorState.SingleLineComment - | FSharpTokenizerColorState.StringInComment - | FSharpTokenizerColorState.VerbatimStringInComment - | FSharpTokenizerColorState.TripleQuoteStringInComment -> false - // String, VerbatimString, TripleQuoteString and the extended interpolated string state, which the public enum does not name. - | _ -> true + offset = line.End + || (offset + 1 < line.End + && sourceText[offset] = '/' + && sourceText[offset + 1] = '/') + +let private startsInsideString (state: FSharpTokenizerLexState) = + match FSharpLineTokenizer.ColorStateOfLexState state with + | FSharpTokenizerColorState.String + | FSharpTokenizerColorState.VerbatimString + | FSharpTokenizerColorState.TripleQuoteString -> true + // The public enum does not name the interpolated string states. + | color -> not (Enum.IsDefined(typeof, color)) let private isSkipped (state: FSharpTokenizerLexState) = match FSharpLineTokenizer.ColorStateOfLexState state with @@ -108,7 +117,6 @@ let private startsWithDirective (code: ReadOnlySpan) (keyword: string) = || Char.IsWhiteSpace code[keyword.Length] || code[keyword.Length] = '/') -/// Whether the line is a directive the lexer recognizes while it skips an inactive branch. let private isSkippedDirective (line: string) = let code = line.AsSpan().TrimStart() @@ -127,8 +135,6 @@ let private scanLine (tokenizer: FSharpSourceTokenizer) (line: string) (state: F loop state -/// Lines inside multi-line string literals of the branches of `#if` the defines leave inactive. Each run of inactive -/// lines between two directives is lexed as the code it is when its branch is taken. let private linesInsideInactiveLiterals (sourceText: SourceText) (defines: string list) (langVersion: string) (lines: HashSet) = let fileTokenizer = FSharpSourceTokenizer(defines, None, Some langVersion) let branchTokenizer = FSharpSourceTokenizer([], None, Some langVersion) @@ -137,18 +143,22 @@ let private linesInsideInactiveLiterals (sourceText: SourceText) (defines: strin for line in sourceText.Lines do let text = line.ToString() + let inInactiveBranch = isSkipped fileState && not (isSkippedDirective text) - if isSkipped fileState && not (isSkippedDirective text) then - if continuesString (FSharpLineTokenizer.ColorStateOfLexState branchState) then - lines.Add line.LineNumber |> ignore + if inInactiveBranch && startsInsideString branchState then + lines.Add line.LineNumber |> ignore - branchState <- scanLine branchTokenizer text branchState - else - branchState <- FSharpTokenizerLexState.Initial + branchState <- + if inInactiveBranch then + scanLine branchTokenizer text branchState + else + FSharpTokenizerLexState.Initial fileState <- scanLine fileTokenizer text fileState -/// Lines inside multi-line string literals, including those in branches of `#if` the defines leave inactive. +let private linesAfterFirst (m: range) = + seq { for line in m.StartLine + 1 .. m.EndLine -> Line.toZ line } + let linesInsideLiterals (sourceText: SourceText) (parseTree: ParsedInput) (defines: string list) (langVersion: string) = let lines = (HashSet(), parseTree) @@ -156,9 +166,7 @@ let linesInsideLiterals (sourceText: SourceText) (parseTree: ParsedInput) (defin match node with | SyntaxNode.SynExpr(SynExpr.Const(range = m)) | SyntaxNode.SynExpr(SynExpr.InterpolatedString(range = m)) - | SyntaxNode.SynPat(SynPat.Const(range = m)) -> - for line in m.StartLine + 1 .. m.EndLine do - lines.Add(Line.toZ line) |> ignore + | SyntaxNode.SynPat(SynPat.Const(range = m)) -> lines.UnionWith(linesAfterFirst m) | _ -> () lines) @@ -182,9 +190,7 @@ let usedNames (parseTree: ParsedInput) = | SyntaxNode.SynExpr(SynExpr.LongIdentSet(longDotId = SynLongIdent(id = ident :: _))) | SyntaxNode.SynExpr(SynExpr.NamedIndexedPropertySet(longDotId = SynLongIdent(id = ident :: _))) | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) -> names.Add ident.idText |> ignore - | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = idents))) -> - for ident in idents do - names.Add ident.idText |> ignore + | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = idents))) -> names.UnionWith(idents |> Seq.map _.idText) | _ -> () names) @@ -193,10 +199,9 @@ let uniqueName (baseName: string) (used: HashSet) = Seq.initInfinite (fun i -> if i = 0 then baseName else $"{baseName}{i}") |> Seq.find (used.Contains >> not) -/// The text of the span with every line after the first moved by as many columns as the first line moves to reach -/// column. Lines inside multi-line string literals are kept as they are; a line that would need a negative indentation -/// makes the whole text unavailable. -let tryIndentedText (sourceText: SourceText) (span: TextSpan) (column: int) (literalLines: HashSet) = +let private tryIndentedText (extraction: Extraction) (column: int) = + let sourceText = extraction.SourceText + let span = extraction.Target.Extracted let lines = sourceText.Lines let first = lines.GetLineFromPosition span.Start let last = lines.GetLineFromPosition span.End @@ -216,7 +221,7 @@ let tryIndentedText (sourceText: SourceText) (span: TextSpan) (column: int) (lit sourceText.ToString(TextSpan.FromBounds(line.Start, min line.End span.End)) let moved = - match literalLines.Contains lineNumber with + match extraction.LiteralLines.Contains lineNumber with | true -> ValueSome content | false when String.IsNullOrWhiteSpace content -> ValueSome content | false when shift >= 0 -> ValueSome(String(' ', shift) + content) @@ -334,8 +339,7 @@ let private isInExcludedContext (expr: SynExpr) (path: SyntaxVisitorPath) = loop (SyntaxNode.SynExpr expr) path -/// Whether the expression contains a computation expression construct outside any computation expression of its own. -let private hasStatementOnlyConstruct (expr: SynExpr) = +let private hasOrphanedComputationConstruct (expr: SynExpr) = let containers = ResizeArray() let statements = ResizeArray() @@ -357,6 +361,37 @@ let private hasStatementOnlyConstruct (expr: SynExpr) = |> Seq.exists (fun container -> Range.rangeContainsRange container statement) )) +let private targetOf (sourceText: SourceText) (expr: SynExpr) (path: SyntaxVisitorPath) = + let span = textSpanOf sourceText expr.Range + + let extracted = + match expr with + | SynExpr.Paren(expr = inner) -> textSpanOf sourceText inner.Range + | _ -> span + + let replaced = + match expr with + | SynExpr.Paren _ when isCall path -> extracted + | _ -> span + + { + Expr = expr + Path = path + Extracted = extracted + Replaced = replaced + } + +let private exprSpanning (start: pos) (finish: pos) (path: SyntaxVisitorPath) (node: SyntaxNode) = + match node with + | SyntaxNode.SynExpr expr when Position.posEq expr.Range.Start start && Position.posEq expr.Range.End finish -> Some(expr, path) + | _ -> None + +let private crossesDirective (directives: ConditionalDirectiveTrivia list) (start: pos) (finish: pos) = + directives + |> List.exists (fun directive -> + let line = directiveLine directive + line >= start.Line && line <= finish.Line) + let tryExtractionTarget (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = let span = trimmed sourceText selection @@ -365,53 +400,75 @@ let tryExtractionTarget (sourceText: SourceText) (parseTree: ParsedInput) (selec let start = positionOf sourceText span.Start let finish = positionOf sourceText span.End - let crossesDirective = - file.Trivia.ConditionalDirectives - |> List.exists (fun directive -> - let line = directiveLine directive - line >= start.Line && line <= finish.Line) - - let exact = - (start, parseTree) - ||> ParsedInput.tryPickLast (fun path node -> - match node with - | SyntaxNode.SynExpr expr when Position.posEq expr.Range.Start start && Position.posEq expr.Range.End finish -> - Some(expr, path) - | _ -> None) - - match exact with + match (start, parseTree) ||> ParsedInput.tryPickLast (exprSpanning start finish) with | Some(expr, path) when - not crossesDirective + not (crossesDirective file.Trivia.ConditionalDirectives start finish) && isExtractableShape expr path && not (isInExcludedContext expr path) - && not (hasStatementOnlyConstruct expr) + && not (hasOrphanedComputationConstruct expr) -> - let content = - match expr with - | SynExpr.Paren(expr = inner) -> textSpanOf sourceText inner.Range - | _ -> span - - let replaced = - match expr with - | SynExpr.Paren _ when isCall path -> content - | _ -> span - - ValueSome - { - Expr = expr - Path = path - Content = content - Replaced = replaced - } + ValueSome(targetOf sourceText expr path) | _ -> ValueNone | _ -> ValueNone +let isLiteralConstant (expr: SynExpr) = + match expr with + | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.Single _ | SynConst.Double _ | SynConst.Decimal _ | SynConst.Char _ | SynConst.String _), + _) -> true + | _ -> false + +let private constantAt (position: pos) (path: SyntaxVisitorPath) (node: SyntaxNode) = + match node with + | SyntaxNode.SynExpr(SynExpr.Const(range = m) as expr) when + isLiteralConstant expr + && Position.posGeq position m.Start + && Position.posGeq m.End position + && not (isInExcludedContext expr path) + -> + Some(expr, path) + | _ -> None + +let tryConstantAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = + let position = positionOf sourceText selection.Start + + if selection.IsEmpty then + match (position, parseTree) ||> ParsedInput.tryPickLast (constantAt position) with + | Some(expr, path) -> ValueSome(targetOf sourceText expr path) + | None -> ValueNone + else + ValueNone + +let private parenthesizedLambdaHeaderAt (position: pos) (_: SyntaxVisitorPath) (node: SyntaxNode) = + match node with + | SyntaxNode.SynExpr(SynExpr.Paren(expr = SynExpr.Lambda(parsedData = Some _; trivia = { ArrowRange = Some arrow }) as lambda; range = m)) when + Position.posGeq position lambda.Range.Start + && Position.posGeq arrow.End position + -> + Some m + | _ -> None + +let private tryParenthesizedLambdaAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = + let position = positionOf sourceText caret + + match + (position, parseTree) + ||> ParsedInput.tryPickLast (parenthesizedLambdaHeaderAt position) + with + | Some m -> tryExtractionTarget sourceText parseTree (textSpanOf sourceText m) + | None -> ValueNone + +let trySelectedTarget (sourceText: SourceText) (parseTree: ParsedInput) (selection: TextSpan) = + if selection.IsEmpty then + tryParenthesizedLambdaAtCaret sourceText parseTree selection.Start + else + tryExtractionTarget sourceText parseTree selection + let private isFunctionBinding (binding: SynBinding) = match binding with | SynBinding(headPat = SynPat.LongIdent(argPats = SynArgPats.Pats(_ :: _))) -> true | _ -> false -let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: SyntaxNode voption) (limit: int) = +let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (outer: SyntaxVisitorPath) (offsideColumn: int) = match parent, child with | SyntaxNode.SynExpr(SynExpr.Sequential _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse), SyntaxNode.SynExpr expr when isSame letOrUse.Body expr -> @@ -423,54 +480,52 @@ let private anchorOf (child: SyntaxNode) (parent: SyntaxNode) (grandparent: Synt ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynModule(SynModuleDecl.Expr _), SyntaxNode.SynExpr expr -> ValueSome(Anchor.Statement expr.Range.Start) | SyntaxNode.SynBinding(SynBinding(expr = rhs; trivia = trivia) as binding), SyntaxNode.SynExpr expr when isSame rhs expr -> - match grandparent, trivia.EqualsRange with - | ValueSome(SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse)), _ when not letOrUse.IsRecursive && not (isFunctionBinding binding) -> + match outer, trivia.EqualsRange with + | SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse) :: _, _ when not letOrUse.IsRecursive && not (isFunctionBinding binding) -> ValueSome(Anchor.Statement letOrUse.Range.Start) - | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range, limit)) - | _ -> ValueNone - | SyntaxNode.SynMatchClause(SynMatchClause(resultExpr = result; trivia = trivia)), SyntaxNode.SynExpr expr when isSame result expr -> - match trivia.ArrowRange with - | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range, limit)) - | None -> ValueNone - | SyntaxNode.SynExpr(SynExpr.IfThenElse(thenExpr = thenExpr; elseExpr = elseExpr; trivia = trivia)), SyntaxNode.SynExpr expr -> - match elseExpr, trivia.ElseKeyword with - | _ when isSame thenExpr expr -> ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range, limit)) - | Some elseExpr, Some elseKeyword when isSame elseExpr expr -> ValueSome(Anchor.Clause(elseKeyword.End, expr.Range, limit)) + | _, Some equals -> ValueSome(Anchor.Clause(equals.End, expr.Range, offsideColumn)) | _ -> ValueNone - | SyntaxNode.SynExpr(SynExpr.Lambda(parsedData = Some(_, body); trivia = trivia)), SyntaxNode.SynExpr expr when isSame body expr -> - match trivia.ArrowRange with - | Some arrow -> ValueSome(Anchor.Clause(arrow.End, expr.Range, limit)) - | None -> ValueNone + | SyntaxNode.SynMatchClause(SynMatchClause(resultExpr = body; trivia = { ArrowRange = Some arrow })), SyntaxNode.SynExpr expr when + isSame body expr + -> + ValueSome(Anchor.Clause(arrow.End, expr.Range, offsideColumn)) + | SyntaxNode.SynExpr(SynExpr.IfThenElse(thenExpr = body; trivia = trivia)), SyntaxNode.SynExpr expr when isSame body expr -> + ValueSome(Anchor.Clause(trivia.ThenKeyword.End, expr.Range, offsideColumn)) + | SyntaxNode.SynExpr(SynExpr.IfThenElse(elseExpr = Some body; trivia = { ElseKeyword = Some elseKeyword })), SyntaxNode.SynExpr expr when + isSame body expr + -> + ValueSome(Anchor.Clause(elseKeyword.End, expr.Range, offsideColumn)) + | SyntaxNode.SynExpr(SynExpr.Lambda(parsedData = Some(_, body); trivia = { ArrowRange = Some arrow })), SyntaxNode.SynExpr expr when + isSame body expr + -> + ValueSome(Anchor.Clause(arrow.End, expr.Range, offsideColumn)) | _ -> ValueNone -/// The column where the innermost `let`, `match`, `try`, `if` or loop of the path starts. An `if` after `else` continues -/// the `if` it belongs to. -let rec private limitColumn (path: SyntaxVisitorPath) = +let private (|ElifBranch|_|) (path: SyntaxVisitorPath) = match path with - | [] -> 0 - | SyntaxNode.SynExpr(SynExpr.IfThenElse _ as nested) :: (SyntaxNode.SynExpr(SynExpr.IfThenElse(elseExpr = Some elseExpr)) :: _ as rest) when + | SyntaxNode.SynExpr(SynExpr.IfThenElse _ as nested) :: (SyntaxNode.SynExpr(SynExpr.IfThenElse(elseExpr = Some elseExpr)) :: _ as enclosingIf) when isSame elseExpr nested -> - limitColumn rest + Some enclosingIf + | _ -> None + +let rec private offsideColumnOf (path: SyntaxVisitorPath) = + match path with + | [] -> 0 + | ElifBranch enclosingIf -> offsideColumnOf enclosingIf | SyntaxNode.SynBinding(SynBinding(trivia = trivia)) :: _ -> trivia.LeadingKeyword.Range.StartColumn | SyntaxNode.SynExpr(SynExpr.Match(range = m) | SynExpr.MatchBang(range = m) | SynExpr.TryWith(range = m) | SynExpr.TryFinally(range = m) | SynExpr.IfThenElse( range = m) | SynExpr.For(range = m) | SynExpr.ForEach(range = m) | SynExpr.While(range = m)) :: _ -> m.StartColumn - | _ :: rest -> limitColumn rest + | _ :: rest -> offsideColumnOf rest -/// The places a declaration used by the expression can go, innermost first. let anchorsOf (expr: SynExpr) (path: SyntaxVisitorPath) = let rec loop (child: SyntaxNode) (path: SyntaxVisitorPath) = match path with | [] -> [] - | parent :: rest -> - let grandparent = - match rest with - | node :: _ -> ValueSome node - | [] -> ValueNone - - match anchorOf child parent grandparent (limitColumn path) with - | ValueSome anchor -> anchor :: loop parent rest - | ValueNone -> loop parent rest + | parent :: outer -> + match anchorOf child parent outer (offsideColumnOf path) with + | ValueSome anchor -> anchor :: loop parent outer + | ValueNone -> loop parent outer loop (SyntaxNode.SynExpr expr) path @@ -481,24 +536,20 @@ let rec private hasInferredTypeVariable (ty: FSharpType) = not ty.IsMeasureType && ty.GenericArguments |> Seq.exists hasInferredTypeVariable -/// The type the checker gave the parenthesized lambda or `function` the target is, as the source can write it. The -/// checker records no type for one without parentheses. A type with a variable that inference introduced has no name -/// in the source. let tryFunctionTypeText (checkResults: FSharpCheckFileResults) (target: ExtractionTarget) = - let parentheses = + let parenthesizedFunction = match target.Expr, target.Path with | SynExpr.Paren(expr = SynExpr.Lambda _ | SynExpr.MatchLambda _; range = m), _ | (SynExpr.Lambda _ | SynExpr.MatchLambda _), SyntaxNode.SynExpr(SynExpr.Paren(range = m)) :: _ -> ValueSome m | _ -> ValueNone - parentheses + parenthesizedFunction |> ValueOption.bind (fun m -> match checkResults.TryGetCapturedType m, checkResults.TryGetCapturedDisplayContext m with | Some ty, Some displayContext when ty.StripAbbreviations().IsFunctionType && not (hasInferredTypeVariable ty) -> ValueSome(ty.Format displayContext) | _ -> ValueNone) -/// `Literal`, with its namespace where the name alone does not reach the FSharp.Core attribute from the position. let literalAttributeAt (checkResults: FSharpCheckFileResults) (position: pos) = let attribute = checkResults.ProjectContext.GetReferencedAssemblies() @@ -509,174 +560,107 @@ let literalAttributeAt (checkResults: FSharpCheckFileResults) (position: pos) = "global.Microsoft.FSharp.Core.Literal" | _ -> "Literal" -let isLiteralConstant (expr: SynExpr) = - match expr with - | SynExpr.Const((SynConst.Bool _ | SynConst.SByte _ | SynConst.Byte _ | SynConst.Int16 _ | SynConst.UInt16 _ | SynConst.Int32 _ | SynConst.UInt32 _ | SynConst.Int64 _ | SynConst.UInt64 _ | SynConst.Single _ | SynConst.Double _ | SynConst.Decimal _ | SynConst.Char _ | SynConst.String _), - _) -> true - | _ -> false - -/// The literal constant the caret is in or touches. -let tryConstantAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = - let position = positionOf sourceText caret - - let constant = - (position, parseTree) - ||> ParsedInput.tryPickLast (fun path node -> - match node with - | SyntaxNode.SynExpr(SynExpr.Const(range = m) as expr) when - isLiteralConstant expr - && Position.posGeq position m.Start - && Position.posGeq m.End position - && not (isInExcludedContext expr path) - -> - Some(expr, path) - | _ -> None) - - match constant with - | Some(expr, path) -> - let span = textSpanOf sourceText expr.Range - - ValueSome - { - Expr = expr - Path = path - Content = span - Replaced = span - } - | None -> ValueNone - -/// The parenthesized lambda whose `fun ->` the caret is in, as if it were selected with its parentheses. -let tryParenthesizedLambdaAtCaret (sourceText: SourceText) (parseTree: ParsedInput) (caret: int) = - let position = positionOf sourceText caret - - let parenthesized = - (position, parseTree) - ||> ParsedInput.tryPickLast (fun _ node -> - match node with - | SyntaxNode.SynExpr(SynExpr.Paren( - expr = SynExpr.Lambda(parsedData = Some _; trivia = { ArrowRange = Some arrow }) as lambda; range = m)) when - Position.posGeq position lambda.Range.Start - && Position.posGeq arrow.End position - -> - Some m - | _ -> None) - - match parenthesized with - | Some m -> tryExtractionTarget sourceText parseTree (textSpanOf sourceText m) - | None -> ValueNone - -/// The module-level let declaration containing the path, with its first binding. -let tryEnclosingModuleLet (path: SyntaxVisitorPath) = +let private tryEnclosingModuleLet (path: SyntaxVisitorPath) = path |> List.tryPick (function | SyntaxNode.SynModule(SynModuleDecl.Let(bindings = binding :: _; range = m)) -> Some struct (binding, m) | _ -> None) -let private padding (width: int) = String(' ', width) +let tryDeclaration (extraction: Extraction) (header: string) (bodyColumn: int) = + let sourceText = extraction.SourceText + let lines = sourceText.Lines + let extracted = extraction.Target.Extracted -/// `header = `, with a multi-line content starting on its own line at bodyColumn. -let tryDeclaration (sourceText: SourceText) (content: TextSpan) (header: string) (bodyColumn: int) literalLines lineBreak = - tryIndentedText sourceText content bodyColumn literalLines + tryIndentedText extraction bodyColumn |> ValueOption.map (fun body -> - let lines = sourceText.Lines - - if lines.GetLineFromPosition(content.Start).LineNumber = lines.GetLineFromPosition(content.End).LineNumber then + if lines.GetLineFromPosition(extracted.Start).LineNumber = lines.GetLineFromPosition(extracted.End).LineNumber then $"{header} = {body}" else - $"{header} ={lineBreak}{padding bodyColumn}{body}") - -/// Changes that declare `header = ` in front of the anchor and put replacement where the selection was. -/// A body that shares its line with its keyword moves to new lines under that keyword, when only closing brackets -/// and a comment follow it. -let tryDeclareInFront - (sourceText: SourceText) - (target: ExtractionTarget) - (anchor: Anchor) + $"{header} ={extraction.LineBreak}{padding bodyColumn}{body}") + +let private tryInFrontOfLine (extraction: Extraction) (start: pos) (header: string) (replacement: string) = + let line = extraction.SourceText.Lines[Line.toZ start.Line] + + tryDeclaration extraction header (start.Column + extraction.IndentSize) + |> ValueOption.map (fun declaration -> + [ + TextChange(TextSpan(line.Start, 0), $"{padding start.Column}{declaration}{extraction.LineBreak}") + TextChange(extraction.Target.Replaced, replacement) + ]) + +let private linesMovedWithBody (extraction: Extraction) (body: range) = + let lines = extraction.SourceText.Lines + let replaced = extraction.Target.Replaced + + let stays (line: TextLine) = + extraction.LiteralLines.Contains line.LineNumber + || String.IsNullOrWhiteSpace(line.ToString()) + || (line.Start >= replaced.Start && line.Start <= replaced.End) + + [ + for lineNumber in Line.toZ body.StartLine + 1 .. Line.toZ body.EndLine -> lines[lineNumber] + ] + |> List.filter (stays >> not) + +let private tryUnderKeyword + (extraction: Extraction) + (keywordEnd: pos) + (body: range) + (offsideColumn: int) (header: string) (replacement: string) - (indentSize: int) - (literalLines: HashSet) = - let lines = sourceText.Lines - let lineBreak = lineBreakOf sourceText - let replacementChange = TextChange(target.Replaced, replacement) + let sourceText = extraction.SourceText + let keywordLine = sourceText.Lines[Line.toZ keywordEnd.Line] - let inFrontOfLine (start: pos) = - let line = lines[Line.toZ start.Line] + let gap = + TextSpan.FromBounds(keywordLine.Start + keywordEnd.Column, (textSpanOf sourceText body).Start) - tryDeclaration sourceText target.Content header (start.Column + indentSize) literalLines lineBreak - |> ValueOption.map (fun declaration -> - [ - TextChange(TextSpan(line.Start, 0), $"{padding start.Column}{declaration}{lineBreak}") - replacementChange - ]) + let newIndent = + max (leadingSpaces sourceText keywordLine) offsideColumn + extraction.IndentSize - match anchor with - | Anchor.Statement start when isLineLeading sourceText start -> inFrontOfLine start - | Anchor.Clause(body = body) when isLineLeading sourceText body.Start -> inFrontOfLine body.Start - | Anchor.Clause(keywordEnd, body, limit) when keywordEnd.Line = body.StartLine && restOfLineIsClosers sourceText body.End -> - let keywordLine = lines[Line.toZ keywordEnd.Line] - let keywordEndOffset = keywordLine.Start + keywordEnd.Column - let bodySpan = textSpanOf sourceText body - let newIndent = max (leadingSpaces sourceText keywordLine) limit + indentSize - let shift = newIndent - body.StartColumn - - let continuation = - [ - for lineNumber in Line.toZ body.StartLine + 1 .. Line.toZ body.EndLine do - let line = lines[lineNumber] + let shift = newIndent - body.StartColumn + let movedLines = linesMovedWithBody extraction body + + let indentation = + [ + for line in movedLines do + if shift > 0 then + TextChange(TextSpan(line.Start, 0), padding shift) + elif shift < 0 then + TextChange(TextSpan(line.Start, -shift), "") + ] - let keep = - literalLines.Contains lineNumber - || String.IsNullOrWhiteSpace(line.ToString()) - || (line.Start >= target.Replaced.Start && line.Start <= target.Replaced.End) + let canMove = + movedLines + |> List.forall (fun line -> shift >= 0 || leadingSpaces sourceText line >= -shift) - if not keep then - line + if canMove && String.IsNullOrWhiteSpace(sourceText.ToString gap) then + tryDeclaration extraction header (newIndent + extraction.IndentSize) + |> ValueOption.map (fun declaration -> + [ + TextChange(gap, $"{extraction.LineBreak}{padding newIndent}{declaration}{extraction.LineBreak}{padding newIndent}") + yield! indentation + TextChange(extraction.Target.Replaced, replacement) ] + |> List.sortBy _.Span.Start) + else + ValueNone - let movable = - continuation - |> List.forall (fun line -> shift >= 0 || leadingSpaces sourceText line >= -shift) - - let separatedByWhitespace = - String.IsNullOrWhiteSpace(sourceText.ToString(TextSpan.FromBounds(keywordEndOffset, bodySpan.Start))) - - if movable && separatedByWhitespace then - tryDeclaration sourceText target.Content header (newIndent + indentSize) literalLines lineBreak - |> ValueOption.map (fun declaration -> - [ - TextChange( - TextSpan.FromBounds(keywordEndOffset, bodySpan.Start), - $"{lineBreak}{padding newIndent}{declaration}{lineBreak}{padding newIndent}" - ) - - for line in continuation do - if shift > 0 then - TextChange(TextSpan(line.Start, 0), padding shift) - elif shift < 0 then - TextChange(TextSpan(line.Start, -shift), "") - - replacementChange - ] - |> List.sortBy _.Span.Start) - else - ValueNone +let tryDeclareInFront (extraction: Extraction) (anchor: Anchor) (header: string) (replacement: string) = + let sourceText = extraction.SourceText + + match anchor with + | Anchor.Statement start when isLineLeading sourceText start -> tryInFrontOfLine extraction start header replacement + | Anchor.Clause(body = body) when isLineLeading sourceText body.Start -> tryInFrontOfLine extraction body.Start header replacement + | Anchor.Clause(keywordEnd, body, offsideColumn) when keywordEnd.Line = body.StartLine && onlyClosersFollow sourceText body.End -> + tryUnderKeyword extraction keywordEnd body offsideColumn header replacement | _ -> ValueNone -/// Changes that declare `header = `, preceded by the attribute lines, in front of the module-level let -/// containing the selection, and put replacement where the selection was. Unavailable when that let does not start its -/// line, as in `module M = let f () = 1`. -let tryDeclareInFrontOfModuleLet - (sourceText: SourceText) - (target: ExtractionTarget) - (attributes: string list) - (header: string) - (replacement: string) - (indentSize: int) - (literalLines: HashSet) - = - match tryEnclosingModuleLet target.Path with +let tryDeclareInFrontOfModuleLet (extraction: Extraction) (attributes: string list) (header: string) (replacement: string) = + let sourceText = extraction.SourceText + + match tryEnclosingModuleLet extraction.Target.Path with | Some(struct (SynBinding(xmlDoc = xmlDoc), declaration)) when isLineLeading sourceText declaration.Start -> let firstLine = if xmlDoc.IsEmpty then @@ -686,17 +670,62 @@ let tryDeclareInFrontOfModuleLet let line = sourceText.Lines[Line.toZ firstLine] let indent = leadingSpaces sourceText line - let lineBreak = lineBreakOf sourceText - tryDeclaration sourceText target.Content header (indent + indentSize) literalLines lineBreak + tryDeclaration extraction header (indent + extraction.IndentSize) |> ValueOption.map (fun declaration -> let attributeLines = attributes - |> List.map (fun attribute -> $"{padding indent}{attribute}{lineBreak}") + |> List.map (fun attribute -> $"{padding indent}{attribute}{extraction.LineBreak}") |> String.concat "" [ - TextChange(TextSpan(line.Start, 0), $"{attributeLines}{padding indent}{declaration}{lineBreak}{lineBreak}") - TextChange(target.Replaced, replacement) + TextChange( + TextSpan(line.Start, 0), + $"{attributeLines}{padding indent}{declaration}{extraction.LineBreak}{extraction.LineBreak}" + ) + TextChange(extraction.Target.Replaced, replacement) ]) | _ -> ValueNone + +let prepareExtraction + (context: CodeRefactoringContext) + (provider: string) + (sourceText: SourceText) + (parseTree: ParsedInput) + (target: ExtractionTarget) + = + cancellableTask { + let document = context.Document + let! options = document.GetOptionsAsync context.CancellationToken + let! defines, langVersion = document.GetFsharpParsingOptionsAsync provider + let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync provider + + return + { + Context = context + Provider = provider + SourceText = sourceText + Target = target + CheckResults = checkResults + IndentSize = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) + LiteralLines = linesInsideLiterals sourceText parseTree defines langVersion + LineBreak = lineBreakOf sourceText + Names = usedNames parseTree + } + } + +let registerRefactoring (extraction: Extraction) (title: string) (kind: string) (changes: TextChange list voption) = + match changes with + | ValueSome changes -> + let changedDocument = + cancellableTask { + TelemetryReporter.ReportSingleEvent( + TelemetryEvents.RefactoringActivated, + [| "name", box extraction.Provider; "kind", box kind |] + ) + + return extraction.Context.Document.WithText(extraction.SourceText.WithChanges changes) + } + + extraction.Context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + | ValueNone -> () diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index ee91306e8c9..71f5b51cb2b 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -111,30 +111,6 @@ Navrhnout názvy pro nerozpoznané identifikátory; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Zobrazit poznámky v Rychlých informacích Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf index 3da69d80fff..bfd4d5f6add 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -111,30 +111,6 @@ Namen für nicht aufgelöste Bezeichner vorschlagen; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Hinweise in QuickInfo anzeigen Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index 87c04dc4893..900ee0ec482 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -111,30 +111,6 @@ Sugerir nombres para identificadores sin resolver; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Mostrar comentarios en Información rápida Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index 34eac7d40be..811ede1106a 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -111,30 +111,6 @@ Suggérer des noms pour les identificateurs non résolus ; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Afficher les notes dans Info express Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index 13485b7fbeb..ef7ca7af878 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -111,30 +111,6 @@ Suggerisci i nomi per gli identificatori non risolti; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Mostra i commenti in Informazioni rapide Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index 0a1c6d80bf6..a45f15bbf09 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -111,30 +111,6 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ F# 構文規則に準拠した改行を追加して、署名を指定された Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index 641d1532bbd..f5a379a170a 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -111,30 +111,6 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ F# 구문 규칙에 맞는 줄 바꿈을 추가하여 지정된 너비에 시그 Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index b0a39958122..dbccfef8199 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -111,30 +111,6 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Pokaż uwagi w szybkich informacjach Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf index 7aa3304364b..da075248b75 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf @@ -111,30 +111,6 @@ Sugerir nomes para identificadores não resolvidos; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Mostrar os comentários nas Informações Rápidas Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 820d1a83b65..5984019f246 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -111,30 +111,6 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Show remarks in Quick Info Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index 6a5e5538f2d..ca968c2398a 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -111,30 +111,6 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Açıklamaları Hızlı Bilgide göster Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf index a95893afe2f..4292a58b8a1 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf @@ -111,30 +111,6 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Show remarks in Quick Info Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf index 7f4c4d80ff3..48cd50dd07e 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf @@ -111,30 +111,6 @@ Suggest names for unresolved identifiers; {0} in {1} {2} Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. - - Extract to let binding - Extract to let binding - - - - Extract to literal - Extract to literal - - - - Extract to local function - Extract to local function - - - - Extract to module function - Extract to module function - - - - Extract to private member - Extract to private member - F# Disposable Values (locals) @@ -474,6 +450,21 @@ Show remarks in Quick Info Extract to literal + + Extract to local function + Extract to local function + + + + Extract to module function + Extract to module function + + + + Extract to private member + Extract to private member + + \ No newline at end of file diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs index a53e01a46e2..b0764e21902 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ExtractLetBindingTests.fs @@ -1,9 +1,5 @@ module FSharp.Editor.Tests.Refactors.ExtractLetBindingTests -open System - -open Microsoft.CodeAnalysis.Text - open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks @@ -12,40 +8,67 @@ open Xunit open FSharp.Editor.Tests.Helpers open FSharp.Editor.Tests.Refactors.RefactorTestFramework +[] let private extractToLetBinding = "Extract to let binding" + +[] let private extractToLiteral = "Extract to literal" -let private selectionOf (code: string) (selected: string) = - TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) +[] +let private bothTitles = extractToLetBinding + ";" + extractToLiteral -let private caretAt (code: string) (marker: string) = - TextSpan(code.IndexOf(marker, StringComparison.Ordinal), 0) +[] +let private area = + """ +let area w h = + printfn "%d" (w * h + 1) +""" -let private extractedAt (title: string) (code: string) (span: TextSpan) = - use context = TestContext.CreateWithCode code +[] +let private greet = + """ +let greet name = + printfn "Hello %s" name +""" + +[] +let private greetWithLiteral = + """ +[] +let ExtractedConstant = "Hello %s" - let document = - refactorSpan code span title context (new FSharpExtractLetBindingRefactoring()) +let greet name = + printfn ExtractedConstant name +""" - let parseResults = - document.GetFSharpParseResultsAsync "test" - |> CancellableTask.runSynchronouslyWithoutCancellation +[] +let private moduleConstant = + """ +let f () = g 42 +""" - Assert.Empty(parseResults.Diagnostics) - (document.GetTextAsync() |> GetTaskResult).ToString() +[] +let private mapIncrement = + """ +let f xs = + xs |> List.map (fun x -> x + 1) +""" -let private extracted (title: string) (code: string) (selected: string) = - extractedAt title code (selectionOf code selected) +[] +let private mapIncrementExtracted = + """ +let f xs = + let extracted: int -> int = fun x -> x + 1 + xs |> List.map extracted +""" -let private titlesAt (code: string) (span: TextSpan) = +let private extractedAt title code span = use context = TestContext.CreateWithCode code + refactoredText (new FSharpExtractLetBindingRefactoring()) context title code span - tryGetRefactoringActionsForSpan code span context (new FSharpExtractLetBindingRefactoring()) - |> Seq.map _.Title - |> Seq.toList - -let private titlesFor (code: string) (selected: string) = - titlesAt code (selectionOf code selected) +let private titlesAt code span = + use context = TestContext.CreateWithCode code + offeredTitles (new FSharpExtractLetBindingRefactoring()) context code span let private assertTypeChecks (code: string) = use context = TestContext.CreateWithCode code @@ -56,59 +79,40 @@ let private assertTypeChecks (code: string) = Assert.Empty(checkResults.Diagnostics) -let private area = - """ -module M - -let area w h = - printfn "%d" (w * h + 1) -""" - [] -[] -[] -let ``Expression in a statement is bound in front of it`` (selected: string, expected: string) = - Assert.Equal(expected, extracted extractToLetBinding area selected) - -[] -let ``Parentheses of a method call stay`` () = - let code = - """ -module M - +[] +[ List.filter (fun i -> i > acc) |> List.sum total -""" - -[] -let ``Multi-line right-hand side is bound in front of its let`` () = - let expected = - """ -module M - +""", + """items + |> List.filter (fun i -> i > acc) + |> List.sum""", + """ let run items = let mutable acc = 0 let extracted = @@ -133,22 +134,10 @@ let run items = let total = extracted total -""" - - let start = run.LastIndexOf("items", StringComparison.Ordinal) - - let finish = - run.IndexOf("|> List.sum", start, StringComparison.Ordinal) - + "|> List.sum".Length - - Assert.Equal(expected, extractedAt extractToLetBinding run (TextSpan.FromBounds(start, finish))) - -[] -let ``Whole lines selected with their indentation and line break are extracted`` () = - let code = - """ -module M - +""")>] +[ List.sum total -""" - - let expected = - """ -module M - +""", + """ items + |> List.filter (fun i -> i > acc) + |> List.sum +""", + """ let run items = let mutable acc = 0 @@ -175,133 +164,85 @@ let run items = extracted total -""" - - let lineStart = code.LastIndexOf(" items", StringComparison.Ordinal) - - let afterLineBreak = - code.IndexOf('\n', code.IndexOf("|> List.sum", lineStart, StringComparison.Ordinal)) - + 1 - - Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan.FromBounds(lineStart, afterLineBreak))) - -[] -let ``Match clause body on the arrow line moves to its own lines`` () = - let code = - """ -module M - +""")>] +[ v * 2 + offset | None -> 0 -""" - - let expected = - """ -module M - +""", + "v * 2 + offset", + """ let f x offset = match x with | Some v -> let extracted = v * 2 + offset extracted | None -> 0 -""" - - Assert.Equal(expected, extracted extractToLetBinding code "v * 2 + offset") - -[] -let ``Right-hand side on the equals line keeps its trailing comment`` () = - let code = - """ -module M - +""")>] +[] -let ``Expression in a computation expression is bound in front of its statement`` () = - let code = - """ -module M - +""")>] +[ List.length } -""" - - let expected = - """ -module M - +""", + "parse raw |> List.length", + """ let load id = async { let! raw = fetch id let extracted = parse raw |> List.length return extracted } -""" - - Assert.Equal(expected, extracted extractToLetBinding code "parse raw |> List.length") - -[] -let ``Lambda body on the arrow line moves to its own lines`` () = - let code = - """ -module M - +""")>] +[ List.map (fun x -> x + 1) -""" - - let expected = - """ -module M - +""", + "x + 1", + """ let r = xs |> List.map (fun x -> let extracted = x + 1 extracted) -""" - - Assert.Equal(expected, extracted extractToLetBinding code "x + 1") - -[] -[] +[ 1 + 2 """, "1 + 2", """ -module M - let f x = match x with _ -> let extracted = 1 + 2 extracted """)>] -[] -[] -let ``Body moved under its keyword is indented from the construct that starts inside the line`` - (code: string, selected: string, expected: string) - = - Assert.Equal(expected, extracted extractToLetBinding code selected) - -[] -let ``Expression in a finally block is bound inside the block`` () = - let code = - """ -module M - +[] -let ``Name does not collide with an existing identifier`` () = - let code = - """ -module M - +""")>] +[] +[] +[] +[] +[] +[] +let ExtractedConstant = -1 -[] -let ``Line breaks of the file are kept`` () = - let code = "module M\r\n\r\nlet f x =\r\n printfn \"%d\" (x + 1)\r\n" +let f () = g ExtractedConstant +""")>] +let ``Selection is extracted`` (_scenario: string, title: string, code: string, selected: string, expected: string) = + let code = fileOf code + Assert.Equal(fileOf expected, extractedAt title code (selectionOf code selected)) - let expected = - "module M\r\n\r\nlet f x =\r\n let extracted = x + 1\r\n printfn \"%d\" (extracted)\r\n" +[] +[] +let ExtractedConstant = 1.5m - Assert.Equal(expected, extracted extractToLetBinding code "x + 1") - -[] -let ``Lines inside a multi-line string are not re-indented`` () = - // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. - let code = - "module M\n\nlet f name =\n printfn \"%s\" (String.Format(\"\"\"Hello\n{0}\"\"\", name))\n" - - let expected = - "module M\n\nlet f name =\n let extracted =\n String.Format(\"\"\"Hello\n{0}\"\"\", name)\n printfn \"%s\" (extracted)\n" - - Assert.Equal(expected, extracted extractToLetBinding code "String.Format(\"\"\"Hello\n{0}\"\"\", name)") - -[] -let ``Lines inside a multi-line string in an inactive branch are not re-indented`` () = - // The code contains a triple-quoted string, which a triple-quoted literal cannot hold. - let code = - "module M\n\nlet r = combine 1 [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" - - let expected = - "module M\n\nlet r =\n let extracted = 1\n combine extracted [\n #if ALT\n \"\"\"first\n preserved\n last\"\"\"\n #else\n \"\"\n #endif\n ]\n" - - Assert.Equal(expected, extractedAt extractToLetBinding code (TextSpan(code.IndexOf("1 [", StringComparison.Ordinal), 1))) - -[] -[] -[] -[] -let ``Name does not collide with an assignment target from another file`` (declaration: string, assignment: string) = - let dependency = - """ -module Dependency - -type Box = { mutable Value: int } - -DECLARATION -""" - .Replace("DECLARATION", declaration) - - let code = - """ -module M - -open Dependency - -let f () = - ASSIGNMENT({ Value = 1 }).Value -""" - .Replace("ASSIGNMENT", assignment) - - let expected = - """ -module M - -open Dependency - -let f () = - let extracted1 = { Value = 1 } - ASSIGNMENTextracted1.Value -""" - .Replace("ASSIGNMENT", assignment) - - use context = TestContext.CreateWithCodeAndDependency code dependency - - let document = - refactorSpan code (selectionOf code "({ Value = 1 })") extractToLetBinding context (new FSharpExtractLetBindingRefactoring()) - - Assert.Equal(expected, (document.GetTextAsync() |> GetTaskResult).ToString()) - -let private greet = - """ -module M - -let greet name = - printfn "Hello %s" name -""" - -let private greetWithLiteral = - """ -module M - -[] -let ExtractedConstant = "Hello %s" - -let greet name = - printfn ExtractedConstant name -""" - -[] -let ``Constant in a module-level declaration becomes a literal in front of it`` () = - Assert.Equal(greetWithLiteral, extracted extractToLiteral greet "\"Hello %s\"") - -[] -let ``Negative number becomes a literal`` () = - let code = - """ -module M - -let f () = g -1 -""" - - let expected = - """ -module M - -[] -let ExtractedConstant = -1 - -let f () = g ExtractedConstant -""" - - Assert.Equal(expected, extracted extractToLiteral code "-1") - -let private moduleConstant = - """ -module M - -let f () = g 42 -""" - -[] -let ``Literal is offered only for constants outside types`` () = - let memberConstant = - """ -module M - -type T() = - member _.M() = 42 -""" - - let expression = - """ -module M - -let f x = g (x + 1) -""" - - Assert.Equal([ extractToLetBinding; extractToLiteral ], titlesFor moduleConstant "42") - Assert.Equal([ extractToLetBinding ], titlesFor memberConstant "42") - Assert.Equal([ extractToLetBinding ], titlesFor expression "x + 1") - -[] -let ``Decimal constant becomes a literal`` () = - let code = - """ -module M - -let f () = id 1.5m -""" - - let expected = - """ -module M - -[] -let ExtractedConstant = 1.5m - -let f () = id ExtractedConstant -""" - - let result = extracted extractToLiteral code "1.5m" - Assert.Equal(expected, result) - assertTypeChecks result - -[] -[] -[] -let ``Native integer does not become a literal`` (constant: string) = - let code = moduleConstant.Replace("42", constant) - - Assert.Equal([ extractToLetBinding ], titlesFor code constant) - Assert.Empty(titlesAt code (caretAt code constant)) - -[] -let ``Literal attribute is qualified where another LiteralAttribute hides it`` () = - let code = - """ -module M - -type LiteralAttribute(required: int) = - inherit System.Attribute() +let f () = id ExtractedConstant +""")>] +[] -let ``Literal is not offered where the declaration does not start its line`` () = - let code = - """ -namespace N -module M = let f () = 42 -""" - - Assert.Equal([ extractToLetBinding ], titlesFor code "42") - Assert.Empty(titlesAt code (caretAt code "42")) - -[] -let ``Operand of nameof is not extracted where the nameof expression is`` () = - let code = - """ -module M - -let name = nameof System.String.Empty -""" - - Assert.Empty(titlesFor code "System.String.Empty") - Assert.Equal([ extractToLetBinding ], titlesFor code "nameof System.String.Empty") - -let private mapIncrement = - """ -module M - -let f xs = - xs |> List.map (fun x -> x + 1) -""" - -[] -[] -[")>] -[ x")>] -[] -let ``Caret in the header of a parenthesized lambda extracts the lambda`` (marker: string) = - let expected = - """ -module M - -let f xs = - let extracted: int -> int = fun x -> x + 1 - xs |> List.map extracted -""" - - Assert.Equal([ extractToLetBinding ], titlesAt mapIncrement (caretAt mapIncrement marker)) - Assert.Equal(expected, extractedAt extractToLetBinding mapIncrement (caretAt mapIncrement marker)) - -[] -[] +[ int = (fun x -> x.Length) """, "(fun x -> x.Length)", """ -module M - let f: string -> int = let extracted: string -> int = fun x -> x.Length extracted """)>] -[ List.map (fun x -> [ x ]) """, "(fun x -> [ x ])", """ -module M - let f (xs: 'T list) = let extracted: 'T -> 'T list = fun x -> [ x ] xs |> List.map extracted """)>] -[ List.map (fun x -> [ x ]) """, "(fun x -> [ x ])", """ -module M - let f xs = let extracted = fun x -> [ x ] xs |> List.map extracted """)>] -[ int = (function Some x -> x.Length | None -> 0) """, "(function Some x -> x.Length | None -> 0)", """ -module M - let f: string option -> int = let extracted: string option -> int = function Some x -> x.Length | None -> 0 extracted """)>] -[ List.map (fun x -> x.Length) """, "fun x -> x.Length", """ -module M - let f (xs: string list) = let extracted: string -> int = fun x -> x.Length xs |> List.map (extracted) """)>] -[) = xs.ConvertAll(fun x -> x.Length) """, "(fun x -> x.Length)", """ -module M - let f (xs: System.Collections.Generic.List) = let extracted: string -> int = fun x -> x.Length xs.ConvertAll(extracted) """)>] -let ``Extracted function expression keeps the type its context gave it`` (code: string, selected: string, expected: string) = - let result = extracted extractToLetBinding code selected +let ``Extracted code type checks`` (_scenario: string, title: string, code: string, selected: string, expected: string) = + let code = fileOf code + let result = extractedAt title code (selectionOf code selected) - Assert.Equal(expected, result) + Assert.Equal(fileOf expected, result) assertTypeChecks result [] -[] -[] -[] -let ``No action without a selection outside a parenthesized lambda header`` (marker: string) = - Assert.Empty(titlesAt mapIncrement (caretAt mapIncrement marker)) - -[] -let ``No action without a selection on a lambda without parentheses`` () = - let code = +[] +[", mapIncrementExtracted)>] +[ x", mapIncrementExtracted)>] +[] +[] +[] +[] +let ``Caret extracts the lambda or constant around it`` (_scenario: string, title: string, code: string, marker: string, expected: string) = + let code = fileOf code + Assert.Equal(fileOf expected, extractedAt title code (caretAt code marker)) + +[] +[] +[] +[] +let ``Name does not collide with an assignment target from another file`` (_scenario: string, declaration: string, assignment: string) = + let dependency = """ -module M +module Dependency -let f = fun x -> x + 1 -""" +type Box = { mutable Value: int } - Assert.Empty(titlesAt code (caretAt code "fun")) +DECLARATION +""" + .Replace("DECLARATION", declaration) -[] -[] -[] -[] -let ``Constant at the caret becomes a literal without a selection`` (marker: string, offset: int) = - let caret = TextSpan(greet.IndexOf(marker, StringComparison.Ordinal) + offset, 0) - Assert.Equal(greetWithLiteral, extractedAt extractToLiteral greet caret) + let code = + """ +module M -[] -let ``Only the literal is offered without a selection`` () = - let caret = TextSpan(moduleConstant.IndexOf("42", StringComparison.Ordinal) + 1, 0) +open Dependency - Assert.Equal([ extractToLiteral ], titlesAt moduleConstant caret) +let f () = + ASSIGNMENT({ Value = 1 }).Value +""" + .Replace("ASSIGNMENT", assignment) -[] -[] -[] -[] -let ``No action without a selection`` (code: string, marker: string) = - Assert.Empty(titlesAt code (caretAt code marker)) + use context = TestContext.CreateWithCodeAndDependency code dependency -[] -[] +[] +[] -[] +[] +[] +[] +[] -[] +[] +[] +[] +[] +[] -[] +[] -[] +[ 0 -> v | _ -> 0 """, - "v > 0")>] -[] -[ 0", + "")>] +[] +[] -[] -[] -[ 0 -""", - "s.Length > 0")>] -[] +[] +[] +[ 0", "s.Length > 0", "")>] +[] -[] +[] type S = val mutable X: int @@ -857,19 +566,49 @@ let f () = (s).Inc() s.X """, - "(s)")>] -[] +[] -[] +[] +let ``Actions offered for a selection`` (_scenario: string, code: string, selected: string, titles: string) = + let code = fileOf code + Assert.Equal(titles, titlesAt code (selectionOf code selected)) -let f x = x + 1 +[] +[] +[", extractToLetBinding)>] +[ x", extractToLetBinding)>] +[] +[] +[] +[] +[] +[ x + 1", "fun", "")>] +[] +[] +[] +[] +[] +[] -let ``No action`` (code: string, selected: string) = Assert.Empty(titlesFor code selected) +let ``Actions offered at a caret`` (_scenario: string, code: string, marker: string, titles: string) = + let code = fileOf code + Assert.Equal(titles, titlesAt code (caretAt code marker)) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs index cd385130efe..2a75ccb9085 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs @@ -1,13 +1,15 @@ -module FSharp.Editor.Tests.Refactors.RefactorTestFramework +module FSharp.Editor.Tests.Refactors.RefactorTestFramework open System -open System.Linq open System.Collections.Generic open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks +open Xunit + open FSharp.Test.ProjectGeneration open FSharp.Editor.Tests.Helpers @@ -98,7 +100,7 @@ let tryGetRefactoringActions (code: string) (cursorPosition) (context: TestConte |> CancellableTask.startWithoutCancellation |> fun task -> task.Result -let private refactoringActionsAt (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: CodeRefactoringProvider) = +let tryGetRefactoringActionsForSpan (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: CodeRefactoringProvider) = let actions = List() let existingDocument = RoslynTestHelpers.GetLastDocument context.Solution context.Solution <- context.Solution.WithDocumentText(existingDocument.Id, SourceText.From(code)) @@ -110,12 +112,9 @@ let private refactoringActionsAt (code: string) (span: TextSpan) (context: TestC refactorProvider.ComputeRefactoringsAsync(refactoringContext).GetAwaiter().GetResult() actions -let tryGetRefactoringActionsForSpan (code: string) (span: TextSpan) (context: TestContext) (refactorProvider: #CodeRefactoringProvider) = - refactoringActionsAt code span context refactorProvider - -let refactorSpan (code: string) (span: TextSpan) (title: string) (context: TestContext) (refactorProvider: #CodeRefactoringProvider) = +let refactorSpan (code: string) (span: TextSpan) (title: string) (context: TestContext) (refactorProvider: CodeRefactoringProvider) = let action = - refactoringActionsAt code span context refactorProvider + tryGetRefactoringActionsForSpan code span context refactorProvider |> Seq.find (fun action -> String.Equals(action.Title, title, StringComparison.Ordinal)) for operation in action.GetOperationsAsync(context.CancellationToken) |> GetTaskResult do @@ -124,3 +123,37 @@ let refactorSpan (code: string) (span: TextSpan) (title: string) (context: TestC context.Solution <- applyChanges.ChangedSolution RoslynTestHelpers.GetLastDocument context.Solution + +let selectionOf (code: string) (selected: string) = + TextSpan(code.IndexOf(selected, StringComparison.Ordinal), selected.Length) + +let caretAt (code: string) (marker: string) = + let offset = max 0 (marker.IndexOf '|') + TextSpan(code.IndexOf(marker.Replace("|", ""), StringComparison.Ordinal) + offset, 0) + +let fileOf (declarations: string) = + let text = declarations.Trim('\r', '\n') + + if + text.StartsWith("module ", StringComparison.Ordinal) + || text.StartsWith("namespace ", StringComparison.Ordinal) + then + declarations + else + let lineBreak = if declarations.IndexOf '\r' >= 0 then "\r\n" else "\n" + $"{lineBreak}module M{lineBreak}{lineBreak}{text}{lineBreak}" + +let refactoredText (provider: CodeRefactoringProvider) (context: TestContext) (title: string) (code: string) (span: TextSpan) = + let document = refactorSpan code span title context provider + + let parseResults = + document.GetFSharpParseResultsAsync "test" + |> CancellableTask.runSynchronouslyWithoutCancellation + + Assert.Empty(parseResults.Diagnostics) + (document.GetTextAsync() |> GetTaskResult).ToString() + +let offeredTitles (provider: CodeRefactoringProvider) (context: TestContext) (code: string) (span: TextSpan) = + tryGetRefactoringActionsForSpan code span context provider + |> Seq.map _.Title + |> String.concat ";"