From aed1324e6b764bab992ec8a56006c729e64642d7 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 09:42:41 +0200 Subject: [PATCH 01/17] 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 3089435309b..4183dcb27ee 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 @@ -384,9 +383,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 ] @@ -417,15 +415,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 = []) @@ -438,13 +431,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 ) @@ -476,7 +469,7 @@ type RoslynTestHelpers private () = |] } - struct (id, projectInfo, instanceOptions) + id, projectInfo, instanceOptions ] let solution = @@ -485,7 +478,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 eaa7dfa54ac795140a5915f45ed40a957508c8ac Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:12 +0200 Subject: [PATCH 02/17] 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 4183dcb27ee..106f35ec160 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 e09a7f22555338429579f48d0c37f51995e720df Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:34 +0200 Subject: [PATCH 03/17] 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 40d6eacd576..d4a295e3f04 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 d5d1a09c79a..c05b12e5ff2 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -29,6 +29,7 @@ + @@ -100,6 +101,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 106f35ec160..877c3974381 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 2279759f4bdaaa38cc589eaed837af60735fd5f4 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:34 +0200 Subject: [PATCH 04/17] 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 500e435222d06e9fbd148ab6dedb63d72460687a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:15:35 +0200 Subject: [PATCH 05/17] 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 a93b1bd2ace46e96623a69bc9f063c40e8b3d66a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 12:49:23 +0200 Subject: [PATCH 06/17] 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 be3eb9f96f9..8b078f802c0 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -6,6 +6,7 @@ * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) +* 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 7f2a9a77e05614ef314b23ee5ed214be72286b2d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 18:40:41 +0200 Subject: [PATCH 07/17] 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 f9ff2e47cacb26463e4f0eee20ea9bd2a7de7863 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 7 Sep 2026 21:55:52 +0200 Subject: [PATCH 08/17] 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 5877a8b3bb82ea85c9e16ce3ec1aa6cbfefa1cab Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 04:16:43 +0200 Subject: [PATCH 09/17] 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 3bb6c78dc9153e1b7c6c3ffb8526df34427f1511 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 12:56:42 +0200 Subject: [PATCH 10/17] 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 d6f0da51524a1f1bd1fe5e92825edf5abb4d02f7 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 1 Oct 2026 19:16:59 +0200 Subject: [PATCH 11/17] 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 877c3974381..6ee4871d7d4 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 @@ -478,8 +479,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 ] @@ -510,10 +512,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 = []) @@ -526,13 +533,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 ) @@ -564,7 +571,7 @@ type RoslynTestHelpers private () = |] } - id, projectInfo, instanceOptions + struct (id, projectInfo, instanceOptions) ] let solution = @@ -573,7 +580,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 da742c73140a7b6fbacfe1cc252392f176f8395e Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 11:04:08 +0200 Subject: [PATCH 12/17] 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) From b416f97e2312f7898c52d0f88ac055930d22abd3 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 5 Nov 2026 02:21:16 +0100 Subject: [PATCH 13/17] Carry the cross-language search's items as struct tuples The search for the C# and VB uses of an F# symbol passed reference tuples through three stages. Struct tuples allocate 5-8% less there and cost the same time on .NET Framework: in a stand-in pipeline of one definition, up to 200 consumers and 20 locations each, 1588 KB against 1456 KB, with means within the noise. The symbol of the declaring assembly is found with Seq.tryFindV. The test helper takes the referenced assemblies as an ImmutableArray and throws InvalidOperationException when a compilation or a reference fails. Co-Authored-By: Claude Sonnet 5.5 --- .../Navigation/FindUsagesService.fs | 31 ++++++++++--------- .../FindReferencesFromCSharpTests.fs | 4 +-- .../Helpers/RoslynHelpers.fs | 10 +++--- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index 1673cc17029..f143b63edc2 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -47,8 +47,8 @@ module FSharpFindUsagesService = externalDefinitionItem else definitionItems - |> Array.tryFindV (fun (_, project: Project) -> project.FilePath = doc.Project.FilePath) - |> ValueOption.map (fun (definitionItem, _) -> definitionItem) + |> Array.tryFindV (fun struct (_, project: Project) -> project.FilePath = doc.Project.FilePath) + |> ValueOption.map (fun struct (definitionItem, _) -> definitionItem) |> ValueOption.defaultValue externalDefinitionItem let referenceItem = @@ -106,14 +106,14 @@ module FSharpFindUsagesService = // would answer a compilation-wide lookup first, and the search would report its uses. let ofDeclaringAssembly = DocumentationCommentId.GetSymbolsForDeclarationId(docId, compilation) - |> Seq.tryFind (fun symbol -> + |> Seq.tryFindV (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 -> + | ValueNone -> return Seq.empty + | ValueSome symbol -> let! referencedSymbols = SymbolFinder.FindReferencesAsync( symbol, @@ -131,27 +131,28 @@ module FSharpFindUsagesService = /// 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)[]) = + let private findCrossLanguageReferences (docId: string) (definitionItems: struct (FSharpDefinitionItem * Project)[]) = seq { - for definitionItem, declaringProject in definitionItems do - for project in referencingCompilationProjects declaringProject -> definitionItem, declaringProject.AssemblyName, project + for struct (definitionItem, declaringProject) in definitionItems do + for project in referencingCompilationProjects declaringProject -> + struct (definitionItem, declaringProject.AssemblyName, project) } - |> Seq.distinctBy (fun (_, _, project) -> project.Id) - |> Seq.map (fun (definitionItem, declaringAssembly, project) -> + |> Seq.distinctBy (fun struct (_, _, project) -> project.Id) + |> Seq.map (fun struct (definitionItem, declaringAssembly, project) -> findRoslynReferences docId declaringAssembly project - |> CancellableTask.map (Seq.map (fun location -> definitionItem, location))) + |> CancellableTask.map (Seq.map (fun location -> struct (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) + (found: struct (FSharpDefinitionItem * ReferenceLocation) seq) (onReferenceFoundAsync: FSharpSourceReferenceItem -> Task) = cancellableTask { let reported = HashSet() - for definitionItem, location in found do + for struct (definitionItem, location) in found do let span = location.Location.SourceSpan if reported.Add(struct (location.Document.FilePath, span)) then @@ -218,11 +219,11 @@ module FSharpFindUsagesService = let definitionItems = declarationSpans - |> Array.map (fun span -> FSharpDefinitionItem.Create(tags, displayParts, span), span.Document.Project) + |> Array.map (fun span -> struct (FSharpDefinitionItem.Create(tags, displayParts, span), span.Document.Project)) do! definitionItems - |> Seq.map (fst >> context.OnDefinitionFoundAsync) + |> Seq.map (fun struct (definitionItem, _) -> context.OnDefinitionFoundAsync definitionItem) |> Task.WhenAll if isExternal then diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs index bef74c241d9..c4e3159521f 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -47,7 +47,7 @@ let private solution = "Consumer", $"class Consumer {{ int M() => {moduleName}.twice(1); int N() => {moduleName}.answer; }}", library.GetProjectOptions checker, - [ assembly ] + ImmutableArray.Create assembly ) let private consumer = @@ -197,7 +197,7 @@ let ``Find All References on a multi-targeted F# library reports the C# consumer name, $"class Consumer {{ int M() => {moduleName}.twice(1); }}", options, - [ output ] + ImmutableArray.Create output )) let withOutputs = diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 6ee4871d7d4..7e8c4ce1ce1 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -359,12 +359,12 @@ type RoslynTestHelpers private () = |> Array.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error) with | [||] -> syntheticProject.OutputFilename - | errors -> failwith $"Compilation of {syntheticProject.Name} failed: %A{errors}" + | errors -> invalidOp $"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) + (solution: Solution, name: string, source: string, options: FSharpProjectOptions, referencedAssemblies: ImmutableArray) = let projectId = ProjectId.CreateNewId() let projectDir = $"C:\\{name}" @@ -392,12 +392,10 @@ type RoslynTestHelpers private () = // 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 + |> Seq.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}" + invalidOp $"Could not add the references of {name}" workspace.CurrentSolution From c27ab5bc9e81e5c7f33dd616bfb20afaa2220491 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 5 Nov 2026 11:44:51 +0100 Subject: [PATCH 14/17] Resolve the symbol inside the declaring assembly, so a call through an extern alias is found A compilation's global namespace holds only the assemblies it references without an alias, and that is where DocumentationCommentId looks. A consumer that referenced the F# library as `FSLib` therefore got no result at all, and one that declared a type of the same name got that type's uses instead. Walk the namespaces and types of the referenced assembly symbol to the member whose declaration id matches. This Roslyn appends `~ReturnType` to the id of every method, so the suffix is ignored. The fixture gains a VB consumer and an enum case, and the three symbols are searched through one theory. Every consumer references a copy of the assembly in a folder of its own: AdhocWorkspace turns a reference to a project's output into a project reference whenever another project is added later. Co-Authored-By: Claude Sonnet 5.5 --- .../Navigation/FindUsagesService.fs | 26 ++- .../FindReferencesFromCSharpTests.fs | 153 ++++++++++-------- .../Helpers/RoslynHelpers.fs | 35 ++-- 3 files changed, 132 insertions(+), 82 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index f143b63edc2..4fbce3d281b 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -94,6 +94,21 @@ module FSharpFindUsagesService = ProjectFiltering.getProjectsReferencingAssembly outputFilePath declaringProject.Solution |> List.filter (fun project -> not project.IsFSharp && project.SupportsCompilation) + /// A compilation's global namespace, where `DocumentationCommentId` looks, holds only the assemblies it + /// references without an alias, so the id is resolved from the namespace of the assembly itself. + let private symbolOfId (docId: string) (assembly: IAssemblySymbol) = + let names = docId.Substring(2).Split('(').[0].Split('.') + + (Seq.singleton (assembly.GlobalNamespace :> INamespaceOrTypeSymbol), Array.take (names.Length - 1) names) + ||> Seq.fold (fun containers name -> + containers + |> Seq.collect _.GetMembers() + |> Seq.chooseV (function + | :? INamespaceOrTypeSymbol as container when container.MetadataName = name -> ValueSome container + | _ -> ValueNone)) + |> Seq.collect _.GetMembers() + |> Seq.tryFindV (fun candidate -> DocumentationCommentId.CreateDeclarationId(candidate).Split('~').[0] = docId) + /// Locations in a C# or VB project of the symbol with the given documentation comment id. let private findRoslynReferences (docId: string) (declaringAssembly: string) (project: Project) = cancellableTask { @@ -102,14 +117,11 @@ module FSharpFindUsagesService = match! project.GetCompilationAsync cancellationToken with | null -> return Seq.empty | compilation -> - // 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.tryFindV (fun symbol -> - match symbol.ContainingAssembly with - | null -> false - | assembly -> System.String.Equals(assembly.Name, declaringAssembly, System.StringComparison.OrdinalIgnoreCase)) + compilation.SourceModule.ReferencedAssemblySymbols + |> Seq.tryFindV (fun assembly -> + System.String.Equals(assembly.Name, declaringAssembly, System.StringComparison.OrdinalIgnoreCase)) + |> ValueOption.bind (symbolOfId docId) match ofDeclaringAssembly with | ValueNone -> return Seq.empty diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs index c4e3159521f..031f843e3f9 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -1,6 +1,6 @@ // 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. +/// An F# library and C# and VB projects referencing its built assembly, as VS wires a C# or VB → F# project reference. module FSharp.Editor.Tests.FindReferencesFromCSharpTests open System @@ -12,7 +12,6 @@ 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 @@ -36,33 +35,47 @@ type Color = /// The synthetic project puts its modules in a namespace named after the project. let private moduleName = $"{library.Name}.ModuleFirst" +/// A consumer references the copy of the assembly in its own output, not the file the F# project writes. +let private copiedTo (folder: string) = + let directory = Path.Combine(Path.GetDirectoryName library.OutputFilename, folder) + Directory.CreateDirectory directory |> ignore + let copy = Path.Combine(directory, Path.GetFileName library.OutputFilename) + File.Copy(library.OutputFilename, copy, true) + copy + let private solution = let struct (librarySolution, checker) = RoslynTestHelpers.CreateMultiProjectSolution library - let assembly = RoslynTestHelpers.CompileToAssembly(library, checker) + RoslynTestHelpers.CompileToAssembly(library, checker) |> ignore + let options = library.GetProjectOptions checker - RoslynTestHelpers.AddCSharpProject( - librarySolution, - "Consumer", - $"class Consumer {{ int M() => {moduleName}.twice(1); int N() => {moduleName}.answer; }}", - library.GetProjectOptions checker, - ImmutableArray.Create assembly - ) + let csharp = + $"class Consumer {{ int M() => {moduleName}.twice(1); int N() => {moduleName}.answer; int O() => (int){moduleName}.Color.Red; }}" -let private consumer = - solution.Projects |> Seq.find (fun p -> p.Language = LanguageNames.CSharp) + let vb = + $"Class Consumer\n Dim a = {moduleName}.twice(1)\n Dim b = {moduleName}.answer\n Dim c = {moduleName}.Color.Red\nEnd Class" + + (librarySolution, + [ + LanguageNames.CSharp, "CSharpConsumer", csharp + LanguageNames.VisualBasic, "VbConsumer", vb + ]) + ||> List.fold (fun solution (language, name, source) -> + RoslynTestHelpers.AddProject(solution, language, name, source, options, ImmutableArray.Create(copiedTo name))) let private firstPath = library.GetFilePath "First" -let private declarationPosition = - (File.ReadAllText firstPath).IndexOf("twice", StringComparison.Ordinal) +let private positionOf (name: string) = + (File.ReadAllText firstPath).IndexOf(name, StringComparison.Ordinal) -let private fsharpDocument = +let private fsharpDocumentIn (solution: Solution) = solution.GetDocumentIdsWithFilePath firstPath |> Seq.exactlyOne |> solution.GetDocument +let private fsharpDocument = fsharpDocumentIn solution + let private findUsagesService = FSharpFindUsagesService() :> IFSharpFindUsagesService @@ -78,32 +91,15 @@ let private documentSpanOf (reference: FSharpSourceReferenceItem) = 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 - - 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 private textOf (document: Document) = + document.GetTextAsync(CancellationToken.None).Result [] -let ``the consumer is found as a project referencing the F# assembly`` () = +let ``the consumers are found as projects referencing the F# assembly`` () = let referencing = ProjectFiltering.getProjectsReferencingAssembly fsharpDocument.Project.OutputFilePath solution - Assert.Equal([ consumer.Id ], referencing |> List.map _.Id) + Assert.Equal([ "CSharpConsumer"; "VbConsumer" ], referencing |> List.map _.Name) [] [] @@ -128,40 +124,74 @@ let ``DocumentationCommentId is the compiled form Roslyn resolves`` (symbolName: Assert.Equal(expected, symbol.DocumentationCommentId) -[] -let ``Find All References on an F# function reports its C# call site`` () = +[] +[] +[] +[] +let ``Find All References on an F# symbol reports its C# and VB uses`` (symbolName: string) = let context, foundDefinitions, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() - findUsagesService.FindReferencesAsync(fsharpDocument, declarationPosition, context).Wait() + findUsagesService.FindReferencesAsync(fsharpDocument, positionOf symbolName, context).Wait() + + Assert.Single foundDefinitions |> ignore - Assert.Equal(1, foundDefinitions.Count) - let document, span = documentSpanOf (Assert.Single foundReferences) - Assert.Equal(LanguageNames.CSharp, document.Project.Language) + let used = + foundReferences + |> Seq.map (fun reference -> + let document, span = documentSpanOf reference + document.Project.Language, (textOf document).ToString span) + |> Seq.sort + |> Seq.toList - let text = document.GetTextAsync(CancellationToken.None).Result - Assert.Equal("twice", text.ToString span) + Assert.Equal<(string * string) list>([ LanguageNames.CSharp, symbolName; LanguageNames.VisualBasic, symbolName ], used) [] -let ``Find All References on an F# literal reports its C# use`` () = - let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() +let ``Find All References on an F# function reports a call through an extern alias, not the namesake in the consumer`` () = + let alias = "FSLib" + let assembly = copiedTo "AliasedConsumer" + + let source = + $"extern alias {alias};\nnamespace {library.Name} {{ public static class ModuleFirst {{ public static int twice(int x) => x; }} }}\nclass Consumer {{\n int Real() => {alias}::{moduleName}.twice(1);\n int Other() => {moduleName}.twice(1);\n}}" + + let options = + library.GetProjectOptions(solution.GetFSharpWorkspaceService().Checker) - let position = - (File.ReadAllText firstPath).IndexOf("answer", StringComparison.Ordinal) + let withConsumer = + RoslynTestHelpers.AddProject(solution, LanguageNames.CSharp, "AliasedConsumer", source, options, ImmutableArray.Create assembly) - findUsagesService.FindReferencesAsync(fsharpDocument, position, context).Wait() + let consumer = + withConsumer.Projects + |> Seq.find (fun project -> project.Name = "AliasedConsumer") - let document, span = documentSpanOf (Assert.Single foundReferences) - Assert.Equal(LanguageNames.CSharp, document.Project.Language) + let aliasedReferences = + consumer.MetadataReferences + |> Seq.map (function + | :? PortableExecutableReference as reference when reference.FilePath = assembly -> + reference.WithAliases [ alias ] :> MetadataReference + | reference -> reference) + + let aliased = + withConsumer.WithProjectMetadataReferences(consumer.Id, aliasedReferences) + + let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() + + findUsagesService.FindReferencesAsync(fsharpDocumentIn aliased, positionOf "twice", context).Wait() + + let linesInConsumer = + foundReferences + |> Seq.map documentSpanOf + |> Seq.filter (fun (document, _) -> document.Project.Name = consumer.Name) + |> Seq.map (fun (document, span) -> (textOf document).Lines.GetLineFromPosition(span.Start).ToString()) + |> Seq.toList - let text = document.GetTextAsync(CancellationToken.None).Result - Assert.Equal("answer", text.ToString span) + Assert.Contains("Real", Assert.Single linesInConsumer) [] let ``Find Implementations on an F# function does not report C# call sites`` () = let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() - findUsagesService.FindImplementationsAsync(fsharpDocument, declarationPosition, context).Wait() + findUsagesService.FindImplementationsAsync(fsharpDocument, positionOf "twice", context).Wait() Assert.Empty foundReferences @@ -176,15 +206,7 @@ let ``Find All References on a multi-targeted F# library reports the C# consumer 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 outputs = [ for _, target in consumers -> copiedTo target ] let options = library.GetProjectOptions(librarySolution.GetFSharpWorkspaceService().Checker) @@ -192,8 +214,9 @@ let ``Find All References on a multi-targeted F# library reports the C# consumer let withConsumers = (librarySolution, List.zip consumers outputs) ||> List.fold (fun solution ((name, _), output) -> - RoslynTestHelpers.AddCSharpProject( + RoslynTestHelpers.AddProject( solution, + LanguageNames.CSharp, name, $"class Consumer {{ int M() => {moduleName}.twice(1); }}", options, @@ -217,7 +240,7 @@ let ``Find All References on a multi-targeted F# library reports the C# consumer let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() - findUsagesService.FindReferencesAsync(document, declarationPosition, context).Wait() + findUsagesService.FindReferencesAsync(document, positionOf "twice", context).Wait() let reportedIn = foundReferences diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 7e8c4ce1ce1..1eb993a8f3d 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -13,6 +13,7 @@ open System.Threading open System.Threading.Tasks open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.CSharp +open Microsoft.CodeAnalysis.VisualBasic open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages open Microsoft.VisualStudio.Composition @@ -35,6 +36,7 @@ module MefHelpers = [| "Microsoft.CodeAnalysis.Workspaces.dll" "Microsoft.CodeAnalysis.CSharp.Workspaces.dll" + "Microsoft.CodeAnalysis.VisualBasic.Workspaces.dll" "Microsoft.VisualStudio.Shell.15.0.dll" "Microsoft.VisualStudio.Platform.VSEditor.dll" "FSharp.Editor.dll" @@ -242,7 +244,8 @@ type RoslynTestHelpers private () = match extension with | ".fsx" -> SourceCodeKind.Script | ".fsi" - | ".cs" -> SourceCodeKind.Regular + | ".cs" + | ".vb" -> SourceCodeKind.Regular | ".fs" -> SourceCodeKind.Regular | _ -> failwith "not supported" @@ -361,26 +364,38 @@ type RoslynTestHelpers private () = | [||] -> syntheticProject.OutputFilename | errors -> invalidOp $"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: ImmutableArray) - = + /// Adds a C# or VB library that references the framework of `options` and the given assemblies, the way + /// VS references an F# project from C# or VB through its built assembly. + static member AddProject + ( + solution: Solution, + language: string, + name: string, + source: string, + options: FSharpProjectOptions, + referencedAssemblies: ImmutableArray + ) = let projectId = ProjectId.CreateNewId() let projectDir = $"C:\\{name}" + let extension, compilationOptions = + if language = LanguageNames.VisualBasic then + "vb", VisualBasicCompilationOptions OutputKind.DynamicallyLinkedLibrary :> CompilationOptions + else + "cs", CSharpCompilationOptions OutputKind.DynamicallyLinkedLibrary + let projectInfo = ProjectInfo.Create( projectId, VersionStamp.Create(DateTime.UtcNow), name, name, - LanguageNames.CSharp, - filePath = Path.Combine(projectDir, $"{name}.csproj"), - compilationOptions = CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary), + language, + filePath = Path.Combine(projectDir, $"{name}.{extension}proj"), + compilationOptions = compilationOptions, documents = [ - RoslynTestHelpers.CreateDocumentInfo projectId (Path.Combine(projectDir, "Program.cs")) source + RoslynTestHelpers.CreateDocumentInfo projectId (Path.Combine(projectDir, $"Program.{extension}")) source ], metadataReferences = RoslynTestHelpers.MetadataReferencesOf(options, []) ) From 7c8bc43bff1ef28ba3b1a83755f6a8bb3e5bb896 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 5 Nov 2026 11:44:51 +0100 Subject: [PATCH 15/17] Compact the cross-language find-references change Comment lines 30 -> 8, characters 2906 -> 889. The F# path's guard around OnReferenceFoundAsync is one helper used by both paths, the two literal cases of DocumentationCommentId are one, the file spans are deduplicated by Seq.distinctBy, and the F# and C#/VB searches run through CancellableTask.parallelZip. The tests reuse FindReferencesTests.getPositionOf and InlineRenameTests.getReferenceSpan, and one helper adds every consumer. Co-Authored-By: Claude Sonnet 5.5 --- .../LanguageService/ProjectFiltering.fs | 31 ++-- .../FSharp.Editor/LanguageService/Symbols.fs | 34 ++-- .../Navigation/FindUsagesService.fs | 83 ++++----- .../FindReferencesFromCSharpTests.fs | 170 ++++++------------ .../Helpers/RoslynHelpers.fs | 20 +-- .../FSharp.Editor.Tests/InlineRenameTests.fs | 2 +- 6 files changed, 111 insertions(+), 229 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs b/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs index e63d5ffc707..43b8e117e27 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs @@ -11,7 +11,7 @@ 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) @@ -20,31 +20,24 @@ module internal ProjectFiltering = 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 = + let outputsPerProjectFile = solution.Projects |> Seq.filter (fun project -> sameFileName project.OutputFilePath) |> 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 - declaringOutputs |> Array.exists (samePath path) + // Consumers reference copies of the assembly, so its file name identifies it unless several project files produce that name. + let isTheAssembly = + if outputsPerProjectFile.Length < 2 then + sameFileName else - sameFileName path + let declaringOutputs = + outputsPerProjectFile + |> Array.tryFindV (Array.exists (samePath assemblyFilePath)) + |> ValueOption.defaultValue [| assemblyFilePath |] + + fun path -> declaringOutputs |> Array.exists (samePath path) solution.Projects |> Seq.filter (fun project -> diff --git a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs index f55950401f5..3e895dedea1 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs @@ -1,7 +1,6 @@ [] module internal Microsoft.VisualStudio.FSharp.Editor.Symbols -open System open System.IO open Microsoft.CodeAnalysis open FSharp.Compiler.CodeAnalysis @@ -36,28 +35,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 = + let struct (xmlDocSig, compilesToField) = match this with - | :? 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 -> - 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 - | _ -> "" - - if String.IsNullOrEmpty xmlDocSig then - ValueNone - else - ValueSome xmlDocSig + | :? FSharpMemberOrFunctionOrValue as value -> struct (value.XmlDocSig, value.LiteralValue.IsSome) + | :? FSharpEntity as entity -> struct (entity.XmlDocSig, false) + | :? FSharpField as field -> struct (field.XmlDocSig, field.IsLiteral) + | :? FSharpUnionCase as unionCase -> struct (unionCase.XmlDocSig, false) + | _ -> struct ("", false) + + match xmlDocSig with + | null + | "" -> ValueNone + // Roslyn names a field F: where FCS says P:. + | docSig when compilesToField && docSig.StartsWith("P:", System.StringComparison.Ordinal) -> ValueSome $"F:{docSig.Substring 2}" + | docSig -> ValueSome docSig type FSharpSymbolUse with diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index 4fbce3d281b..b378d76b8c1 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -2,7 +2,7 @@ namespace Microsoft.VisualStudio.FSharp.Editor -open System.Collections.Generic +open System open System.Collections.Immutable open System.Composition open System.Threading.Tasks @@ -12,7 +12,6 @@ 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 @@ -20,6 +19,15 @@ open CancellableTasks module FSharpFindUsagesService = + // REVIEW: OnReferenceFoundAsync is throwing inside Roslyn, putting a try/with so find-all refs doesn't fail. + let private reportReference (onReferenceFoundAsync: FSharpSourceReferenceItem -> Task) definitionItem (document: Document) span = + cancellableTask { + try + do! onReferenceFoundAsync (FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(document, span))) + with _ -> + () + } + let onSymbolFound allReferences declarationRange @@ -51,13 +59,7 @@ module FSharpFindUsagesService = |> ValueOption.map (fun struct (definitionItem, _) -> definitionItem) |> ValueOption.defaultValue externalDefinitionItem - let referenceItem = - FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(doc, fixedSpan)) - // REVIEW: OnReferenceFoundAsync is throwing inside Roslyn, putting a try/with so find-all refs doesn't fail. - try - do! onReferenceFoundAsync referenceItem - with _ -> - () + do! reportReference onReferenceFoundAsync definitionItem doc fixedSpan | _ -> () } @@ -87,15 +89,7 @@ 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) - - /// A compilation's global namespace, where `DocumentationCommentId` looks, holds only the assemblies it - /// references without an alias, so the id is resolved from the namespace of the assembly itself. + // DocumentationCommentId alone searches only the unaliased references, and finds a consumer's namesake first. let private symbolOfId (docId: string) (assembly: IAssemblySymbol) = let names = docId.Substring(2).Split('(').[0].Split('.') @@ -109,7 +103,6 @@ module FSharpFindUsagesService = |> Seq.collect _.GetMembers() |> Seq.tryFindV (fun candidate -> DocumentationCommentId.CreateDeclarationId(candidate).Split('~').[0] = docId) - /// Locations in a C# or VB project of the symbol with the given documentation comment id. let private findRoslynReferences (docId: string) (declaringAssembly: string) (project: Project) = cancellableTask { let! cancellationToken = CancellableTask.getCancellationToken () @@ -119,8 +112,7 @@ module FSharpFindUsagesService = | compilation -> let ofDeclaringAssembly = compilation.SourceModule.ReferencedAssemblySymbols - |> Seq.tryFindV (fun assembly -> - System.String.Equals(assembly.Name, declaringAssembly, System.StringComparison.OrdinalIgnoreCase)) + |> Seq.tryFindV (fun assembly -> String.Equals(assembly.Name, declaringAssembly, StringComparison.OrdinalIgnoreCase)) |> ValueOption.bind (symbolOfId docId) match ofDeclaringAssembly with @@ -137,44 +129,26 @@ module FSharpFindUsagesService = return referencedSymbols |> Seq.collect _.Locations } - // 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: struct (FSharpDefinitionItem * Project)[]) = seq { for struct (definitionItem, declaringProject) in definitionItems do - for project in referencingCompilationProjects declaringProject -> - struct (definitionItem, declaringProject.AssemblyName, project) + for project in ProjectFiltering.getProjectsReferencingAssembly declaringProject.OutputFilePath declaringProject.Solution do + if project.SupportsCompilation && not project.IsFSharp then + struct (definitionItem, declaringProject.AssemblyName, project) } |> Seq.distinctBy (fun struct (_, _, project) -> project.Id) |> Seq.map (fun struct (definitionItem, declaringAssembly, project) -> findRoslynReferences docId declaringAssembly project |> CancellableTask.map (Seq.map (fun location -> struct (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: struct (FSharpDefinitionItem * ReferenceLocation) seq) - (onReferenceFoundAsync: FSharpSourceReferenceItem -> Task) - = - cancellableTask { - let reported = HashSet() - - for struct (definitionItem, location) in found do - let span = location.Location.SourceSpan - - if reported.Add(struct (location.Document.FilePath, span)) then - // 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 _ -> - () - } + // Each file span once: the target-framework instances of a consumer share their files. + |> CancellableTask.map (fun found -> + found + |> Seq.concat + |> Seq.distinctBy (fun struct (_, location) -> struct (location.Document.FilePath, location.Location.SourceSpan))) let findReferencedSymbolsAsync (document: Document, position: int, context: IFSharpFindUsagesContext, allReferences: bool, userOp: string) @@ -251,16 +225,19 @@ module FSharpFindUsagesService = symbol.Ident.idText context.OnReferenceFoundAsync - // Searched alongside the F# projects, reported after them. let crossLanguageSearch = match symbolUse.Symbol.DocumentationCommentId with | ValueSome docId when allReferences && not isExternal && not symbolUse.Symbol.IsInternalToProject -> - findCrossLanguageReferences docId definitionItems cancellationToken - | _ -> Task.FromResult Seq.empty + findCrossLanguageReferences docId definitionItems + | _ -> CancellableTask.singleton Seq.empty + + let! _, found = + CancellableTask.parallelZip + (SymbolHelpers.findSymbolUses symbolUse document checkFileResults onFound) + crossLanguageSearch - do! SymbolHelpers.findSymbolUses symbolUse document checkFileResults onFound - let! found = crossLanguageSearch - do! reportCrossLanguageReferences found context.OnReferenceFoundAsync + for struct (definitionItem, location) in found do + do! reportReference context.OnReferenceFoundAsync definitionItem location.Document location.Location.SourceSpan } open FSharpFindUsagesService diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs index 031f843e3f9..ebb520df752 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -1,18 +1,13 @@ // Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. -/// An F# library and C# and VB projects referencing its built assembly, as VS wires a C# or VB → 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.Text open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks open FSharp.Editor.Tests.Helpers @@ -21,21 +16,13 @@ open FSharp.Test.ProjectGeneration let private library = SyntheticProject.Create( { sourceFile "First" [] with - ExtraSource = - "let twice x = x * 2 -[] -let answer = 42 -type Color = - | Red = 0 - | Blue = 1 -" + ExtraSource = "let twice x = x * 2\n[]\nlet answer = 42\ntype Color = Red = 0 | Blue = 1\n" } ) -/// The synthetic project puts its modules in a namespace named after the project. let private moduleName = $"{library.Name}.ModuleFirst" -/// A consumer references the copy of the assembly in its own output, not the file the F# project writes. +// A reference to the F# project's own output becomes a project reference once another project is added: consumers get copies. let private copiedTo (folder: string) = let directory = Path.Combine(Path.GetDirectoryName library.OutputFilename, folder) Directory.CreateDirectory directory |> ignore @@ -43,57 +30,38 @@ let private copiedTo (folder: string) = File.Copy(library.OutputFilename, copy, true) copy +let private withConsumer language name source (assemblies: string list) (solution: Solution) = + let options = + library.GetProjectOptions(solution.GetFSharpWorkspaceService().Checker) + + RoslynTestHelpers.AddProject(solution, language, name, source, options, ImmutableArray.CreateRange assemblies) + +let private csharpConsumer = + $"class Consumer {{ int M() => {moduleName}.twice(1); int N() => {moduleName}.answer; int O() => (int){moduleName}.Color.Red; }}" + +let private vbConsumer = + $"Class Consumer\n Dim a = {moduleName}.twice(1)\n Dim b = {moduleName}.answer\n Dim c = {moduleName}.Color.Red\nEnd Class" + let private solution = let struct (librarySolution, checker) = RoslynTestHelpers.CreateMultiProjectSolution library RoslynTestHelpers.CompileToAssembly(library, checker) |> ignore - let options = library.GetProjectOptions checker - - let csharp = - $"class Consumer {{ int M() => {moduleName}.twice(1); int N() => {moduleName}.answer; int O() => (int){moduleName}.Color.Red; }}" - let vb = - $"Class Consumer\n Dim a = {moduleName}.twice(1)\n Dim b = {moduleName}.answer\n Dim c = {moduleName}.Color.Red\nEnd Class" - - (librarySolution, - [ - LanguageNames.CSharp, "CSharpConsumer", csharp - LanguageNames.VisualBasic, "VbConsumer", vb - ]) - ||> List.fold (fun solution (language, name, source) -> - RoslynTestHelpers.AddProject(solution, language, name, source, options, ImmutableArray.Create(copiedTo name))) + librarySolution + |> withConsumer LanguageNames.CSharp "CSharpConsumer" csharpConsumer [ copiedTo "CSharpConsumer" ] + |> withConsumer LanguageNames.VisualBasic "VbConsumer" vbConsumer [ copiedTo "VbConsumer" ] let private firstPath = library.GetFilePath "First" -let private positionOf (name: string) = - (File.ReadAllText firstPath).IndexOf(name, StringComparison.Ordinal) - -let private fsharpDocumentIn (solution: Solution) = - solution.GetDocumentIdsWithFilePath firstPath - |> Seq.exactlyOne - |> solution.GetDocument +let private positionOf name = + FindReferencesTests.getPositionOf name firstPath -let private fsharpDocument = fsharpDocumentIn solution +let private fsharpDocument = solution.TryGetDocumentFromPath(firstPath).Value 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 private textOf (document: Document) = - document.GetTextAsync(CancellationToken.None).Result - [] let ``the consumers are found as projects referencing the F# assembly`` () = let referencing = @@ -129,99 +97,65 @@ let ``DocumentationCommentId is the compiled form Roslyn resolves`` (symbolName: [] [] let ``Find All References on an F# symbol reports its C# and VB uses`` (symbolName: string) = - let context, foundDefinitions, foundReferences = - RoslynTestHelpers.CreateFindUsagesContext() - + let context, definitions, references = RoslynTestHelpers.CreateFindUsagesContext() findUsagesService.FindReferencesAsync(fsharpDocument, positionOf symbolName, context).Wait() - - Assert.Single foundDefinitions |> ignore + Assert.Single definitions |> ignore let used = - foundReferences - |> Seq.map (fun reference -> - let document, span = documentSpanOf reference - document.Project.Language, (textOf document).ToString span) - |> Seq.sort - |> Seq.toList + [ + for reference in references do + let document, span = InlineRenameTests.getReferenceSpan reference + document.Project.Language, document.GetTextAsync().Result.ToString span + ] + |> List.sort Assert.Equal<(string * string) list>([ LanguageNames.CSharp, symbolName; LanguageNames.VisualBasic, symbolName ], used) [] let ``Find All References on an F# function reports a call through an extern alias, not the namesake in the consumer`` () = let alias = "FSLib" - let assembly = copiedTo "AliasedConsumer" + let name = "AliasedConsumer" let source = $"extern alias {alias};\nnamespace {library.Name} {{ public static class ModuleFirst {{ public static int twice(int x) => x; }} }}\nclass Consumer {{\n int Real() => {alias}::{moduleName}.twice(1);\n int Other() => {moduleName}.twice(1);\n}}" - let options = - library.GetProjectOptions(solution.GetFSharpWorkspaceService().Checker) - - let withConsumer = - RoslynTestHelpers.AddProject(solution, LanguageNames.CSharp, "AliasedConsumer", source, options, ImmutableArray.Create assembly) - - let consumer = - withConsumer.Projects - |> Seq.find (fun project -> project.Name = "AliasedConsumer") - - let aliasedReferences = - consumer.MetadataReferences - |> Seq.map (function - | :? PortableExecutableReference as reference when reference.FilePath = assembly -> - reference.WithAliases [ alias ] :> MetadataReference - | reference -> reference) + let added = solution |> withConsumer LanguageNames.CSharp name source [] + let consumer = added.Projects |> Seq.find (fun project -> project.Name = name) let aliased = - withConsumer.WithProjectMetadataReferences(consumer.Id, aliasedReferences) + added.AddMetadataReference(consumer.Id, MetadataReference.CreateFromFile(copiedTo name).WithAliases [ alias ]) - let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() - - findUsagesService.FindReferencesAsync(fsharpDocumentIn aliased, positionOf "twice", context).Wait() + let context, _, references = RoslynTestHelpers.CreateFindUsagesContext() + findUsagesService.FindReferencesAsync(aliased.GetDocument(fsharpDocument.Id), positionOf "twice", context).Wait() let linesInConsumer = - foundReferences - |> Seq.map documentSpanOf - |> Seq.filter (fun (document, _) -> document.Project.Name = consumer.Name) - |> Seq.map (fun (document, span) -> (textOf document).Lines.GetLineFromPosition(span.Start).ToString()) - |> Seq.toList + references + |> Seq.map InlineRenameTests.getReferenceSpan + |> Seq.filter (fun (document, _) -> document.Project.Name = name) + |> Seq.map (fun (document, span) -> document.GetTextAsync().Result.Lines.GetLineFromPosition(span.Start).ToString()) Assert.Contains("Real", Assert.Single linesInConsumer) [] let ``Find Implementations on an F# function does not report C# call sites`` () = - let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() - + let context, _, references = RoslynTestHelpers.CreateFindUsagesContext() findUsagesService.FindImplementationsAsync(fsharpDocument, positionOf "twice", context).Wait() + Assert.Empty references - 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 consumers = [ "Consumer8"; "Consumer9" ] let struct (librarySolution, instances) = RoslynTestHelpers.CreateMultiTargetSolution(library, [ for _ in consumers -> { Defines = []; ExcludedFileIds = [] } ]) - let outputs = [ for _, target in consumers -> copiedTo target ] - - let options = - library.GetProjectOptions(librarySolution.GetFSharpWorkspaceService().Checker) + let outputs = consumers |> List.map copiedTo let withConsumers = (librarySolution, List.zip consumers outputs) - ||> List.fold (fun solution ((name, _), output) -> - RoslynTestHelpers.AddProject( - solution, - LanguageNames.CSharp, - name, - $"class Consumer {{ int M() => {moduleName}.twice(1); }}", - options, - ImmutableArray.Create output - )) + ||> List.fold (fun solution (name, output) -> solution |> withConsumer LanguageNames.CSharp name csharpConsumer [ output ]) let withOutputs = (withConsumers, List.zip instances outputs) @@ -234,18 +168,14 @@ let ``Find All References on a multi-targeted F# library reports the C# consumer else withOutputs - let document = - solution.GetProject(List.head instances).Documents - |> Seq.find (fun document -> document.FilePath = firstPath) - - let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() - + let document = solution.GetProject(List.head instances).Documents |> Seq.exactlyOne + let context, _, references = RoslynTestHelpers.CreateFindUsagesContext() findUsagesService.FindReferencesAsync(document, positionOf "twice", context).Wait() let reportedIn = - foundReferences - |> Seq.map (fun reference -> (documentSpanOf reference |> fst).Project.Name) - |> Seq.sort - |> Seq.toList + [ + for reference in references -> (InlineRenameTests.getReferenceSpan reference |> fst).Project.Name + ] + |> List.sort - Assert.Equal(consumers |> List.map fst, reportedIn) + Assert.Equal(consumers, reportedIn) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 1eb993a8f3d..785d2400aca 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -243,9 +243,9 @@ type RoslynTestHelpers private () = match extension with | ".fsx" -> SourceCodeKind.Script - | ".fsi" + | ".fsi" -> SourceCodeKind.Regular | ".cs" - | ".vb" -> SourceCodeKind.Regular + | ".vb" | ".fs" -> SourceCodeKind.Regular | _ -> failwith "not supported" @@ -340,7 +340,6 @@ 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 @@ -364,17 +363,9 @@ type RoslynTestHelpers private () = | [||] -> syntheticProject.OutputFilename | errors -> invalidOp $"Compilation of {syntheticProject.Name} failed: %A{errors}" - /// Adds a C# or VB library that references the framework of `options` and the given assemblies, the way - /// VS references an F# project from C# or VB through its built assembly. static member AddProject - ( - solution: Solution, - language: string, - name: string, - source: string, - options: FSharpProjectOptions, - referencedAssemblies: ImmutableArray - ) = + (solution: Solution, language, name: string, source, options: FSharpProjectOptions, referencedAssemblies: ImmutableArray) + = let projectId = ProjectId.CreateNewId() let projectDir = $"C:\\{name}" @@ -403,8 +394,7 @@ type RoslynTestHelpers private () = 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. + // AdhocWorkspace.AddProject turns a reference to a project's output into a project reference; VS keeps C# → F# as metadata. let withAssemblies = referencedAssemblies |> Seq.fold (fun (project: Project) assembly -> project.AddMetadataReference(MetadataReference.CreateFromFile assembly)) project diff --git a/vsintegration/tests/FSharp.Editor.Tests/InlineRenameTests.fs b/vsintegration/tests/FSharp.Editor.Tests/InlineRenameTests.fs index a2b625fdf88..c39aa75d230 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/InlineRenameTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/InlineRenameTests.fs @@ -65,7 +65,7 @@ let private getDocumentSpan (span: obj) = let textSpan = span.GetType().GetProperty("SourceSpan").GetValue(span) :?> TextSpan document, textSpan -let private getReferenceSpan (reference: obj) = +let getReferenceSpan (reference: obj) = let item = reference.GetType().GetProperty("RoslynSourceReferenceItem", BindingFlags.Instance ||| BindingFlags.NonPublic).GetValue(reference) From b10cde3dc469dc52418536c7af0dd129736ea960 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 5 Nov 2026 15:24:01 +0100 Subject: [PATCH 16/17] Accept the reference assembly of the declaring project when several projects write the same file name A consumer may reference obj\...\ref\X.dll, which Roslyn reports as the project's OutputRefFilePath. Co-Authored-By: Claude Sonnet 5.5 --- .../LanguageService/ProjectFiltering.fs | 5 ++++- .../FindReferencesFromCSharpTests.fs | 15 +++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs b/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs index 43b8e117e27..35f4721b121 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs @@ -24,7 +24,10 @@ module internal ProjectFiltering = solution.Projects |> Seq.filter (fun project -> sameFileName project.OutputFilePath) |> Seq.groupBy _.FilePath - |> Seq.map (fun (_, targets) -> targets |> Seq.map _.OutputFilePath |> Seq.toArray) + |> Seq.map (fun (_, targets) -> + targets + |> Seq.collect (fun target -> [ target.OutputFilePath; target.OutputRefFilePath ]) + |> Seq.toArray) |> Seq.toArray // Consumers reference copies of the assembly, so its file name identifies it unless several project files produce that name. diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs index ebb520df752..34b0bb8068a 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -143,9 +143,12 @@ let ``Find Implementations on an F# function does not report C# call sites`` () Assert.Empty references [] -[] -[] -let ``Find All References on a multi-targeted F# library reports the C# consumers of every target`` (anotherProjectWritesTheName: bool) = +[] +[] +[] +let ``Find All References on a multi-targeted F# library reports the C# consumers of every target`` + (anotherProjectWritesTheName: bool, consumersReferenceTheReferenceAssembly: bool) + = let consumers = [ "Consumer8"; "Consumer9" ] let struct (librarySolution, instances) = @@ -159,7 +162,11 @@ let ``Find All References on a multi-targeted F# library reports the C# consumer let withOutputs = (withConsumers, List.zip instances outputs) - ||> List.fold (fun solution (instance, output) -> solution.WithProjectOutputFilePath(instance, output)) + ||> List.fold (fun solution (instance, output) -> + if consumersReferenceTheReferenceAssembly then + solution.WithProjectOutputFilePath(instance, library.OutputFilename).WithProjectOutputRefFilePath(instance, output) + else + solution.WithProjectOutputFilePath(instance, output)) let solution = if anotherProjectWritesTheName then From f902077527af3ad479cb86fd5235e4b2ce7d2e81 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 5 Nov 2026 18:24:43 +0100 Subject: [PATCH 17/17] Parse the id with Roslyn before walking the assembly, and ignore references without a path - The declaration id goes through Roslyn's parser first, and the symbol must belong to the declaring assembly. FCS writes ``Generic{`0}`` where Roslyn writes ``Generic`1`` for a generic type used inside itself, so the string comparison of the assembly walk missed the operators of a generic type. The walk stays for the assemblies that carry an alias. - A reference without a file path (an image loaded from memory) is no longer taken for the F# assembly when the declaring project has no reference-assembly path and another project writes the same file name. - The tests compare sequences with the collection assert instead of lists built for the comparison. Co-Authored-By: Claude Sonnet 5.5 --- .../LanguageService/ProjectFiltering.fs | 3 +- .../Navigation/FindUsagesService.fs | 11 ++- .../FindReferencesFromCSharpTests.fs | 70 +++++++++++++------ 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs b/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs index 35f4721b121..bafde67ca11 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/ProjectFiltering.fs @@ -18,7 +18,8 @@ module internal ProjectFiltering = && String.Equals(Path.GetFileName path, assemblyFileName, StringComparison.OrdinalIgnoreCase) let samePath (path: string) (other: string) = - String.Equals(path, other, StringComparison.OrdinalIgnoreCase) + not (String.IsNullOrEmpty path) + && String.Equals(path, other, StringComparison.OrdinalIgnoreCase) let outputsPerProjectFile = solution.Projects diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index b378d76b8c1..c2c961ebfd1 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -89,8 +89,7 @@ module FSharpFindUsagesService = return spans |> Array.choose id } - // DocumentationCommentId alone searches only the unaliased references, and finds a consumer's namesake first. - let private symbolOfId (docId: string) (assembly: IAssemblySymbol) = + let private symbolInAssembly (docId: string) (assembly: IAssemblySymbol) = let names = docId.Substring(2).Split('(').[0].Split('.') (Seq.singleton (assembly.GlobalNamespace :> INamespaceOrTypeSymbol), Array.take (names.Length - 1) names) @@ -103,6 +102,12 @@ module FSharpFindUsagesService = |> Seq.collect _.GetMembers() |> Seq.tryFindV (fun candidate -> DocumentationCommentId.CreateDeclarationId(candidate).Split('~').[0] = docId) + // Roslyn's parser reads the ids FCS writes, but sees only the references without an alias; walking the assembly covers the aliased ones. + let private symbolOfId (docId: string) (compilation: Compilation) (assembly: IAssemblySymbol) = + DocumentationCommentId.GetSymbolsForDeclarationId(docId, compilation) + |> Seq.tryFindV (fun symbol -> SymbolEqualityComparer.Default.Equals(symbol.ContainingAssembly, assembly)) + |> ValueOption.orElseWith (fun () -> symbolInAssembly docId assembly) + let private findRoslynReferences (docId: string) (declaringAssembly: string) (project: Project) = cancellableTask { let! cancellationToken = CancellableTask.getCancellationToken () @@ -113,7 +118,7 @@ module FSharpFindUsagesService = let ofDeclaringAssembly = compilation.SourceModule.ReferencedAssemblySymbols |> Seq.tryFindV (fun assembly -> String.Equals(assembly.Name, declaringAssembly, StringComparison.OrdinalIgnoreCase)) - |> ValueOption.bind (symbolOfId docId) + |> ValueOption.bind (symbolOfId docId compilation) match ofDeclaringAssembly with | ValueNone -> return Seq.empty diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs index 34b0bb8068a..60ab9663461 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -16,7 +16,9 @@ open FSharp.Test.ProjectGeneration let private library = SyntheticProject.Create( { sourceFile "First" [] with - ExtraSource = "let twice x = x * 2\n[]\nlet answer = 42\ntype Color = Red = 0 | Blue = 1\n" + ExtraSource = + "let twice x = x * 2\n[]\nlet answer = 42\ntype Color = Red = 0 | Blue = 1\n" + + "type Generic<'T>() =\n static member (+) (a: Generic<'T>, _: Generic<'T>) = a\n" } ) @@ -37,10 +39,10 @@ let private withConsumer language name source (assemblies: string list) (solutio RoslynTestHelpers.AddProject(solution, language, name, source, options, ImmutableArray.CreateRange assemblies) let private csharpConsumer = - $"class Consumer {{ int M() => {moduleName}.twice(1); int N() => {moduleName}.answer; int O() => (int){moduleName}.Color.Red; }}" + $"class Consumer {{ int M() => {moduleName}.twice(1); int N() => {moduleName}.answer; int O() => (int){moduleName}.Color.Red; object P({moduleName}.Generic a, {moduleName}.Generic b) => a + b; }}" let private vbConsumer = - $"Class Consumer\n Dim a = {moduleName}.twice(1)\n Dim b = {moduleName}.answer\n Dim c = {moduleName}.Color.Red\nEnd Class" + $"Class Consumer\n Dim a = {moduleName}.twice(1)\n Dim b = {moduleName}.answer\n Dim c = {moduleName}.Color.Red\n Function P(x As {moduleName}.Generic(Of Integer), y As {moduleName}.Generic(Of Integer)) As Object\n Return x + y\n End Function\nEnd Class" let private solution = let struct (librarySolution, checker) = @@ -67,7 +69,28 @@ let ``the consumers are found as projects referencing the F# assembly`` () = let referencing = ProjectFiltering.getProjectsReferencingAssembly fsharpDocument.Project.OutputFilePath solution - Assert.Equal([ "CSharpConsumer"; "VbConsumer" ], referencing |> List.map _.Name) + Assert.Equal([ "CSharpConsumer"; "VbConsumer" ], referencing |> Seq.map _.Name) + +[] +let ``a reference without a file path is not the F# assembly even when another project writes its name`` () = + let added = + solution + |> withConsumer LanguageNames.CSharp "ExactConsumer" csharpConsumer [] + |> withConsumer LanguageNames.CSharp "InMemoryConsumer" csharpConsumer [] + + let idOf name = + (added.Projects |> Seq.find (fun project -> project.Name = name)).Id + + let other = added.AddProject("Other", "Other", LanguageNames.CSharp) + + let referencing = + other.Solution + .AddMetadataReference(idOf "ExactConsumer", MetadataReference.CreateFromFile library.OutputFilename) + .AddMetadataReference(idOf "InMemoryConsumer", MetadataReference.CreateFromImage(File.ReadAllBytes library.OutputFilename)) + .WithProjectOutputFilePath(other.Id, Path.Combine("C:\\Other", Path.GetFileName library.OutputFilename)) + |> ProjectFiltering.getProjectsReferencingAssembly fsharpDocument.Project.OutputFilePath + + Assert.Equal([ "ExactConsumer" ], referencing |> Seq.map _.Name) [] [] @@ -92,24 +115,30 @@ let ``DocumentationCommentId is the compiled form Roslyn resolves`` (symbolName: Assert.Equal(expected, symbol.DocumentationCommentId) +let private usesAt (position: int) = + let context, definitions, references = RoslynTestHelpers.CreateFindUsagesContext() + findUsagesService.FindReferencesAsync(fsharpDocument, position, context).Wait() + Assert.Single definitions |> ignore + + references + |> Seq.map (fun reference -> + let document, span = InlineRenameTests.getReferenceSpan reference + document.Project.Language, document.GetTextAsync().Result.ToString span) + |> Seq.sort + [] [] [] [] let ``Find All References on an F# symbol reports its C# and VB uses`` (symbolName: string) = - let context, definitions, references = RoslynTestHelpers.CreateFindUsagesContext() - findUsagesService.FindReferencesAsync(fsharpDocument, positionOf symbolName, context).Wait() - Assert.Single definitions |> ignore - - let used = - [ - for reference in references do - let document, span = InlineRenameTests.getReferenceSpan reference - document.Project.Language, document.GetTextAsync().Result.ToString span - ] - |> List.sort + Assert.Equal( + [ LanguageNames.CSharp, symbolName; LanguageNames.VisualBasic, symbolName ], + usesAt (positionOf symbolName) + ) - Assert.Equal<(string * string) list>([ LanguageNames.CSharp, symbolName; LanguageNames.VisualBasic, symbolName ], used) +[] +let ``Find All References on an operator of a generic F# type reports its C# and VB uses`` () = + Assert.Equal([ LanguageNames.CSharp, "+"; LanguageNames.VisualBasic, "+" ], usesAt (positionOf "(+) (a" + 1)) [] let ``Find All References on an F# function reports a call through an extern alias, not the namesake in the consumer`` () = @@ -180,9 +209,8 @@ let ``Find All References on a multi-targeted F# library reports the C# consumer findUsagesService.FindReferencesAsync(document, positionOf "twice", context).Wait() let reportedIn = - [ - for reference in references -> (InlineRenameTests.getReferenceSpan reference |> fst).Project.Name - ] - |> List.sort + references + |> Seq.map (fun reference -> (InlineRenameTests.getReferenceSpan reference |> fst).Project.Name) + |> Seq.sort - Assert.Equal(consumers, reportedIn) + Assert.Equal(consumers, reportedIn)