From 7a5dca53621adcbcd27ad7108860b10d56d49cee Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 09:42:41 +0200 Subject: [PATCH 01/10] Build multi-project and multi-target Roslyn solutions in FSharp.Editor tests Test helpers so far put every synthetic file into one Roslyn project. CreateMultiProjectSolution creates one project per synthetic project with project references, the way VS wires project-to-project references; CreateMultiTargetSolution creates one project per target instance sharing the project path and the document paths, the way VS loads a multi-targeted project. Co-Authored-By: Claude Fable 5.1 --- .../Helpers/RoslynHelpers.fs | 146 +++++++++++++++++- 1 file changed, 140 insertions(+), 6 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 25509f14ace..89a449eceb7 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -201,6 +201,14 @@ type TestHostServices() = override this.CreateWorkspaceServices(workspace) = new TestHostWorkspaceServices(this, workspace) +/// One Roslyn project instance of a multi-targeted F# project: its extra defines and the +/// synthetic files left out of it, as VS does per target framework. +type TargetInstance = + { + Defines: string list + ExcludedFileIds: string list + } + [] type RoslynTestHelpers private () = @@ -258,6 +266,33 @@ type RoslynTestHelpers private () = filePath = filePath ) + static member private ProjectInfoFor + (id, name, filePath, outputFilePath, documents, projectReferences: ProjectReference list, metadataReferences: MetadataReference seq) + = + ProjectInfo.Create( + id, + VersionStamp.Create(DateTime.UtcNow), + name, + name, + LanguageNames.FSharp, + filePath = filePath, + outputFilePath = outputFilePath, + documents = documents, + projectReferences = projectReferences, + metadataReferences = metadataReferences + ) + + static member private MetadataReferencesOf(options: FSharpProjectOptions, excludedPaths: string seq) = + let excluded = HashSet(excludedPaths, StringComparer.OrdinalIgnoreCase) + + options.OtherOptions + |> Seq.filter (fun x -> x.StartsWith("-r:", StringComparison.Ordinal)) + |> Seq.map _.Substring(3) + |> Seq.filter (excluded.Contains >> not) + |> Seq.map MetadataReference.CreateFromFile + |> Seq.cast + |> Seq.toList + static member SetProjectOptions projId (solution: Solution) (options: FSharpProjectOptions) = solution.Workspace.Services .GetService() @@ -331,12 +366,8 @@ type RoslynTestHelpers private () = let options = syntheticProject.GetProjectOptions checker - let metadataReferences = - options.OtherOptions - |> Seq.filter (fun x -> x.StartsWith("-r:")) - |> Seq.map (fun x -> x.Substring(3) |> MetadataReference.CreateFromFile :> MetadataReference) - - let projInfo = projInfo.WithMetadataReferences metadataReferences + let projInfo = + projInfo.WithMetadataReferences(RoslynTestHelpers.MetadataReferencesOf(options, [])) let solution = RoslynTestHelpers.CreateSolution [ projInfo ] @@ -344,6 +375,109 @@ type RoslynTestHelpers private () = solution, checker + /// 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) = + let checker = syntheticProject.SaveAndCheck() + + let projects = + syntheticProject.GetAllProjects() + |> List.distinctBy _.Name + |> List.map (fun project -> project, ProjectId.CreateNewId()) + + let projectIds = dict [ for project, id in projects -> project.Name, id ] + + let projectInfos = + [ + for project, id in projects do + let options = project.GetProjectOptions checker + + RoslynTestHelpers.ProjectInfoFor( + id, + project.Name, + project.ProjectFileName, + project.OutputFilename, + [ + for path in project.SourceFilePaths -> RoslynTestHelpers.CreateDocumentInfo id path (File.ReadAllText path) + ], + [ + for dependency in project.DependsOn -> ProjectReference projectIds[dependency.Name] + ], + RoslynTestHelpers.MetadataReferencesOf(options, project.DependsOn |> List.map _.OutputFilename) + ) + ] + + let solution = RoslynTestHelpers.CreateSolution projectInfos + + for project, id in projects do + project.GetProjectOptions checker + |> RoslynTestHelpers.SetProjectOptions id solution + + solution, checker + + /// One Roslyn project per target instance, all sharing the .fsproj path and the document file + /// paths, like the per-target-framework projects VS creates for a multi-targeted project. + static member CreateMultiTargetSolution(syntheticProject: SyntheticProject, instances: TargetInstance list) = + assert (syntheticProject.DependsOn = []) + + let checker = syntheticProject.SaveAndCheck() + let options = syntheticProject.GetProjectOptions checker + let metadataReferences = RoslynTestHelpers.MetadataReferencesOf(options, []) + + let instances = + [ + for instance in instances -> + let excludedPaths = + HashSet( + [ + for fileId in instance.ExcludedFileIds do + syntheticProject.GetFilePath fileId + + if (syntheticProject.Find fileId).HasSignatureFile then + syntheticProject.GetSignatureFilePath fileId + ], + StringComparer.OrdinalIgnoreCase + ) + + let sourceFiles = + syntheticProject.SourceFilePaths |> List.filter (excludedPaths.Contains >> not) + + let id = ProjectId.CreateNewId() + + let projectInfo = + RoslynTestHelpers.ProjectInfoFor( + id, + syntheticProject.Name, + syntheticProject.ProjectFileName, + syntheticProject.OutputFilename, + [ + for path in sourceFiles -> RoslynTestHelpers.CreateDocumentInfo id path (File.ReadAllText path) + ], + [], + metadataReferences + ) + + let instanceOptions = + { options with + SourceFiles = List.toArray sourceFiles + OtherOptions = + [| + yield! options.OtherOptions + for define in instance.Defines -> $"--define:{define}" + |] + } + + id, projectInfo, instanceOptions + ] + + let solution = + RoslynTestHelpers.CreateSolution [ for _, projectInfo, _ in instances -> projectInfo ] + + for id, _, instanceOptions in instances do + RoslynTestHelpers.SetProjectOptions id solution instanceOptions + + solution, [ for id, _, _ in instances -> id ] + static member GetFsDocument(code, ?customProjectOption: string, ?customEditorOptions) = let customProjectOptions = customProjectOption From a860dea5076a0595020fce4b3737dbdb0c7a16b0 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:12 +0200 Subject: [PATCH 02/10] Share the find-usages test context between test files The IFSharpFindUsagesContext stub of FindReferencesTests moves to RoslynTestHelpers.CreateFindUsagesContext so other test files can collect the definitions and references a search reports. Co-Authored-By: Claude Fable 5.1 --- .../FindReferencesTests.fs | 26 +----------------- .../Helpers/RoslynHelpers.fs | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs index 5519fdd337b..4d6755736d8 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs @@ -1,12 +1,8 @@ module FSharp.Editor.Tests.FindReferencesTests -open System.Threading.Tasks -open System.Threading open System.IO -open System.Collections.Concurrent open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages -open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages open Microsoft.VisualStudio.FSharp.Editor open Xunit @@ -40,27 +36,7 @@ module FindReferences = let findUsagesService = FSharpFindUsagesService() :> IFSharpFindUsagesService let getContext () = - let foundDefinitions = ConcurrentBag() - let foundReferences = ConcurrentBag() - - let context = - { new IFSharpFindUsagesContext with - - member _.OnDefinitionFoundAsync(definition: FSharpDefinitionItem) = - foundDefinitions.Add definition - Task.CompletedTask - - member _.OnReferenceFoundAsync(reference: FSharpSourceReferenceItem) = - foundReferences.Add reference - Task.CompletedTask - - member _.ReportMessageAsync _ = Task.CompletedTask - member _.ReportProgressAsync(_, _) = Task.CompletedTask - member _.SetSearchTitleAsync _ = Task.CompletedTask - member _.CancellationToken = CancellationToken.None - } - - context, foundDefinitions, foundReferences + RoslynTestHelpers.CreateFindUsagesContext() [] let ``Find references to a document-local symbol`` () = diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 89a449eceb7..e42388afae1 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -6,9 +6,14 @@ open System open System.IO open System.Reflection open System.Linq +open System.Collections.Concurrent open System.Collections.Generic open System.Collections.Immutable +open System.Threading +open System.Threading.Tasks open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages open Microsoft.VisualStudio.Composition open Microsoft.CodeAnalysis.Host open Microsoft.CodeAnalysis.Text @@ -305,6 +310,28 @@ type RoslynTestHelpers private () = static member SetEditorOptions (solution: Solution) options = solution.Workspace.Services.GetService().With(options) + static member CreateFindUsagesContext() = + let foundDefinitions = ConcurrentBag() + let foundReferences = ConcurrentBag() + + let context = + { new IFSharpFindUsagesContext with + member _.OnDefinitionFoundAsync definition = + foundDefinitions.Add definition + Task.CompletedTask + + member _.OnReferenceFoundAsync reference = + foundReferences.Add reference + Task.CompletedTask + + member _.ReportMessageAsync _ = Task.CompletedTask + member _.ReportProgressAsync(_, _) = Task.CompletedTask + member _.SetSearchTitleAsync _ = Task.CompletedTask + member _.CancellationToken = CancellationToken.None + } + + context, foundDefinitions, foundReferences + static member CreateSolution(source, ?options: FSharpProjectOptions, ?extraFSharpProjectOtherOptions: string array, ?editorOptions) = let projId = ProjectId.CreateNewId() From 8c0ae258b912ff29938ca392c6852448fa0ffc96 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:34 +0200 Subject: [PATCH 03/10] Host C# projects in the editor test workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test host served language services for F# only. It now creates them for any language from the same export provider, imports the C# workspace parts, accepts .cs documents, and gains two helpers: CompileToAssembly builds a synthetic project into its OutputFilename with the checker's options, and AddCSharpProject adds a C# library referencing the framework of the F# options and given assemblies. The assemblies are added after AdhocWorkspace.AddProject, which would otherwise rewrite a reference to a project's output into a project reference; VS keeps a C# → F# reference as metadata. The smoke tests check that the C# compilation resolves the documentation comment id of an F# function, that SymbolFinder finds its call site, and that ProjectFiltering sees the C# project as a consumer of the F# assembly. Co-Authored-By: Claude Fable 5.1 --- eng/Packages.props | 1 + .../FSharp.Editor.Tests.fsproj | 2 + .../FindReferencesFromCSharpTests.fs | 70 ++++++++++++++++ .../Helpers/RoslynHelpers.fs | 80 +++++++++++++++++-- 4 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs diff --git a/eng/Packages.props b/eng/Packages.props index eac871c2d58..4a74a543ffd 100644 --- a/eng/Packages.props +++ b/eng/Packages.props @@ -36,6 +36,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..fdabaac1233 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 @@ + @@ -94,6 +95,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs new file mode 100644 index 00000000000..0d6f76a4414 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// An F# library and a C# project referencing its built assembly, as VS wires a C# → F# project reference. +module FSharp.Editor.Tests.FindReferencesFromCSharpTests + +open System.Collections.Immutable +open System.Threading +open Xunit +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.FindSymbols +open Microsoft.VisualStudio.FSharp.Editor +open FSharp.Editor.Tests.Helpers +open FSharp.Test.ProjectGeneration + +let private library = + SyntheticProject.Create( + { sourceFile "First" [] with + ExtraSource = "let twice x = x * 2\n" + } + ) + +/// The synthetic project puts its modules in a namespace named after the project. +let private moduleName = $"{library.Name}.ModuleFirst" + +let private solution = + let librarySolution, checker = RoslynTestHelpers.CreateMultiProjectSolution library + let assembly = RoslynTestHelpers.CompileToAssembly(library, checker) + + RoslynTestHelpers.AddCSharpProject( + librarySolution, + "Consumer", + $"class Consumer {{ int M() => {moduleName}.twice(1); }}", + library.GetProjectOptions checker, + [ assembly ] + ) + +let private consumer = + solution.Projects |> Seq.find (fun p -> p.Language = LanguageNames.CSharp) + +let private fsharpDocument = + solution.GetDocumentIdsWithFilePath(library.GetFilePath "First") + |> Seq.exactlyOne + |> solution.GetDocument + +[] +let ``the C# compilation resolves the doc comment id of an F# function`` () = + let compilation = consumer.GetCompilationAsync(CancellationToken.None).Result + + let errors = + compilation.GetDiagnostics() + |> Seq.filter (fun d -> d.Severity = DiagnosticSeverity.Error) + + Assert.Empty errors + + let twice = + DocumentationCommentId.GetFirstSymbolForDeclarationId($"M:{moduleName}.twice(System.Int32)", compilation) + + Assert.NotNull twice + + let references = + SymbolFinder.FindReferencesAsync(twice, solution, ImmutableHashSet.CreateRange consumer.Documents, CancellationToken.None).Result + + Assert.Single(references |> Seq.collect _.Locations) |> ignore + +[] +let ``the consumer is found as a project referencing the F# assembly`` () = + let referencing = + ProjectFiltering.getProjectsReferencingAssembly fsharpDocument.Project.OutputFilePath solution + + Assert.Equal([ consumer.Id ], referencing |> List.map _.Id) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index e42388afae1..fefd998e6bb 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -12,6 +12,7 @@ open System.Collections.Immutable open System.Threading open System.Threading.Tasks open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.CSharp open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages open Microsoft.VisualStudio.Composition @@ -20,6 +21,7 @@ open Microsoft.CodeAnalysis.Text open Microsoft.VisualStudio.FSharp.Editor open Microsoft.CodeAnalysis.Host.Mef open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Diagnostics open FSharp.Test.ProjectGeneration [] @@ -32,6 +34,7 @@ module MefHelpers = let imports = [| "Microsoft.CodeAnalysis.Workspaces.dll" + "Microsoft.CodeAnalysis.CSharp.Workspaces.dll" "Microsoft.VisualStudio.Shell.15.0.dll" "Microsoft.VisualStudio.Platform.VSEditor.dll" "FSharp.Editor.dll" @@ -175,8 +178,7 @@ type TestHostWorkspaceServices(hostServices: HostServices, workspace: Workspace) |> Seq.distinctBy (fun x -> x.Key) |> System.Collections.Concurrent.ConcurrentDictionary - let langServices = - new TestHostLanguageServices(this, LanguageNames.FSharp, exportProvider) + let languageServices = ConcurrentDictionary() override _.Workspace = workspace @@ -194,9 +196,10 @@ type TestHostWorkspaceServices(hostServices: HostServices, workspace: Workspace) override _.FindLanguageServices(_filter) = Seq.empty override _.GetLanguageServices(languageName) = - match languageName with - | LanguageNames.FSharp -> langServices :> HostLanguageServices - | _ -> raise (NotSupportedException(sprintf "Language '%s' not supported in FSharp VS tests." languageName)) + languageServices.GetOrAdd( + languageName, + (fun language -> new TestHostLanguageServices(this, language, exportProvider) :> HostLanguageServices) + ) override _.HostServices = hostServices @@ -237,7 +240,8 @@ type RoslynTestHelpers private () = match extension with | ".fsx" -> SourceCodeKind.Script - | ".fsi" -> SourceCodeKind.Regular + | ".fsi" + | ".cs" -> SourceCodeKind.Regular | ".fs" -> SourceCodeKind.Regular | _ -> failwith "not supported" @@ -332,6 +336,70 @@ type RoslynTestHelpers private () = context, foundDefinitions, foundReferences + /// Compiles the synthetic project to its OutputFilename with the options the checker sees. + static member CompileToAssembly(syntheticProject: SyntheticProject, checker: FSharpChecker) = + let options = syntheticProject.GetProjectOptions checker + + let diagnostics, exn = + checker.Compile + [| + "fsc.exe" + "--target:library" + $"-o:{syntheticProject.OutputFilename}" + yield! options.OtherOptions + yield! options.SourceFiles + |] + |> Async.RunSynchronously + + exn |> Option.iter raise + + match + diagnostics + |> Array.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error) + with + | [||] -> syntheticProject.OutputFilename + | errors -> failwith $"Compilation of {syntheticProject.Name} failed: %A{errors}" + + /// Adds a C# library that references the framework of `options` and the given assemblies, the way + /// VS references an F# project from C# through its built assembly. + static member AddCSharpProject + (solution: Solution, name: string, source: string, options: FSharpProjectOptions, referencedAssemblies: string list) + = + let projectId = ProjectId.CreateNewId() + let projectDir = $"C:\\{name}" + + let projectInfo = + ProjectInfo.Create( + projectId, + VersionStamp.Create(DateTime.UtcNow), + name, + name, + LanguageNames.CSharp, + filePath = Path.Combine(projectDir, $"{name}.csproj"), + compilationOptions = CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary), + documents = + [ + RoslynTestHelpers.CreateDocumentInfo projectId (Path.Combine(projectDir, "Program.cs")) source + ], + metadataReferences = RoslynTestHelpers.MetadataReferencesOf(options, []) + ) + + let workspace = solution.Workspace :?> AdhocWorkspace + let project = workspace.AddProject projectInfo + + // AdhocWorkspace.AddProject turns a reference to another project's output into a project reference, + // whereas VS keeps a C# → F# reference as metadata; the assemblies are added afterwards. + let withAssemblies = + referencedAssemblies + |> List.fold + (fun (project: Project) assembly -> project.AddMetadataReference(MetadataReference.CreateFromFile assembly)) + project + + if not (workspace.TryApplyChanges withAssemblies.Solution) then + failwith $"Could not add the references of {name}" + + workspace.CurrentSolution + static member CreateSolution(source, ?options: FSharpProjectOptions, ?extraFSharpProjectOtherOptions: string array, ?editorOptions) = let projId = ProjectId.CreateNewId() From e8d07b77c336a3f5e137d14de609b386bde5e51a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:34 +0200 Subject: [PATCH 04/10] Report C# and VB uses of an F# symbol in Find All References Find All References searched F# documents only: C# and VB projects were never in scope, so call sites of F# functions from C# were missing. After the F# uses are reported, the symbol's documentation comment id (its XmlDocSig, the compiled form Roslyn resolves) is looked up in the compilation of every C# or VB project whose metadata references include the assembly of a project declaring the symbol, and SymbolFinder.FindReferencesAsync reports the locations under the F# definition item. Multi-targeted consumers report each file span once. Only Find All References does this: Find Implementations and Rename keep their F#-only scope, and symbols internal to their project or declared in external assemblies are skipped. Co-Authored-By: Claude Fable 5.1 --- .../FSharp.Editor/LanguageService/Symbols.fs | 16 +++++ .../Navigation/FindUsagesService.fs | 64 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs index 19e446f2d08..b5eb9a685be 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs @@ -1,6 +1,7 @@ [] module internal Microsoft.VisualStudio.FSharp.Editor.Symbols +open System open System.IO open Microsoft.CodeAnalysis open FSharp.Compiler.CodeAnalysis @@ -35,6 +36,21 @@ type FSharpSymbol with | :? FSharpField -> not publicOrInternal | _ -> false + /// The documentation comment id of the symbol's compiled form, as C# and VB compilations resolve it. + member this.DocumentationCommentId = + let xmlDocSig = + match this with + | :? FSharpMemberOrFunctionOrValue as value -> value.XmlDocSig + | :? FSharpEntity as entity -> entity.XmlDocSig + | :? FSharpField as field -> field.XmlDocSig + | :? FSharpUnionCase as unionCase -> unionCase.XmlDocSig + | _ -> "" + + if String.IsNullOrEmpty xmlDocSig then + ValueNone + else + ValueSome xmlDocSig + type FSharpSymbolUse with member this.GetSymbolScope(currentDocument: Document) : SymbolScope option = diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index a81d2ec3fed..3d6db011bd4 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -2,6 +2,7 @@ namespace Microsoft.VisualStudio.FSharp.Editor +open System.Collections.Generic open System.Collections.Immutable open System.Composition open System.Threading.Tasks @@ -10,6 +11,8 @@ open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.ExternalAccess.FSharp open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages +open Microsoft.CodeAnalysis.FindSymbols +open Microsoft.CodeAnalysis.Text open FSharp.Compiler.EditorServices open FSharp.Compiler.Text @@ -44,7 +47,7 @@ module FSharpFindUsagesService = externalDefinitionItem else definitionItems - |> Array.tryFindV (snd >> (=) doc.Project.FilePath) + |> Array.tryFindV (fun (_, project: Project) -> project.FilePath = doc.Project.FilePath) |> ValueOption.map (fun (definitionItem, _) -> definitionItem) |> ValueOption.defaultValue externalDefinitionItem @@ -84,6 +87,58 @@ module FSharpFindUsagesService = return spans |> Array.choose id } + let private referencingCompilationProjects (declaringProject: Project) = + match declaringProject.OutputFilePath with + | null -> [] + | outputFilePath -> + ProjectFiltering.getProjectsReferencingAssembly outputFilePath declaringProject.Solution + |> List.filter (fun project -> not project.IsFSharp && project.SupportsCompilation) + + /// Locations in a C# or VB project of the symbol with the given documentation comment id. + let private findRoslynReferences (docId: string) (project: Project) = + cancellableTask { + let! cancellationToken = CancellableTask.getCancellationToken () + + match! project.GetCompilationAsync cancellationToken with + | null -> return Seq.empty + | compilation -> + match DocumentationCommentId.GetFirstSymbolForDeclarationId(docId, compilation) with + | null -> return Seq.empty + | symbol -> + let! referencedSymbols = + SymbolFinder.FindReferencesAsync( + symbol, + project.Solution, + ImmutableHashSet.CreateRange project.Documents, + cancellationToken + ) + + return referencedSymbols |> Seq.collect _.Locations + } + + /// Reports the uses in C# and VB projects that reference the assembly of a project declaring the symbol. + let private findCrossLanguageReferences + (docId: string) + (definitionItems: (FSharpDefinitionItem * Project)[]) + (onReferenceFoundAsync: FSharpSourceReferenceItem -> Task) + = + cancellableTask { + let reported = HashSet() + + for definitionItem, declaringProject in definitionItems do + for project in referencingCompilationProjects declaringProject do + let! locations = findRoslynReferences docId project + + for location in locations do + let span = location.Location.SourceSpan + + if reported.Add(struct (location.Document.FilePath, span)) then + do! + onReferenceFoundAsync ( + FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(location.Document, span)) + ) + } + let findReferencedSymbolsAsync (document: Document, position: int, context: IFSharpFindUsagesContext, allReferences: bool, userOp: string) : CancellableTask = @@ -139,7 +194,7 @@ module FSharpFindUsagesService = let definitionItems = declarationSpans - |> Array.map (fun span -> FSharpDefinitionItem.Create(tags, displayParts, span), span.Document.Project.FilePath) + |> Array.map (fun span -> FSharpDefinitionItem.Create(tags, displayParts, span), span.Document.Project) do! definitionItems @@ -160,6 +215,11 @@ module FSharpFindUsagesService = context.OnReferenceFoundAsync do! SymbolHelpers.findSymbolUses symbolUse document checkFileResults onFound + + if allReferences && not isExternal && not symbolUse.Symbol.IsInternalToProject then + match symbolUse.Symbol.DocumentationCommentId with + | ValueSome docId -> do! findCrossLanguageReferences docId definitionItems context.OnReferenceFoundAsync + | ValueNone -> () } open FSharpFindUsagesService From 68f148ca81dcb752fd26bfb9639b0a0076aef627 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:35 +0200 Subject: [PATCH 05/10] Test Find All References from an F# declaration into C# An F# library compiled to its assembly and a C# consumer calling its function: Find All References on the F# declaration reports the C# call site, Find Implementations does not, and DocumentationCommentId gives the compiled form for a module, a function and nothing for a local. Co-Authored-By: Claude Fable 5.1 --- .../FindReferencesFromCSharpTests.fs | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs index 0d6f76a4414..162ef981ae1 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -3,12 +3,19 @@ /// An F# library and a C# project referencing its built assembly, as VS wires a C# → F# project reference. module FSharp.Editor.Tests.FindReferencesFromCSharpTests +open System open System.Collections.Immutable +open System.IO +open System.Reflection open System.Threading open Xunit open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages open Microsoft.CodeAnalysis.FindSymbols +open Microsoft.CodeAnalysis.Text open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks open FSharp.Editor.Tests.Helpers open FSharp.Test.ProjectGeneration @@ -37,11 +44,31 @@ let private solution = let private consumer = solution.Projects |> Seq.find (fun p -> p.Language = LanguageNames.CSharp) +let private firstPath = library.GetFilePath "First" + +let private declarationPosition = + (File.ReadAllText firstPath).IndexOf("twice", StringComparison.Ordinal) + let private fsharpDocument = - solution.GetDocumentIdsWithFilePath(library.GetFilePath "First") + solution.GetDocumentIdsWithFilePath firstPath |> Seq.exactlyOne |> solution.GetDocument +let private findUsagesService = + FSharpFindUsagesService() :> IFSharpFindUsagesService + +/// ExternalAccess exposes no span on a reference item; its Roslyn item is read through reflection. +let private documentSpanOf (reference: FSharpSourceReferenceItem) = + let flags = BindingFlags.Instance ||| BindingFlags.NonPublic ||| BindingFlags.Public + + let property (target: obj) name = + target.GetType().GetProperty(name, flags).GetValue target + + let documentSpan = + property (property reference "RoslynSourceReferenceItem") "SourceSpan" + + property documentSpan "Document" :?> Document, property documentSpan "SourceSpan" :?> TextSpan + [] let ``the C# compilation resolves the doc comment id of an F# function`` () = let compilation = consumer.GetCompilationAsync(CancellationToken.None).Result @@ -68,3 +95,46 @@ let ``the consumer is found as a project referencing the F# assembly`` () = ProjectFiltering.getProjectsReferencingAssembly fsharpDocument.Project.OutputFilePath solution Assert.Equal([ consumer.Id ], referencing |> List.map _.Id) + +[] +[] +[] +[] +let ``DocumentationCommentId is the compiled form Roslyn resolves`` (symbolName: string, expectedFormat: string) = + let expected = + expectedFormat + |> ValueOption.ofObj + |> ValueOption.map (fun format -> String.Format(format, moduleName)) + + let _, checkFileResults = + fsharpDocument.GetFSharpParseAndCheckResultsAsync "test" + |> CancellableTask.runSynchronouslyWithoutCancellation + + let symbol = + checkFileResults.GetAllUsesOfAllSymbolsInFile() + |> Seq.find (fun symbolUse -> symbolUse.IsFromDefinition && symbolUse.Symbol.DisplayName = symbolName) + |> _.Symbol + + Assert.Equal(expected, symbol.DocumentationCommentId) + +[] +let ``Find All References on an F# function reports its C# call site`` () = + let context, foundDefinitions, foundReferences = + RoslynTestHelpers.CreateFindUsagesContext() + + findUsagesService.FindReferencesAsync(fsharpDocument, declarationPosition, context).Wait() + + Assert.Equal(1, foundDefinitions.Count) + let document, span = documentSpanOf (Assert.Single foundReferences) + Assert.Equal(LanguageNames.CSharp, document.Project.Language) + + let text = document.GetTextAsync(CancellationToken.None).Result + Assert.Equal("twice", text.ToString span) + +[] +let ``Find Implementations on an F# function does not report C# call sites`` () = + let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() + + findUsagesService.FindImplementationsAsync(fsharpDocument, declarationPosition, context).Wait() + + Assert.Empty foundReferences From c8233cb1caec641ba5b055f60dc4ac7f7634ac09 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 12:49:23 +0200 Subject: [PATCH 06/10] Add the release note for PR #20463 --- 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 e6034dca8df..4a2d8b8b42a 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,6 +2,7 @@ * 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)) +* Find All References on an F# symbol also lists its uses in C# and Visual Basic projects that reference the F# project's built assembly. ([PR #20463](https://github.com/dotnet/fsharp/pull/20463)) ### Fixed From f8c8212040dc8b42b1785d3426b2cd492f014f35 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 18:40:41 +0200 Subject: [PATCH 07/10] Give a literal the field id Roslyn resolves FCS names a module literal `P:` like any module value, but it compiles to a const field, so `DocumentationCommentId.GetFirstSymbolForDeclarationId` found nothing and Find All References showed no C# uses of it. The id now starts with `F:` for a literal. Co-Authored-By: Claude Fable 5.1 --- .../FSharp.Editor/LanguageService/Symbols.fs | 6 +++++- .../FindReferencesFromCSharpTests.fs | 20 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs index b5eb9a685be..847d8c447db 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs @@ -40,7 +40,11 @@ type FSharpSymbol with member this.DocumentationCommentId = let xmlDocSig = match this with - | :? FSharpMemberOrFunctionOrValue as value -> value.XmlDocSig + | :? FSharpMemberOrFunctionOrValue as value -> + match value.XmlDocSig with + // A literal compiles to a field, which Roslyn names F: where FCS says P:. + | docSig when value.LiteralValue.IsSome && docSig.StartsWith("P:", StringComparison.Ordinal) -> $"F:{docSig.Substring 2}" + | docSig -> docSig | :? FSharpEntity as entity -> entity.XmlDocSig | :? FSharpField as field -> field.XmlDocSig | :? FSharpUnionCase as unionCase -> unionCase.XmlDocSig diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs index 162ef981ae1..f678f649916 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -22,7 +22,7 @@ open FSharp.Test.ProjectGeneration let private library = SyntheticProject.Create( { sourceFile "First" [] with - ExtraSource = "let twice x = x * 2\n" + ExtraSource = "let twice x = x * 2\n[]\nlet answer = 42\n" } ) @@ -36,7 +36,7 @@ let private solution = RoslynTestHelpers.AddCSharpProject( librarySolution, "Consumer", - $"class Consumer {{ int M() => {moduleName}.twice(1); }}", + $"class Consumer {{ int M() => {moduleName}.twice(1); int N() => {moduleName}.answer; }}", library.GetProjectOptions checker, [ assembly ] ) @@ -99,6 +99,7 @@ let ``the consumer is found as a project referencing the F# assembly`` () = [] [] [] +[] [] let ``DocumentationCommentId is the compiled form Roslyn resolves`` (symbolName: string, expectedFormat: string) = let expected = @@ -131,6 +132,21 @@ let ``Find All References on an F# function reports its C# call site`` () = let text = document.GetTextAsync(CancellationToken.None).Result Assert.Equal("twice", text.ToString span) +[] +let ``Find All References on an F# literal reports its C# use`` () = + let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() + + let position = + (File.ReadAllText firstPath).IndexOf("answer", StringComparison.Ordinal) + + findUsagesService.FindReferencesAsync(fsharpDocument, position, context).Wait() + + let document, span = documentSpanOf (Assert.Single foundReferences) + Assert.Equal(LanguageNames.CSharp, document.Project.Language) + + let text = document.GetTextAsync(CancellationToken.None).Result + Assert.Equal("answer", text.ToString span) + [] let ``Find Implementations on an F# function does not report C# call sites`` () = let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() From 0470f62e108300a02a061d65094ec0a4d41c6958 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 7 Sep 2026 21:55:52 +0200 Subject: [PATCH 08/10] Search the C# and VB consumers alongside the F# projects The cross-language search ran after the F# one and visited the consumers one by one, so on a solution where the F# search takes minutes the C# call sites were the last thing to appear. The search now starts before the F# one and runs a few consumers at a time, each search building a compilation; the results are still reported after the F# uses, each file span once, so the order in the window is unchanged. Co-Authored-By: Claude Fable 5.1 --- .../Navigation/FindUsagesService.fs | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index 3d6db011bd4..614184ad06d 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -116,27 +116,37 @@ module FSharpFindUsagesService = return referencedSymbols |> Seq.collect _.Locations } - /// Reports the uses in C# and VB projects that reference the assembly of a project declaring the symbol. - let private findCrossLanguageReferences - (docId: string) - (definitionItems: (FSharpDefinitionItem * Project)[]) + // Every search may build a compilation, and those cost memory, not just a core. + [] + let private ConcurrentCompilations = 4 + + /// The uses in the C# and VB projects that reference the assembly of a project declaring the symbol, + /// each with the definition item to report them under. + let private findCrossLanguageReferences (docId: string) (definitionItems: (FSharpDefinitionItem * Project)[]) = + seq { + for definitionItem, declaringProject in definitionItems do + for project in referencingCompilationProjects declaringProject -> definitionItem, project + } + |> Seq.distinctBy (fun (_, project) -> project.Id) + |> Seq.map (fun (definitionItem, project) -> + findRoslynReferences docId project + |> CancellableTask.map (Seq.map (fun location -> definitionItem, location))) + |> CancellableTask.whenAllThrottled ConcurrentCompilations + |> CancellableTask.map Seq.concat + + /// Reports each file span once: the target-framework instances of a consumer share their files. + let private reportCrossLanguageReferences + (found: (FSharpDefinitionItem * ReferenceLocation) seq) (onReferenceFoundAsync: FSharpSourceReferenceItem -> Task) = cancellableTask { let reported = HashSet() - for definitionItem, declaringProject in definitionItems do - for project in referencingCompilationProjects declaringProject do - let! locations = findRoslynReferences docId project - - for location in locations do - let span = location.Location.SourceSpan + for definitionItem, location in found do + let span = location.Location.SourceSpan - if reported.Add(struct (location.Document.FilePath, span)) then - do! - onReferenceFoundAsync ( - FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(location.Document, span)) - ) + if reported.Add(struct (location.Document.FilePath, span)) then + do! onReferenceFoundAsync (FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(location.Document, span))) } let findReferencedSymbolsAsync @@ -214,12 +224,16 @@ module FSharpFindUsagesService = symbol.Ident.idText context.OnReferenceFoundAsync - do! SymbolHelpers.findSymbolUses symbolUse document checkFileResults onFound - - if allReferences && not isExternal && not symbolUse.Symbol.IsInternalToProject then + // Searched alongside the F# projects, reported after them. + let crossLanguageSearch = match symbolUse.Symbol.DocumentationCommentId with - | ValueSome docId -> do! findCrossLanguageReferences docId definitionItems context.OnReferenceFoundAsync - | ValueNone -> () + | ValueSome docId when allReferences && not isExternal && not symbolUse.Symbol.IsInternalToProject -> + findCrossLanguageReferences docId definitionItems cancellationToken + | _ -> Task.FromResult Seq.empty + + do! SymbolHelpers.findSymbolUses symbolUse document checkFileResults onFound + let! found = crossLanguageSearch + do! reportCrossLanguageReferences found context.OnReferenceFoundAsync } open FSharpFindUsagesService From ec64e8a5051d885d693ed7eae2f659444a216503 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 04:16:43 +0200 Subject: [PATCH 09/10] Ask the assembly that declares the symbol, and name an enum case as a field Two ways the search reached the wrong symbol, or none. A documentation comment id was resolved against the whole consumer compilation, which answers with the first symbol carrying it. A consumer that declares a type of the same namespace-qualified name as one it references therefore won the lookup, and the search reported uses of its own declaration while missing the ones it was asked about. The id is the F# assembly's, so it is now asked of the symbols of that assembly. An enum case compiles to a field, and FCS names it `P:` where Roslyn spells `F:` - the same disagreement a literal already had. The id named nothing, so a C# or Visual Basic use of an F# enum case was quietly absent from the results rather than wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .../FSharp.Editor/LanguageService/Symbols.fs | 6 ++++- .../Navigation/FindUsagesService.fs | 25 +++++++++++++------ .../FindReferencesFromCSharpTests.fs | 10 +++++++- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs index 847d8c447db..f55950401f5 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs @@ -46,7 +46,11 @@ type FSharpSymbol with | docSig when value.LiteralValue.IsSome && docSig.StartsWith("P:", StringComparison.Ordinal) -> $"F:{docSig.Substring 2}" | docSig -> docSig | :? FSharpEntity as entity -> entity.XmlDocSig - | :? FSharpField as field -> field.XmlDocSig + | :? FSharpField as field -> + match field.XmlDocSig with + // An enum case compiles to a field too, and FCS names it P: where Roslyn says F:. + | docSig when field.IsLiteral && docSig.StartsWith("P:", StringComparison.Ordinal) -> $"F:{docSig.Substring 2}" + | docSig -> docSig | :? FSharpUnionCase as unionCase -> unionCase.XmlDocSig | _ -> "" diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index 614184ad06d..af74dff2489 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -95,16 +95,25 @@ module FSharpFindUsagesService = |> List.filter (fun project -> not project.IsFSharp && project.SupportsCompilation) /// Locations in a C# or VB project of the symbol with the given documentation comment id. - let private findRoslynReferences (docId: string) (project: Project) = + let private findRoslynReferences (docId: string) (declaringAssembly: string) (project: Project) = cancellableTask { let! cancellationToken = CancellableTask.getCancellationToken () match! project.GetCompilationAsync cancellationToken with | null -> return Seq.empty | compilation -> - match DocumentationCommentId.GetFirstSymbolForDeclarationId(docId, compilation) with - | null -> return Seq.empty - | symbol -> + // The id names a symbol of the F# assembly. A consumer that declares the same name itself + // would answer a compilation-wide lookup first, and the search would report its uses. + let ofDeclaringAssembly = + DocumentationCommentId.GetSymbolsForDeclarationId(docId, compilation) + |> Seq.tryFind (fun symbol -> + match symbol.ContainingAssembly with + | null -> false + | assembly -> System.String.Equals(assembly.Name, declaringAssembly, System.StringComparison.OrdinalIgnoreCase)) + + match ofDeclaringAssembly with + | None -> return Seq.empty + | Some symbol -> let! referencedSymbols = SymbolFinder.FindReferencesAsync( symbol, @@ -125,11 +134,11 @@ module FSharpFindUsagesService = let private findCrossLanguageReferences (docId: string) (definitionItems: (FSharpDefinitionItem * Project)[]) = seq { for definitionItem, declaringProject in definitionItems do - for project in referencingCompilationProjects declaringProject -> definitionItem, project + for project in referencingCompilationProjects declaringProject -> definitionItem, declaringProject.AssemblyName, project } - |> Seq.distinctBy (fun (_, project) -> project.Id) - |> Seq.map (fun (definitionItem, project) -> - findRoslynReferences docId project + |> Seq.distinctBy (fun (_, _, project) -> project.Id) + |> Seq.map (fun (definitionItem, declaringAssembly, project) -> + findRoslynReferences docId declaringAssembly project |> CancellableTask.map (Seq.map (fun location -> definitionItem, location))) |> CancellableTask.whenAllThrottled ConcurrentCompilations |> CancellableTask.map Seq.concat diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs index f678f649916..072dac65128 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -22,7 +22,14 @@ open FSharp.Test.ProjectGeneration let private library = SyntheticProject.Create( { sourceFile "First" [] with - ExtraSource = "let twice x = x * 2\n[]\nlet answer = 42\n" + ExtraSource = + "let twice x = x * 2 +[] +let answer = 42 +type Color = + | Red = 0 + | Blue = 1 +" } ) @@ -100,6 +107,7 @@ let ``the consumer is found as a project referencing the F# assembly`` () = [] [] [] +[] [] let ``DocumentationCommentId is the compiled form Roslyn resolves`` (symbolName: string, expectedFormat: string) = let expected = From eea9dba5f2272f7d280117a69f0e2c1784d5f07b Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 12:56:42 +0200 Subject: [PATCH 10/10] Keep one failed item from ending the search, and one name from meaning two assemblies A consumer references the copy of an assembly in its own output rather than the file the producer writes, so the file name is what identifies it - until another project of the solution produces one named the same, when a consumer of that other assembly is picked up instead. The name is checked for that first now, and where it is ambiguous only the declaring project's own output path counts. The F# reporting path wraps the Find All References callback because the window throws inside Roslyn on an item it will not take; the cross-language path reported without that guard, so one such item ended a search that had already found the rest. Co-Authored-By: Claude Opus 5 (1M context) --- .../LanguageService/ProjectFiltering.fs | 25 ++++++++++++++++--- .../Navigation/FindUsagesService.fs | 7 +++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs b/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs index c43588bf50b..df779b8a4e6 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs @@ -11,15 +11,32 @@ module internal ProjectFiltering = /// #10227: Filters projects to those referencing a specific assembly file. /// Used to optimize Find All References for external DLL symbols. let getProjectsReferencingAssembly (assemblyFilePath: string) (solution: Solution) = - let assemblyFileName = Path.GetFileName(assemblyFilePath) + let assemblyFileName = Path.GetFileName assemblyFilePath + + let sameFileName (path: string) = + not (String.IsNullOrEmpty path) + && String.Equals(Path.GetFileName path, assemblyFileName, StringComparison.OrdinalIgnoreCase) + + // A consumer references the copy in its own output rather than the file the producer writes, so the + // name is what identifies the assembly. It stops identifying it once another project of the solution + // produces one named the same, and then only the path the declaring project writes to will do. + let nameIsAmbiguous = + solution.Projects + |> Seq.filter (fun project -> sameFileName project.OutputFilePath) + |> Seq.truncate 2 + |> Seq.length > 1 + + let isTheAssembly (path: string) = + if nameIsAmbiguous then + String.Equals(path, assemblyFilePath, StringComparison.OrdinalIgnoreCase) + else + sameFileName path solution.Projects |> Seq.filter (fun project -> project.MetadataReferences |> Seq.exists (fun metaRef -> match metaRef with - | :? PortableExecutableReference as peRef when not (isNull peRef.FilePath) -> - let refFileName = Path.GetFileName(peRef.FilePath) - String.Equals(refFileName, assemblyFileName, StringComparison.OrdinalIgnoreCase) + | :? PortableExecutableReference as peRef -> isTheAssembly peRef.FilePath | _ -> false)) |> Seq.toList diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index af74dff2489..1673cc17029 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -155,7 +155,12 @@ module FSharpFindUsagesService = let span = location.Location.SourceSpan if reported.Add(struct (location.Document.FilePath, span)) then - do! onReferenceFoundAsync (FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(location.Document, span))) + // Same as the F# path above: the window throws inside Roslyn on an item it will not take, + // and one such item must not end the search that found the rest. + try + do! onReferenceFoundAsync (FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(location.Document, span))) + with _ -> + () } let findReferencedSymbolsAsync