From 22b79805570a9bccd1b90a843c9b5beb6d1ba81e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 09:42:41 +0200 Subject: [PATCH 01/12] 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 | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 72e5ac9a27d..89a449eceb7 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -203,7 +203,6 @@ type TestHostServices() = /// 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 @@ -383,9 +382,8 @@ type RoslynTestHelpers private () = let projects = syntheticProject.GetAllProjects() - |> Seq.distinctBy _.Name - |> Seq.map (fun project -> struct (project, ProjectId.CreateNewId())) - |> Seq.toArray + |> List.distinctBy _.Name + |> List.map (fun project -> project, ProjectId.CreateNewId()) let projectIds = dict [ for project, id in projects -> project.Name, id ] @@ -415,15 +413,10 @@ type RoslynTestHelpers private () = project.GetProjectOptions checker |> RoslynTestHelpers.SetProjectOptions id solution - struct (solution, checker) + 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. - /// - /// The project every instance is made from; it must not depend on other projects. - /// One Roslyn project per entry: its extra defines and the synthetic files left out of it. - /// The solution and the id of each instance's project, in the order of . static member CreateMultiTargetSolution(syntheticProject: SyntheticProject, instances: TargetInstance list) = assert (syntheticProject.DependsOn = []) @@ -436,13 +429,13 @@ type RoslynTestHelpers private () = for instance in instances -> let excludedPaths = HashSet( - seq { + [ for fileId in instance.ExcludedFileIds do syntheticProject.GetFilePath fileId if (syntheticProject.Find fileId).HasSignatureFile then syntheticProject.GetSignatureFilePath fileId - }, + ], StringComparer.OrdinalIgnoreCase ) @@ -474,7 +467,7 @@ type RoslynTestHelpers private () = |] } - struct (id, projectInfo, instanceOptions) + id, projectInfo, instanceOptions ] let solution = @@ -483,7 +476,7 @@ type RoslynTestHelpers private () = for id, _, instanceOptions in instances do RoslynTestHelpers.SetProjectOptions id solution instanceOptions - struct (solution, [ for id, _, _ in instances -> id ]) + solution, [ for id, _, _ in instances -> id ] static member GetFsDocument(code, ?customProjectOption: string, ?customEditorOptions) = let customProjectOptions = From ac67baa1cb1b07b144e799083706b19866cf6f2d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:12 +0200 Subject: [PATCH 02/12] 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 f497098f72b74fac8e1cb28cd742b7ca726a08b1 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:34 +0200 Subject: [PATCH 03/12] 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 fb6753942db..5d0dfb5a35c 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 a62b25af1cf..62c91a720af 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/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 2c55e6c01705ef61779fc20d6204e6283b246906 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:34 +0200 Subject: [PATCH 04/12] 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 ab9e3ba130ca902817a0997da15e2c689f312704 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:35 +0200 Subject: [PATCH 05/12] 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 8ddcc57e559851f8c39c3480ce3375bef75e2f33 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 12:49:23 +0200 Subject: [PATCH 06/12] 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 2d032095814..81ba20bf0db 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -4,6 +4,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)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) +* 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 1a1ef34661bd8552a324220c24360971ebed2b21 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 18:40:41 +0200 Subject: [PATCH 07/12] 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 b13847338e32d499773b3623e471792179a04401 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 7 Sep 2026 21:55:52 +0200 Subject: [PATCH 08/12] 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 8cda6b7d0ffb496972ea153c9bcb8d9bfc296870 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 04:16:43 +0200 Subject: [PATCH 09/12] 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 1439a523e18af663af153486238e29264e227e33 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 12:56:42 +0200 Subject: [PATCH 10/12] 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 From c2bfeb31c22d37bc20449e24eb95564e175342a5 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 1 Oct 2026 19:16:59 +0200 Subject: [PATCH 11/12] Take the shared test fixture as #20462 lands it The multi-project and multi-target helpers now match the ones #20462 adds, struct results included, so this branch rebases onto it without reconciling two copies. Co-Authored-By: Claude Opus 5.5 --- .../FindReferencesFromCSharpTests.fs | 2 +- .../Helpers/RoslynHelpers.fs | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs index 072dac65128..e3594e6d7d4 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -37,7 +37,7 @@ type Color = let private moduleName = $"{library.Name}.ModuleFirst" let private solution = - let librarySolution, checker = RoslynTestHelpers.CreateMultiProjectSolution library + let struct (librarySolution, checker) = RoslynTestHelpers.CreateMultiProjectSolution library let assembly = RoslynTestHelpers.CompileToAssembly(library, checker) RoslynTestHelpers.AddCSharpProject( diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index fefd998e6bb..4e0bcd0c50a 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -211,6 +211,7 @@ type TestHostServices() = /// 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 @@ -477,8 +478,9 @@ type RoslynTestHelpers private () = let projects = syntheticProject.GetAllProjects() - |> List.distinctBy _.Name - |> List.map (fun project -> project, ProjectId.CreateNewId()) + |> Seq.distinctBy _.Name + |> Seq.map (fun project -> struct (project, ProjectId.CreateNewId())) + |> Seq.toArray let projectIds = dict [ for project, id in projects -> project.Name, id ] @@ -508,10 +510,15 @@ type RoslynTestHelpers private () = project.GetProjectOptions checker |> RoslynTestHelpers.SetProjectOptions id solution - solution, checker + struct (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. + /// + /// The project every instance is made from; it must not depend on other projects. + /// One Roslyn project per entry: its extra defines and the synthetic files left out of it. + /// The solution and the id of each instance's project, in the order of . static member CreateMultiTargetSolution(syntheticProject: SyntheticProject, instances: TargetInstance list) = assert (syntheticProject.DependsOn = []) @@ -524,13 +531,13 @@ type RoslynTestHelpers private () = for instance in instances -> let excludedPaths = HashSet( - [ + seq { for fileId in instance.ExcludedFileIds do syntheticProject.GetFilePath fileId if (syntheticProject.Find fileId).HasSignatureFile then syntheticProject.GetSignatureFilePath fileId - ], + }, StringComparer.OrdinalIgnoreCase ) @@ -562,7 +569,7 @@ type RoslynTestHelpers private () = |] } - id, projectInfo, instanceOptions + struct (id, projectInfo, instanceOptions) ] let solution = @@ -571,7 +578,7 @@ type RoslynTestHelpers private () = for id, _, instanceOptions in instances do RoslynTestHelpers.SetProjectOptions id solution instanceOptions - solution, [ for id, _, _ in instances -> id ] + struct (solution, [ for id, _, _ in instances -> id ]) static member GetFsDocument(code, ?customProjectOption: string, ?customEditorOptions) = let customProjectOptions = From 3d88f18bbfcc3727eab95e56a8b9f440b47e628c Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 11:04:08 +0200 Subject: [PATCH 12/12] Search the C# consumers of every target of a multi-targeted F# project Each target framework of a multi-targeted project is a Roslyn project of its own that writes the assembly under one name to its own path. That counted as two projects producing the name, so only the output path of the one target the declaration was kept for identified the assembly, and the consumers of the other targets were not searched. The projects of one project file now count as one producer of the name. Where another project does write the same name, the output path of every target of the declaring project identifies the assembly. The fixture binding in the test file is reflowed as Fantomas formats it. Co-Authored-By: Claude Opus 5.5 --- .../LanguageService/ProjectFiltering.fs | 28 ++++++-- .../FindReferencesFromCSharpTests.fs | 66 ++++++++++++++++++- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs b/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs index df779b8a4e6..e63d5ffc707 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs @@ -17,18 +17,32 @@ module internal ProjectFiltering = 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 = + let samePath (path: string) (other: string) = + String.Equals(path, other, StringComparison.OrdinalIgnoreCase) + + // The output paths of each project that produces an assembly of this name. The target frameworks of a + // multi-targeted project are projects of their own that share its project file: they are one producer, + // writing the assembly once per target. + let producers = solution.Projects |> Seq.filter (fun project -> sameFileName project.OutputFilePath) - |> Seq.truncate 2 - |> Seq.length > 1 + |> Seq.groupBy _.FilePath + |> Seq.map (fun (_, targets) -> targets |> Seq.map _.OutputFilePath |> Seq.toArray) + |> Seq.toArray + + // 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 paths the declaring project writes to will do. + let nameIsAmbiguous = producers.Length > 1 + + let declaringOutputs = + producers + |> Array.tryFindV (Array.exists (samePath assemblyFilePath)) + |> ValueOption.defaultValue [| assemblyFilePath |] let isTheAssembly (path: string) = if nameIsAmbiguous then - String.Equals(path, assemblyFilePath, StringComparison.OrdinalIgnoreCase) + declaringOutputs |> Array.exists (samePath path) else sameFileName path diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs index e3594e6d7d4..bef74c241d9 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -37,7 +37,9 @@ type Color = let private moduleName = $"{library.Name}.ModuleFirst" let private solution = - let struct (librarySolution, checker) = RoslynTestHelpers.CreateMultiProjectSolution library + let struct (librarySolution, checker) = + RoslynTestHelpers.CreateMultiProjectSolution library + let assembly = RoslynTestHelpers.CompileToAssembly(library, checker) RoslynTestHelpers.AddCSharpProject( @@ -162,3 +164,65 @@ let ``Find Implementations on an F# function does not report C# call sites`` () findUsagesService.FindImplementationsAsync(fsharpDocument, declarationPosition, context).Wait() Assert.Empty foundReferences + +/// The library as a project of two target frameworks, each a Roslyn project that writes the assembly under +/// the same name to a folder of its own, and one C# consumer per target that references that target's file. +[] +[] +[] +let ``Find All References on a multi-targeted F# library reports the C# consumers of every target`` (anotherProjectWritesTheName: bool) = + let consumers = [ "Consumer8", "net8.0"; "Consumer9", "net9.0" ] + + let struct (librarySolution, instances) = + RoslynTestHelpers.CreateMultiTargetSolution(library, [ for _ in consumers -> { Defines = []; ExcludedFileIds = [] } ]) + + let outputs = + [ + for _, target in consumers -> + let directory = Path.Combine(Path.GetDirectoryName library.OutputFilename, target) + Directory.CreateDirectory directory |> ignore + let output = Path.Combine(directory, Path.GetFileName library.OutputFilename) + File.Copy(library.OutputFilename, output, true) + output + ] + + let options = + library.GetProjectOptions(librarySolution.GetFSharpWorkspaceService().Checker) + + let withConsumers = + (librarySolution, List.zip consumers outputs) + ||> List.fold (fun solution ((name, _), output) -> + RoslynTestHelpers.AddCSharpProject( + solution, + name, + $"class Consumer {{ int M() => {moduleName}.twice(1); }}", + options, + [ output ] + )) + + let withOutputs = + (withConsumers, List.zip instances outputs) + ||> List.fold (fun solution (instance, output) -> solution.WithProjectOutputFilePath(instance, output)) + + let solution = + if anotherProjectWritesTheName then + let other = withOutputs.AddProject("Other", "Other", LanguageNames.CSharp) + other.Solution.WithProjectOutputFilePath(other.Id, Path.Combine("C:\\Other", Path.GetFileName library.OutputFilename)) + else + withOutputs + + let document = + solution.GetProject(List.head instances).Documents + |> Seq.find (fun document -> document.FilePath = firstPath) + + let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() + + findUsagesService.FindReferencesAsync(document, declarationPosition, context).Wait() + + let reportedIn = + foundReferences + |> Seq.map (fun reference -> (documentSpanOf reference |> fst).Project.Name) + |> Seq.sort + |> Seq.toList + + Assert.Equal(consumers |> List.map fst, reportedIn)