From d9aed63e7434caf2f43ed235f0ad946384dbe7bf Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 16 Oct 2026 18:44:36 +0200 Subject: [PATCH 1/2] Fix record type rename and verify editor dependency builds Keep record and opaque type rename edits on identifier tokens without changing Find All References discovery. Cover copy/update expressions, signatures, dependent projects, lexical edge cases, and both compiler/search modes. Resolve the CODE-QUALITY verification blocker with a complete Release compiler/editor dependency build. Document pinned-SDK host selection, desktop test utility targets, consistent bootstrap-redirection settings for build and test, and the SDK-hosted XAML source-embedding option. No architecture or reuse defect was identified in the reviewed production change. Validation: Release FSharp.slnx and FSharp.Editor.Tests builds succeeded with zero errors and BuildProjectReferences=true, using the exact SDK-hosted recipe in DEVGUIDE.md. All 46 editor rename/FAR tests and all 69 FCS FindReferences tests passed, including all 43 added cases. dotnet fantomas . completed without changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DEVGUIDE.md | 19 + docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../FSharpChecker/FindReferences.fs | 67 ++ .../InlineRename/InlineRenameService.fs | 49 +- .../FSharp.Editor.Tests.fsproj | 2 + .../Helpers/RoslynHelpers.fs | 6 +- .../FSharp.Editor.Tests/InlineRenameTests.fs | 954 ++++++++++++++++++ 7 files changed, 1094 insertions(+), 4 deletions(-) create mode 100644 vsintegration/tests/FSharp.Editor.Tests/InlineRenameTests.fs diff --git a/DEVGUIDE.md b/DEVGUIDE.md index 0af0c55e85c..d65cd45faa3 100644 --- a/DEVGUIDE.md +++ b/DEVGUIDE.md @@ -164,6 +164,25 @@ You can find all test options as separate flags. For example `build -testAll`: Running any of the above will build the latest changes and run tests against them. +### Windows editor spot checks with the SDK compiler + +In a separate PowerShell session, use the repository-local SDK and keep desktop targets enabled. +`BUILDING_USING_DOTNET=true` excludes `net472` from the editor's test utilities. +Build the compiler solution first: the editor test fixture also requires the desktop `fsc` and `fsi` tools. + +```powershell +$env:DOTNET_ROOT = (Resolve-Path .\.dotnet).Path +$env:BUILDING_USING_DOTNET = "false" +$compilerArgs = @("-p:DisableCompilerRedirection=true", "-p:DisableAutoSetFscCompilerPath=false") +.\.dotnet\dotnet.exe build .\FSharp.slnx -c Release @compilerArgs +.\.dotnet\dotnet.exe build .\vsintegration\tests\FSharp.Editor.Tests\FSharp.Editor.Tests.fsproj -c Release @compilerArgs -p:EmbedUntrackedSources=false +.\.dotnet\dotnet.exe test --project .\vsintegration\tests\FSharp.Editor.Tests\FSharp.Editor.Tests.fsproj -c Release --no-build @compilerArgs --filter-class "*InlineRenameTests*" "*FindReferencesTests*" +``` + +Pass the compiler arguments to both build and test, even with `--no-build`, so neither imports a +stale or incomplete `artifacts\Bootstrap` toolset. Disabling untracked-source embedding avoids +generated XAML source-path errors in the SDK-hosted WPF build; all project references still build. + ## Using your custom compiler to build this repository By removing all the subfolders called `Bootstrap` or `Proto` under `artifacts` and running the `build` script again, the proto compiler will include your changes. diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 8f47e2e0dbe..9a4b1fd010a 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -10,6 +10,7 @@ * Go To Definition no longer blocks the UI thread with a bare `Task.Wait`: the synchronous `IFSharpGoToDefinitionService` call now waits through the cancellable threaded-wait dialog, and the editor's `TaskCompletionSource` bridges run their continuations on the thread pool instead of inline on whichever thread finished the check, so repeated F12 on a large solution no longer starves semantic classification and other main-thread work. ([PR #20482](https://github.com/dotnet/fsharp/pull/20482)) * Peek Definition on an F# symbol whose definition lives in metadata no longer deadlocks Visual Studio. Peek holds the main thread in `JoinableTaskFactory.Run` without pumping messages while it asks the language service for the definition, and generating the metadata document needs that same thread; Peek now stops at definitions that already have a document, and Go To Definition, which owns the wait it makes, still opens the generated one. ([PR #20503](https://github.com/dotnet/fsharp/pull/20503)) * Improve Find All References performance by throttling parallel typechecks. ([PR #20128](https://github.com/dotnet/fsharp/pull/20128), [PR #20695](https://github.com/dotnet/fsharp/pull/20695)) +* Fix record-type Rename corrupting copy-and-update expressions, including from abstract signatures, while retaining Find All References results. ([Issue #20791](https://github.com/dotnet/fsharp/issues/20791)) * Fixed Rename incorrectly renaming `get` and `set` keywords for properties with explicit accessors. ([Issue #18270](https://github.com/dotnet/fsharp/issues/18270), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Fixed Find All References crash when F# project contains non-F# files like `.cshtml`. ([Issue #16394](https://github.com/dotnet/fsharp/issues/16394), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Avoid using `cancellableTask` in `DocumentCache`; the editor cache now uses direct `CancellationToken`-aware `task` wrappers, avoiding the background `Task.Run` offload and a larger wrapper closure from the `cancellableTask` builder. ([Issue #20268](https://github.com/dotnet/fsharp/issues/20268)) diff --git a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/FindReferences.fs b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/FindReferences.fs index d4a070d51f3..367dff43677 100644 --- a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/FindReferences.fs +++ b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/FindReferences.fs @@ -900,6 +900,73 @@ module EventHandlerSyntheticSymbols = /// https://github.com/dotnet/fsharp/issues/15290 module RecordCopyAndUpdate = + [] + [] + [] + [] + [] + let ``Issue 20791 - record references distinguish type tokens and semantic expressions`` (fast, transparent) : Task = + task { + let source = """module Main +type State = { Count: int } +type Outer = { Inner: State } +let state = { Count = 0 } +let update (state: State) = { state with Count = state.Count + 1 } +let outer = { Inner = state } +let nested = { outer with Inner.Count = 2 } +let explicitNested = { outer with Inner = { state with Count = 3 } } +""" + let project = + { SyntheticProject.Create({ sourceFile "Main" [] with Source = source }) with AutoAddModules = false } + let checker = + FSharpChecker.Create( + useTransparentCompiler = transparent, + enableBackgroundItemKeyStoreAndSemanticClassification = true, + captureIdentifiersWhenParsing = true) + let! _ = SaveAndCheckProject project checker false + let! results = checkFile "Main" project checker + let check = getTypeCheckResult results + let symbol = check.GetSymbolUseAtLocation(2, 10, "type State = { Count: int }", ["State"]).Value.Symbol + let text = SourceText.ofString source + let describe (range: range) = + range.StartLine, range.StartColumn, range.EndLine, range.EndColumn, text.GetSubTextFromRange range + let ordinary = check.GetUsesOfSymbolInFile(symbol, relatedSymbolKinds = RelatedSymbolUseKind.None) + let related = check.GetUsesOfSymbolInFile(symbol, relatedSymbolKinds = RelatedSymbolUseKind.CopyAndUpdateRecord) + let expectedOrdinary = + [|2, 5, 2, 10, "State"; 3, 22, 3, 27, "State"; 5, 19, 5, 24, "State"|] + let expectedRelated = + [|yield! expectedOrdinary + 5, 28, 5, 66, "{ state with Count = state.Count + 1 }" + 8, 42, 8, 66, "{ state with Count = 3 }"|] + for use' in related do + Assert.True(symbol.IsEffectivelySameAs use'.Symbol) + let outer = check.GetSymbolUseAtLocation(3, 10, "type Outer = { Inner: State }", ["Outer"]).Value.Symbol + Assert.Equal<(int * int * int * int * string) array>( + [|3, 5, 3, 10, "Outer" + 7, 13, 7, 43, "{ outer with Inner.Count = 2 }" + 8, 21, 8, 68, "{ outer with Inner = { state with Count = 3 } }"|], + check.GetUsesOfSymbolInFile(outer, relatedSymbolKinds = RelatedSymbolUseKind.All) + |> Array.map (fun use' -> describe use'.Range) + |> Array.sort) + Assert.Equal<(int * int * int * int * string) array>( + expectedOrdinary, + ordinary |> Array.map (fun use' -> describe use'.Range) |> Array.sort) + Assert.Equal<(int * int * int * int * string) array>( + expectedRelated, related |> Array.map (fun use' -> describe use'.Range) |> Array.sort) + let options = project.GetProjectOptions checker + let! background = + if transparent then + async { + let! snapshot = FSharpProjectSnapshot.FromOptions(options, getFileSnapshot project) + return! checker.FindBackgroundReferencesInFile(project.GetFilePath "Main", snapshot, symbol) + } + else + checker.FindBackgroundReferencesInFile(project.GetFilePath "Main", options, symbol, fastCheck = fast) + Assert.Equal<(int * int * int * int * string) array>( + related |> Array.map (fun use' -> describe use'.Range) |> Array.sort, + background |> Seq.map describe |> Seq.sort |> Seq.toArray) + } + [] let ``Find references of record type includes copy-and-update`` () = let source = """ diff --git a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs b/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs index 8a5debfdc84..04a8566be64 100644 --- a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs +++ b/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs @@ -102,6 +102,12 @@ type internal InlineRenameInfo let symbolDisplayName = symbolUse.Symbol.DisplayName + let recordTypeName = + match symbolUse.Symbol with + | :? FSharpEntity as entity when entity.IsFSharpRecord || entity.IsOpaque -> + ValueSome(FSharpKeywords.NormalizeIdentifierBackticks symbolDisplayName) + | _ -> ValueNone + override _.CanRename = true override _.LocalizedErrorMessage = null override _.TriggerSpan = triggerSpan @@ -160,12 +166,51 @@ type internal InlineRenameInfo let document = document.Project.Solution.GetDocument(documentId) let! sourceText = document.GetTextAsync(cancellationToken) + let! recordOptions = + match recordTypeName with + | ValueSome name -> + cancellableTask { + let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof (InlineRenameInfo)) + return ValueSome(defines, langVersion, name) + } + | ValueNone -> CancellableTask.singleton ValueNone + + let tryGetEditSpan (textSpan: TextSpan) = + match recordOptions with + | ValueSome(defines, langVersion, name) when not textSpan.IsEmpty -> + // Record construction/update references can describe expressions, not type-name tokens. + match + Tokenizer.getSymbolAtPosition ( + document.Id, + sourceText, + textSpan.End - 1, + document.FilePath, + defines, + SymbolLookupKind.Greedy, + false, + false, + Some langVersion, + cancellationToken + ) + with + | Some token when FSharpKeywords.NormalizeIdentifierBackticks token.Ident.idText = name -> + match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, token.Range) with + | ValueSome tokenSpan when tokenSpan.End = textSpan.End && tokenSpan.Start >= textSpan.Start -> + ValueSome tokenSpan + | _ -> ValueNone + | _ -> ValueNone + | ValueSome _ -> ValueNone + | ValueNone -> ValueSome textSpan + return [| for symbolUse in symbolUses do - match Tokenizer.TryFSharpRangeToTextSpanForEditor(sourceText, symbolUse, symbolDisplayName) with + match + Tokenizer.TryFSharpRangeToTextSpanForEditor(sourceText, symbolUse, symbolDisplayName) + |> ValueOption.bind tryGetEditSpan + with | ValueSome textSpan -> yield FSharpInlineRenameLocation(document, textSpan) - | ValueNone -> () + | _ -> () |] } } diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index a62b25af1cf..1ac38a6a3b4 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -27,6 +27,7 @@ + @@ -95,6 +96,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 72e5ac9a27d..3089435309b 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -343,7 +343,7 @@ type RoslynTestHelpers private () = let document = project.Documents |> Seq.last document - static member CreateSolution(syntheticProject: SyntheticProject) = + static member CreateSolution(syntheticProject: SyntheticProject, ?configureWorkspace: Solution -> unit) = let checker = syntheticProject.SaveAndCheck() @@ -371,6 +371,7 @@ type RoslynTestHelpers private () = projInfo.WithMetadataReferences(RoslynTestHelpers.MetadataReferencesOf(options, [])) let solution = RoslynTestHelpers.CreateSolution [ projInfo ] + (defaultArg configureWorkspace ignore) solution options |> RoslynTestHelpers.SetProjectOptions projId solution @@ -378,7 +379,7 @@ type RoslynTestHelpers private () = /// One Roslyn project per synthetic project, wired with project references the way VS wires /// project-to-project references, so the options manager builds in-memory F# references. - static member CreateMultiProjectSolution(syntheticProject: SyntheticProject) = + static member CreateMultiProjectSolution(syntheticProject: SyntheticProject, ?configureWorkspace: Solution -> unit) = let checker = syntheticProject.SaveAndCheck() let projects = @@ -410,6 +411,7 @@ type RoslynTestHelpers private () = ] let solution = RoslynTestHelpers.CreateSolution projectInfos + (defaultArg configureWorkspace ignore) solution for project, id in projects do project.GetProjectOptions checker diff --git a/vsintegration/tests/FSharp.Editor.Tests/InlineRenameTests.fs b/vsintegration/tests/FSharp.Editor.Tests/InlineRenameTests.fs new file mode 100644 index 00000000000..a2b625fdf88 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/InlineRenameTests.fs @@ -0,0 +1,954 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module FSharp.Editor.Tests.InlineRenameTests + +#nowarn "57" + +open System +open System.IO +open System.Reflection +open System.Text +open System.Threading + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Diagnostics +open FSharp.Compiler.Symbols +open FSharp.Compiler.Text +open FSharp.Editor.Tests.Helpers +open FSharp.Test.Compiler +open FSharp.Test.ProjectGeneration + +open Xunit +open CancellableTasks + +let private source = + """module Main + +type State = { Count: int } +type Msg = Increment | Decrement +type Cmd = NoOp + +let update msg (state: State) = + match msg with + | Increment -> { state with Count = state.Count + 1 }, Cmd.NoOp + | Decrement -> { state with Count = state.Count - 1 }, Cmd.NoOp +""" + |> fun text -> text.Replace("\r\n", "\n") + +let private expected = + """module Main + +type Model = { Count: int } +type Msg = Increment | Decrement +type Cmd = NoOp + +let update msg (state: Model) = + match msg with + | Increment -> { state with Count = state.Count + 1 }, Cmd.NoOp + | Decrement -> { state with Count = state.Count - 1 }, Cmd.NoOp +""" + |> fun text -> text.Replace("\r\n", "\n") + +let private coordinates (range: range) = + range.StartLine, range.StartColumn, range.EndLine, range.EndColumn + +let private textSpan (text: SourceText) (line, column, endLine, endColumn) = + TextSpan.FromBounds(text.Lines[line - 1].Start + column, text.Lines[endLine - 1].Start + endColumn) + +let private getDocumentSpan (span: obj) = + let document = span.GetType().GetProperty("Document").GetValue(span) :?> Document + let textSpan = span.GetType().GetProperty("SourceSpan").GetValue(span) :?> TextSpan + document, textSpan + +let private getReferenceSpan (reference: obj) = + let item = + reference.GetType().GetProperty("RoslynSourceReferenceItem", BindingFlags.Instance ||| BindingFlags.NonPublic).GetValue(reference) + + item.GetType().GetProperty("SourceSpan").GetValue(item) |> getDocumentSpan + +let private assertReferences (document: Document) position expectedDefinitions expectedReferences = + task { + let context, definitions, references = + FindReferencesTests.FindReferences.getContext () + + do! (FSharpFindUsagesService() :> IFSharpFindUsagesService).FindReferencesAsync(document, position, context) + + let assertSpans expected actual = + task { + let expectedRows = ResizeArray() + let actualRows = ResizeArray() + + for (doc: Document), range, snippet in expected do + let! text = doc.GetTextAsync() + expectedRows.Add(doc.Id, doc.FilePath, textSpan text range, snippet) + + for (doc: Document), span in actual do + let! text = doc.GetTextAsync() + actualRows.Add(doc.Id, doc.FilePath, span, text.ToString(span)) + + let sorted rows = + rows + |> Seq.sortBy (fun (id: DocumentId, _, span: TextSpan, _) -> id.Id, span.Start) + |> Seq.toArray + + Assert.Equal<(DocumentId * string * TextSpan * string) array>(sorted expectedRows, sorted actualRows) + } + + let definitionSpans = + definitions + |> Seq.collect (fun definition -> + let item = + definition + .GetType() + .GetProperty("RoslynDefinitionItem", BindingFlags.Instance ||| BindingFlags.NonPublic) + .GetValue(definition) + + item.GetType().GetProperty("SourceSpans").GetValue(item) :?> System.Collections.IEnumerable + |> Seq.cast + |> Seq.map getDocumentSpan) + + do! assertSpans expectedDefinitions definitionSpans + do! assertSpans expectedReferences (references |> Seq.map getReferenceSpan) + } + +let private markedSource (marked: string) = + let parts = marked.Split([| "[|"; "|]" |], StringSplitOptions.None) + Assert.True(parts.Length % 2 = 1) + let text = StringBuilder() + let spans = ResizeArray() + + for i in 0 .. parts.Length - 1 do + if i % 2 = 1 then + spans.Add(TextSpan(text.Length, parts[i].Length)) + + text.Append(parts[i]) |> ignore + + text.ToString(), spans.ToArray() + +let private setSearchOptions fast transparent solution = + RoslynTestHelpers.SetEditorOptions + solution + { LanguageServicePerformanceOptions.Default with + EnableFastFindReferencesAndRename = fast + } + + RoslynTestHelpers.SetEditorOptions + solution + { AdvancedOptions.Default with + UseTransparentCompiler = transparent + } + +let private initializeSearchOptions fast transparent solution = + // Snapshot lookup requires identifier capture, including when fast search is subsequently switched off. + setSearchOptions (fast || transparent) transparent solution + +let private assertRename (document: Document) replacementText (expectedDocuments: (Document * string * TextSpan array) list) = + task { + let ct = CancellationToken.None + let solution = document.Project.Solution + + let _, _, triggerSpans = + expectedDocuments |> List.find (fun (doc, _, _) -> doc.Id = document.Id) + + let! info = InlineRenameService().GetRenameInfoAsync(document, triggerSpans[0].Start, ct) + Assert.NotNull(info) + Assert.True(info.CanRename) + Assert.Equal(triggerSpans[0], info.TriggerSpan) + let! locations = info.FindRenameLocationsAsync(false, false, ct) + + let expectedSpans = + expectedDocuments + |> Seq.collect (fun (doc, _, spans) -> spans |> Seq.map (fun span -> doc.Id, span)) + |> Seq.sortBy (fun (id, span) -> id.Id, span.Start) + |> Seq.toArray + + let actualSpans = + locations.Locations + |> Seq.map (fun location -> + Assert.Equal(location.TextSpan, info.GetReferenceEditSpan(location, ct)) + location.Document.Id, location.TextSpan) + |> Seq.sortBy (fun (id, span) -> id.Id, span.Start) + |> Seq.toArray + + Assert.Equal<(DocumentId * TextSpan) array>(expectedSpans, actualSpans) + let! replacement = locations.GetReplacementsAsync(info.GetFinalSymbolName replacementText, ct) + Assert.True(replacement.ReplacementTextValid) + + for project in solution.Projects do + for original in project.Documents do + let! oldText = original.GetTextAsync(ct) + + let expected, spans = + expectedDocuments + |> List.tryFind (fun (doc, _, _) -> doc.Id = original.Id) + |> Option.map (fun (_, expected, spans) -> expected, spans) + |> Option.defaultValue (oldText.ToString(), [||]) + + let proposed = replacement.NewSolution.GetDocument(original.Id) + let! proposedText = proposed.GetTextAsync(ct) + Assert.Equal(expected, proposedText.ToString()) + let! changes = proposed.GetTextChangesAsync(original, ct) + Assert.Equal(spans, changes |> Seq.map _.Span |> Seq.sortBy _.Start |> Seq.toArray) + + for previous, next in spans |> Array.pairwise do + Assert.True(previous.End <= next.Start) + + Assert.True(solution.Workspace.TryApplyChanges replacement.NewSolution) + + for project in solution.Projects do + for original in project.Documents do + let! proposed = replacement.NewSolution.GetDocument(original.Id).GetTextAsync(ct) + let! applied = solution.Workspace.CurrentSolution.GetDocument(original.Id).GetTextAsync(ct) + Assert.Equal(proposed.ToString(), applied.ToString()) + + return solution.Workspace.CurrentSolution + } + +let private compileSolution (solution: Solution) (synthetic: SyntheticProject) (checker: FSharpChecker) = + task { + for id in solution.GetProjectDependencyGraph().GetTopologicallySortedProjects() do + let project = solution.GetProject(id) + + for document in project.Documents do + let! text = document.GetTextAsync() + File.WriteAllText(document.FilePath, text.ToString()) + + let syntheticProject = + synthetic.GetAllProjects() + |> Seq.find (fun p -> p.ProjectFileName = project.FilePath) + + let options = syntheticProject.GetProjectOptions checker + + let! diagnostics, error = + checker.Compile + [| + "fsc.exe" + yield! options.OtherOptions + "--target:library" + $"-o:{syntheticProject.OutputFilename}" + yield! options.SourceFiles + |] + + Assert.True(error.IsNone, $"{error}") + + Assert.Empty( + diagnostics + |> Array.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error) + ) + + Assert.True(File.Exists syntheticProject.OutputFilename) + } + +let renameCases = + [ + "qualified trivia", + "Model", + """module Main +module Records = + type [|State|] = { Count: int } +let a: Records. [|State|] = { Count = 0 } +let b: Records. + [|State|] = a +let c: Records.(*comment*)[|State|] = a +let update (state: Records. [|State|]) = { state with Count = state.Count + 1 } +""", + """module Main +module Records = + type Model = { Count: int } +let a: Records. Model = { Count = 0 } +let b: Records. + Model = a +let c: Records.(*comment*)Model = a +let update (state: Records. Model) = { state with Count = state.Count + 1 } +""" + "qualified backticks", + "``New Model``", + """module Main +module Records = + type [|``Old State``|] = { Count: int } +let update (state: Records.[|``Old State``|]) = + { state with Count = state.Count + 1 }, "Old State" // Old State +""", + """module Main +module Records = + type ``New Model`` = { Count: int } +let update (state: Records.``New Model``) = + { state with Count = state.Count + 1 }, "Old State" // Old State +""" + "generic nested shadowed", + "Model", + """module Main +type [|State|]<'T> = { Count: int; Value: 'T } +type Outer<'T> = { Inner: [|State|]<'T>; Tag: int } +let State = "State" +type Other = { State: int } +let seed: [|State|] = { Count = 0; Value = State } +let outer: Outer = { Inner = seed; Tag = 1 } +let update (state: [|State|]) = + { state with + Count = state.Count + 1 + Value = State }, "State" // State +let nested = + { outer with + Inner.Count = 2 + Tag = 3 } +let explicitNested = { outer with Inner = { seed with Count = 3 } } +module Shadow = + type State = { X: int } + let x: State = { X = 1 } +""", + """module Main +type Model<'T> = { Count: int; Value: 'T } +type Outer<'T> = { Inner: Model<'T>; Tag: int } +let State = "State" +type Other = { State: int } +let seed: Model = { Count = 0; Value = State } +let outer: Outer = { Inner = seed; Tag = 1 } +let update (state: Model) = + { state with + Count = state.Count + 1 + Value = State }, "State" // State +let nested = + { outer with + Inner.Count = 2 + Tag = 3 } +let explicitNested = { outer with Inner = { seed with Count = 3 } } +module Shadow = + type State = { X: int } + let x: State = { X = 1 } +""" + "private record", + "Model", + """module Main +type private [|State|] = { Count: int } +let private update (state: [|State|]) = { state with Count = state.Count - 1 } // State +""", + """module Main +type private Model = { Count: int } +let private update (state: Model) = { state with Count = state.Count - 1 } // State +""" + "dot operator", + "+.+", + """module Main +module Operators = + let ([|-.-|]) a b = a - b +open Operators +let result = 3 [|-.-|] 1 +""", + """module Main +module Operators = + let (+.+) a b = a - b +open Operators +let result = 3 +.+ 1 +""" + "property", + "Contents", + """module Main +type Box() = + let mutable value = 0 + member _.[|Value|] + with get () = value + and set v = value <- v +let box = Box() +box.[|Value|] <- box.[|Value|] + 1 +""", + """module Main +type Box() = + let mutable value = 0 + member _.Contents + with get () = value + and set v = value <- v +let box = Box() +box.Contents <- box.Contents + 1 +""" + "get identifier", + "read", + "module Main\nlet [|get|] x = x + 1\nlet result = [|get|] 42\n", + "module Main\nlet read x = x + 1\nlet result = read 42\n" + "set identifier", + "write", + "module Main\nlet [|set|] x = x + 1\nlet result = [|set|] 42\n", + "module Main\nlet write x = x + 1\nlet result = write 42\n" + ] + +let cases = + seq { + for name, replacement, original, expected in renameCases do + for newline in [ "\n"; "\r\n" ] do + yield + [| + box name + box replacement + box (original.Replace("\r\n", "\n").Replace("\n", newline)) + box (expected.Replace("\r\n", "\n").Replace("\n", newline)) + |] + } + +let private report typeName fieldName localName expanded newline = + let messages = + if expanded then + "type Msg =\n | Increment\n | Decrement\n\ntype Cmd = | NoOp" + else + "type Msg = Increment | Decrement\ntype Cmd = NoOp" + + let code = + $"""module Main + +type {typeName} = {{ {fieldName}: int }} +{messages} + +let update msg ({localName}: {typeName}) = + match msg with + | Increment -> {{ {localName} with {fieldName} = {localName}.{fieldName} + 1 }}, Cmd.NoOp + | Decrement -> {{ {localName} with {fieldName} = {localName}.{fieldName} - 1 }}, Cmd.NoOp +""" + + code.Replace("\r\n", "\n").Replace("\n", newline) + +let reportCases = + seq { + for symbol, replacement, oldNames, newNames in + [ + "type", "Model", ("[|State|]", "Count", "state"), ("Model", "Count", "state") + "field", "Tally", ("State", "[|Count|]", "state"), ("State", "Tally", "state") + "local", "current", ("State", "Count", "[|state|]"), ("State", "Count", "current") + ] do + for expanded, newline, fast, transparent in [ false, "\n", false, false; true, "\n", true, false; true, "\r\n", true, true ] do + let ty, field, local = oldNames + let newTy, newField, newLocal = newNames + + yield + [| + box symbol + box (report ty field local expanded newline) + box replacement + box (report newTy newField newLocal expanded newline) + box fast + box transparent + |] + } + +type RecordRename() = + + static member Cases = cases + static member ReportCases = reportCases + + [] + member _.``Issue 20791 - report type field and local rename``(symbol: string, marked, replacement, expected, fast, transparent) = + task { + let source, spans = markedSource marked + + let project = + { SyntheticProject.Create( + { sourceFile "Main" [] with + Source = source + } + ) with + AutoAddModules = false + } + + let solution, checker = + RoslynTestHelpers.CreateSolution(project, configureWorkspace = initializeSearchOptions fast transparent) + + use _workspace = solution.Workspace + setSearchOptions fast transparent solution + + let document = RoslynTestHelpers.GetSingleDocument solution + + if symbol = "local" then + let! text = document.GetTextAsync() + let pos = text.Lines.GetLinePosition(spans[0].Start) + + let! _, check = + document.GetFSharpParseAndCheckResultsAsync("Issue 20791") + |> CancellableTask.start CancellationToken.None + + let use' = + check + .GetSymbolUseAtLocation(pos.Line + 1, pos.Character + spans[0].Length, text.Lines[pos.Line].ToString(), [ "state" ]) + .Value + + Assert.Equal(Some SymbolScope.CurrentDocument, use'.GetSymbolScope document) + + let! applied = assertRename document replacement [ document, expected, spans ] + do! compileSolution applied project checker + } + + [] + [] + [] + [] + [] + [] + [] + [] + [] + [] + [] + member _.``Issue 20791 - signature ordered files and dependent project``(target: string, fast, transparent) = + task { + let parameter = target = "parameter" + let opaque = target = "opaque signature" + + let signature, implementation, expectedSignature, expectedImplementation, replacement = + if parameter then + "module Library\ntype State = { Count: int }\nval update: [|state|]: State -> State\n", + "module Library\ntype State = { Count: int }\nlet update ([|state|]: State) = { [|state|] with Count = [|state|].Count + 1 }\n", + "module Library\ntype State = { Count: int }\nval update: current: State -> State\n", + "module Library\ntype State = { Count: int }\nlet update (current: State) = { current with Count = current.Count + 1 }\n", + "current" + else + "module Library\ntype [|State|] = { Count: int }\nval update: state: [|State|] -> [|State|]\n", + "module Library\ntype [|State|] = { Count: int }\nlet update (state: [|State|]) = { state with Count = state.Count + 1 }\n", + "module Library\ntype Model = { Count: int }\nval update: state: Model -> Model\n", + "module Library\ntype Model = { Count: int }\nlet update (state: Model) = { state with Count = state.Count + 1 }\n", + "Model" + + let signature, expectedSignature = + if opaque then + "module Library\ntype [|State|]\nval update: state: [|State|] -> [|State|]\n", + "module Library\ntype Model\nval update: state: Model -> Model\n" + else + signature, expectedSignature + + let signature, signatureSpans = markedSource signature + let implementation, implementationSpans = markedSource implementation + + let usage, expectedUsage = + if parameter then + "module Usage\nlet update (state: Library.State) = Library.update state\n", + "module Usage\nlet update (state: Library.State) = Library.update state\n" + else + "module Usage\nlet update (state: Library.[|State|]) = Library.update state\n", + "module Usage\nlet update (state: Library.Model) = Library.update state\n" + + let usage, usageSpans = markedSource usage + + let dependent, expectedDependent = + if parameter then + "module App\nlet update (state: Library.State) = { state with Count = state.Count - 1 }, \"State\"\n", + "module App\nlet update (state: Library.State) = { state with Count = state.Count - 1 }, \"State\"\n" + elif opaque then + "module App\nlet update (state: Library.[|State|]) = Library.update state, \"State\"\n", + "module App\nlet update (state: Library.Model) = Library.update state, \"State\"\n" + else + "module App\nlet update (state: Library.[|State|]) = { state with Count = state.Count - 1 }, \"State\"\n", + "module App\nlet update (state: Library.Model) = { state with Count = state.Count - 1 }, \"State\"\n" + + let dependent, dependentSpans = markedSource dependent + + let library = + { SyntheticProject.Create( + { sourceFile "Library" [] with + Source = implementation + SignatureFile = Custom signature + }, + { sourceFile "Usage" [] with + Source = usage + } + ) with + AutoAddModules = false + } + + let app = + { SyntheticProject.Create( + { sourceFile "App" [] with + Source = dependent + }, + { sourceFile "Untouched" [] with + Source = "module Untouched\nlet text = \"State\"\n" + } + ) with + AutoAddModules = false + DependsOn = [ library ] + } + + let struct (solution, checker) = + RoslynTestHelpers.CreateMultiProjectSolution(app, configureWorkspace = initializeSearchOptions fast transparent) + + use _workspace = solution.Workspace + setSearchOptions fast transparent solution + + let document path = + solution.TryGetDocumentFromPath path |> ValueOption.get + + let implementationDoc = document (library.GetFilePath "Library") + let signatureDoc = document (library.GetSignatureFilePath "Library") + + let triggerDoc, triggerSpan = + if target = "record signature" || opaque then + signatureDoc, signatureSpans[0] + else + implementationDoc, implementationSpans[0] + + let! text = triggerDoc.GetTextAsync() + let position = text.Lines.GetLinePosition(triggerSpan.Start) + + let! _, check = + triggerDoc.GetFSharpParseAndCheckResultsAsync("Issue 20791") + |> CancellableTask.start CancellationToken.None + + let use' = + check + .GetSymbolUseAtLocation( + position.Line + 1, + position.Character + triggerSpan.Length, + text.Lines[position.Line].ToString(), + [ + if parameter then "state" else "State" + ] + ) + .Value + + if parameter then + Assert.Equal(Some SymbolScope.SignatureAndImplementation, use'.GetSymbolScope triggerDoc) + else + let entity = Assert.IsType(use'.Symbol) + Assert.Equal(opaque, entity.IsOpaque) + Assert.Equal(not opaque, entity.IsFSharpRecord) + + let! _, implementationCheck = + implementationDoc.GetFSharpParseAndCheckResultsAsync("Issue 20791") + |> CancellableTask.start CancellationToken.None + + let implementationEntity = + implementationCheck.GetSymbolUseAtLocation(2, 10, "type State = { Count: int }", [ "State" ]).Value.Symbol + + Assert.True(entity.IsEffectivelySameAs implementationEntity) + + Assert.Equal<(int * int * int * int) array>( + [| 2, 5, 2, 10; 3, 19, 3, 24 |], + implementationCheck.GetUsesOfSymbolInFile(entity) + |> Array.map (fun use' -> coordinates use'.Range) + |> Array.sort + ) + + Assert.Equal<(int * int * int * int) array>( + [| 2, 5, 2, 10; 3, 19, 3, 24; 3, 28, 3, 66 |], + implementationCheck.GetUsesOfSymbolInFile(entity, relatedSymbolKinds = RelatedSymbolUseKind.All) + |> Array.map (fun use' -> coordinates use'.Range) + |> Array.sort + ) + + match use'.GetSymbolScope triggerDoc with + | Some(SymbolScope.Projects([ p ], false)) -> Assert.Equal(triggerDoc.Project.Id, p.Id) + | scope -> Assert.Fail($"Unexpected scope: {scope}") + + let definitionDoc = if transparent then implementationDoc else triggerDoc + + do! + assertReferences + triggerDoc + triggerSpan.Start + [ definitionDoc, (2, 5, 2, 10), "State" ] + [ + (if definitionDoc.Id = signatureDoc.Id then + implementationDoc + else + signatureDoc), + (2, 5, 2, 10), + "State" + signatureDoc, (3, 19, 3, 24), "State" + signatureDoc, (3, 28, 3, 33), "State" + implementationDoc, (3, 19, 3, 24), "State" + implementationDoc, (3, 55, 3, 66), "Count + 1 }" + document (library.GetFilePath "Usage"), (2, 27, 2, 32), "State" + document (app.GetFilePath "App"), (2, 27, 2, 32), "State" + if not opaque then + document (app.GetFilePath "App"), (2, 63, 2, 74), "Count - 1 }" + ] + + let! applied = + assertRename + triggerDoc + replacement + [ + signatureDoc, expectedSignature, signatureSpans + implementationDoc, expectedImplementation, implementationSpans + document (library.GetFilePath "Usage"), expectedUsage, usageSpans + document (app.GetFilePath "App"), expectedDependent, dependentSpans + ] + + do! compileSolution applied app checker + } + + [] + member _.``Issue 20791 - lexical locations preserve syntax and symbol identity``(name: string, replacement, marked, expected) = + task { + let source, spans = markedSource marked + + let project = + { SyntheticProject.Create( + { sourceFile "Main" [] with + Source = source + } + ) with + AutoAddModules = false + } + + let solution, checker = RoslynTestHelpers.CreateSolution project + use _workspace = solution.Workspace + let document = RoslynTestHelpers.GetSingleDocument solution + + if name = "generic nested shadowed" then + let! text = document.GetTextAsync() + + let! _, check = + document.GetFSharpParseAndCheckResultsAsync("Issue 20791") + |> CancellableTask.start CancellationToken.None + + let use' = + check.GetSymbolUseAtLocation(2, 10, text.Lines[1].ToString(), [ "State" ]).Value + + let all = + check.GetUsesOfSymbolInFile(use'.Symbol, relatedSymbolKinds = RelatedSymbolUseKind.All) + + let! shared = + SymbolHelpers.getSymbolUses use' document check + |> CancellableTask.start CancellationToken.None + + Assert.Equal<(int * int * int * int) array>( + all |> Array.map (fun use' -> coordinates use'.Range) |> Array.sort, + shared |> Seq.map (snd >> coordinates) |> Seq.sort |> Seq.toArray + ) + + let newline = + if source.IndexOf("\r\n", StringComparison.Ordinal) >= 0 then + "\r\n" + else + "\n" + + do! + assertReferences + document + spans[0].Start + [ document, (2, 5, 2, 10), "State" ] + [ + document, (3, 26, 3, 31), "State" + document, (6, 10, 6, 15), "State" + document, (8, 19, 8, 24), "State" + document, (10, 22, 11, 23), $"Count + 1{newline} Value = State }}" + document, (16, 42, 16, 65), "{ seed with Count = 3 }" + ] + + do! + assertReferences + document + (text.Lines[2].Start + 5) + [ document, (3, 5, 3, 10), "Outer" ] + [ + document, (7, 11, 7, 16), "Outer" + document, (14, 14, 15, 17), $"Count = 2{newline} Tag = 3 }}" + document, (16, 21, 16, 67), "{ outer with Inner = { seed with Count = 3 } }" + ] + + if name = "private record" then + let! _, check = + document.GetFSharpParseAndCheckResultsAsync("Issue 20791") + |> CancellableTask.start CancellationToken.None + + let use' = + check.GetSymbolUseAtLocation(2, 18, "type private State = { Count: int }", [ "State" ]).Value + + Assert.Equal(Some SymbolScope.CurrentDocument, use'.GetSymbolScope document) + + do! + assertReferences + document + spans[0].Start + [ document, (2, 13, 2, 18), "State" ] + [ document, (3, 27, 3, 32), "State"; document, (3, 63, 3, 74), "Count - 1 }" ] + + let! applied = assertRename document replacement [ document, expected, spans ] + do! compileSolution applied project checker + } + + [] + [] + [] + [] + [] + member _.``Issue 20791 - record type rename preserves update expressions``(fast, transparent) = + task { + let ct = CancellationToken.None + + let project = + { SyntheticProject.Create( + { sourceFile "Main" [] with + Source = source + } + ) with + AutoAddModules = false + } + + let solution, _ = + RoslynTestHelpers.CreateSolution(project, configureWorkspace = initializeSearchOptions fast transparent) + + use _workspace = solution.Workspace + setSearchOptions fast transparent solution + + let document = RoslynTestHelpers.GetSingleDocument solution + let! text = document.GetTextAsync(ct) + Assert.DoesNotContain("\r", source) + Assert.Equal(source, text.ToString()) + + let! _, check = + document.GetFSharpParseAndCheckResultsAsync("Issue 20791") + |> CancellableTask.start ct + + let symbolUse = + check.GetSymbolUseAtLocation(3, 10, text.Lines[2].ToString(), [ "State" ]).Value + + let entity = Assert.IsType(symbolUse.Symbol) + Assert.True(entity.IsFSharpRecord) + + let declaredEntity = + check.PartialAssemblySignature.Entities + |> Seq.exactlyOne + |> _.NestedEntities + |> Seq.find (fun entity -> entity.DisplayName = "State") + + Assert.True(entity.IsEffectivelySameAs declaredEntity) + Assert.Equal((3, 5, 3, 10), coordinates entity.DeclarationLocation) + + match symbolUse.GetSymbolScope document with + | Some(SymbolScope.Projects([ p ], false)) -> Assert.Equal(document.Project.Id, p.Id) + | scope -> Assert.Fail($"Unexpected scope: {scope}") + + for kinds in + [ + ValueNone + ValueSome RelatedSymbolUseKind.None + ValueSome RelatedSymbolUseKind.CopyAndUpdateRecord + ValueSome RelatedSymbolUseKind.All + ] do + let uses = + check.GetUsesOfSymbolInFile(entity, ?relatedSymbolKinds = ValueOption.toOption kinds) + + let expectedRanges = + [| + 3, 5, 3, 10 + 7, 23, 7, 28 + if + kinds = ValueSome RelatedSymbolUseKind.All + || kinds = ValueSome RelatedSymbolUseKind.CopyAndUpdateRecord + then + 9, 19, 9, 57 + 10, 19, 10, 57 + |] + + Assert.Equal<(int * int * int * int) array>( + expectedRanges, + uses |> Array.map (fun use' -> coordinates use'.Range) |> Array.sort + ) + + for use' in uses |> Array.sortBy (fun use' -> coordinates use'.Range) do + Assert.True(entity.IsEffectivelySameAs use'.Symbol) + let span = textSpan text (coordinates use'.Range) + + let expectedText = + match use'.Range.StartLine with + | 9 -> "{ state with Count = state.Count + 1 }" + | 10 -> "{ state with Count = state.Count - 1 }" + | _ -> "State" + + Assert.Equal(expectedText, text.ToString(span)) + Assert.Equal(use'.Range.StartLine = 3, use'.IsFromDefinition) + Assert.Equal(use'.Range.StartLine = 7, use'.IsFromType) + Assert.Equal(use'.Range.StartLine >= 9, use'.IsFromUse) + + let ordinary = check.GetUsesOfSymbolInFile(entity) + + Assert.Equal<(int * int * int * int) array>( + [| 3, 5, 3, 10; 7, 23, 7, 28 |], + ordinary |> Array.map (fun use' -> coordinates use'.Range) |> Array.sort + ) + + let! shared = SymbolHelpers.getSymbolUses symbolUse document check |> CancellableTask.start ct + let! snapshot = document.Project.GetFSharpProjectSnapshot() |> CancellableTask.start ct + + let! checker, _, _, options = + document.GetFSharpCompilationOptionsAsync("Issue 20791") + |> CancellableTask.start ct + + let! background = + if transparent then + checker.FindBackgroundReferencesInFile(document.FilePath, snapshot, entity) + else + checker.FindBackgroundReferencesInFile( + document.FilePath, + options, + entity, + canInvalidateProject = false, + fastCheck = fast + ) + + Assert.Equal<(int * int * int * int) array>( + [| 3, 5, 3, 10; 7, 23, 7, 28; 9, 19, 9, 57; 10, 19, 10, 57 |], + background |> Seq.map coordinates |> Seq.sort |> Seq.toArray + ) + + Assert.Equal<(int * int * int * int) array>( + background |> Seq.map coordinates |> Seq.sort |> Seq.toArray, + shared |> Seq.map (snd >> coordinates) |> Seq.sort |> Seq.toArray + ) + + Assert.Equal(transparent, document.Project.UseTransparentCompiler) + Assert.Equal(transparent, document.GetFSharpChecker().UsesTransparentCompiler) + Assert.Equal(fast, document.Project.IsFastFindReferencesEnabled) + + for _, range in shared |> Seq.sortBy (snd >> coordinates) do + let raw = RoslynHelpers.TryFSharpRangeToTextSpan(text, range) |> ValueOption.get + + let converted = + Tokenizer.TryFSharpRangeToTextSpanForEditor(text, range, entity.DisplayName) + + Assert.Equal(textSpan text (coordinates range), raw) + + let expectedSpan = + if range.StartLine >= 9 then + textSpan text (range.StartLine, 46, range.EndLine, 57) + else + raw + + Assert.Equal(ValueSome expectedSpan, converted) + + do! + assertReferences + document + (text.Lines[2].Start + 5) + [ document, (3, 5, 3, 10), "State" ] + [ + document, (7, 23, 7, 28), "State" + document, (9, 46, 9, 57), "Count + 1 }" + document, (10, 46, 10, 57), "Count - 1 }" + ] + + let service = InlineRenameService() + let! info = service.GetRenameInfoAsync(document, text.Lines[2].Start + 5, ct) + Assert.NotNull(info) + Assert.True(info.CanRename) + Assert.Equal(entity.FullName, info.FullDisplayName) + Assert.Equal(textSpan text (3, 5, 3, 10), info.TriggerSpan) + + let! applied = + assertRename + document + "Model" + [ + document, expected, [| textSpan text (3, 5, 3, 10); textSpan text (7, 23, 7, 28) |] + ] + + let! appliedText = applied.GetDocument(document.Id).GetTextAsync(ct) + + FSharp(appliedText.ToString()) + |> asLibrary + |> compile + |> shouldSucceed + |> ignore + } From 24c775e6c042b6f8e536442a0d6061aa82e265dc Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 16 Oct 2026 22:09:56 +0200 Subject: [PATCH 2/2] Link record rename release note to its pull request Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 20b10bf6-6a7c-483a-8d09-d521e7ddd73d --- docs/release-notes/.VisualStudio/18.vNext.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 9a4b1fd010a..bbeb34558e0 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -10,7 +10,7 @@ * Go To Definition no longer blocks the UI thread with a bare `Task.Wait`: the synchronous `IFSharpGoToDefinitionService` call now waits through the cancellable threaded-wait dialog, and the editor's `TaskCompletionSource` bridges run their continuations on the thread pool instead of inline on whichever thread finished the check, so repeated F12 on a large solution no longer starves semantic classification and other main-thread work. ([PR #20482](https://github.com/dotnet/fsharp/pull/20482)) * Peek Definition on an F# symbol whose definition lives in metadata no longer deadlocks Visual Studio. Peek holds the main thread in `JoinableTaskFactory.Run` without pumping messages while it asks the language service for the definition, and generating the metadata document needs that same thread; Peek now stops at definitions that already have a document, and Go To Definition, which owns the wait it makes, still opens the generated one. ([PR #20503](https://github.com/dotnet/fsharp/pull/20503)) * Improve Find All References performance by throttling parallel typechecks. ([PR #20128](https://github.com/dotnet/fsharp/pull/20128), [PR #20695](https://github.com/dotnet/fsharp/pull/20695)) -* Fix record-type Rename corrupting copy-and-update expressions, including from abstract signatures, while retaining Find All References results. ([Issue #20791](https://github.com/dotnet/fsharp/issues/20791)) +* Fix record-type Rename corrupting copy-and-update expressions, including from abstract signatures, while retaining Find All References results. ([Issue #20791](https://github.com/dotnet/fsharp/issues/20791), [PR #20797](https://github.com/dotnet/fsharp/pull/20797)) * Fixed Rename incorrectly renaming `get` and `set` keywords for properties with explicit accessors. ([Issue #18270](https://github.com/dotnet/fsharp/issues/18270), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Fixed Find All References crash when F# project contains non-F# files like `.cshtml`. ([Issue #16394](https://github.com/dotnet/fsharp/issues/16394), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Avoid using `cancellableTask` in `DocumentCache`; the editor cache now uses direct `CancellationToken`-aware `task` wrappers, avoiding the background `Task.Run` offload and a larger wrapper closure from the `cancellableTask` builder. ([Issue #20268](https://github.com/dotnet/fsharp/issues/20268))