From fdbe317d6b17c476ece73020ac8394fe73a8edc2 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 09:48:46 +0200 Subject: [PATCH 1/6] Move the cross-language symbol navigation service into its own file Pure move of the doc-comment-id types, FSharpNavigableLocation and FSharpCrossLanguageSymbolNavigationService out of GoToDefinition.fs, compiled after NavigateToSearchService.fs so the service can use the parsed navigable items cache. Co-Authored-By: Claude Fable 5.1 --- .../src/FSharp.Editor/FSharp.Editor.fsproj | 1 + .../CrossLanguageSymbolNavigation.fs | 255 ++++++++++++++++++ .../Navigation/GoToDefinition.fs | 236 ---------------- 3 files changed, 256 insertions(+), 236 deletions(-) create mode 100644 vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..34150643853 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -92,6 +92,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs b/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs new file mode 100644 index 00000000000..60e8ecd0e16 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Composition +open System.Linq +open System.Threading +open System.Threading.Tasks + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation +open Microsoft.VisualStudio +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.LanguageServices + +open FSharp.Compiler.Symbols +open FSharp.Compiler.Text +open CancellableTasks + +[] +type internal SymbolMemberType = + | Event + | Property + | Method + | Constructor + | Other + +type internal SymbolPath = + { + EntityPath: string list + MemberOrValName: string + GenericParameters: int + } + +[] +type internal DocCommentId = + | Member of SymbolPath * SymbolMemberType: SymbolMemberType + | Field of SymbolPath + | Type of EntityPath: string list + | None + +type FSharpNavigableLocation(metadataAsSource: FSharpMetadataAsSourceService, symbolRange: range, project: Project) = + interface IFSharpNavigableLocation with + member _.NavigateToAsync(_options: FSharpNavigationOptions2, cancellationToken: CancellationToken) : Task = + cancellableTask { + let targetPath = symbolRange.FileName + + let! cancellationToken = CancellableTask.getCancellationToken () + + let targetDoc = + project.Solution.TryGetDocumentFromFSharpRange(symbolRange, project.Id) + + match targetDoc with + | None -> return false + | Some targetDoc -> + let! targetSource = targetDoc.GetTextAsync(cancellationToken) + let gtd = GoToDefinition(metadataAsSource) + + let (|Signature|Implementation|) filepath = + if isSignatureFile filepath then + Signature + else + Implementation + + match targetPath with + | Signature -> return! gtd.NavigateToSymbolDefinitionAsync(targetDoc, targetSource, symbolRange) + | Implementation -> return! gtd.NavigateToSymbolDeclarationAsync(targetDoc, targetSource, symbolRange) + } + |> CancellableTask.start cancellationToken + +[)>] +[)>] +type FSharpCrossLanguageSymbolNavigationService() = + let componentModel = + Package.GetGlobalService(typeof) :?> ComponentModelHost.IComponentModel + + let workspace = componentModel.GetService() + + let metadataAsSource = + componentModel.DefaultExportProvider.GetExport().Value + + let tryFindFieldByName (name: string) (e: FSharpEntity) = + let fields = + e.FSharpFields + |> Seq.filter (fun x -> x.DisplayName = name && not x.IsCompilerGenerated) + |> Seq.map (fun e -> e.DeclarationLocation) + + if fields.Count() <= 0 && (e.IsFSharpUnion || e.IsFSharpRecord) then + Seq.singleton e.DeclarationLocation + else + fields + + let tryFindValByNameAndType + (name: string) + (symbolMemberType: SymbolMemberType) + (genericParametersCount: int) + (e: FSharpEntity) + (entities: FSharpMemberOrFunctionOrValue seq) + = + + let defaultFilter (e: FSharpMemberOrFunctionOrValue) = + (e.DisplayName = name || e.CompiledName = name) + && e.GenericParameters.Count = genericParametersCount + + let isProperty (e: FSharpMemberOrFunctionOrValue) = defaultFilter e && e.IsProperty + let isConstructor (e: FSharpMemberOrFunctionOrValue) = defaultFilter e && e.IsConstructor + + let getLocation (e: FSharpMemberOrFunctionOrValue) = e.DeclarationLocation + + let filteredEntities: range seq = + match symbolMemberType with + | SymbolMemberType.Other + | SymbolMemberType.Method -> entities |> Seq.filter defaultFilter |> Seq.map getLocation + // F# record-specific logic, if navigating to the record's ctor, then navigate to record declaration. + // If we navigating to F# record property, we first check if it's "custom" property, if it's one of the record fields, we search for it in the fields. + | SymbolMemberType.Constructor when e.IsFSharpRecord -> Seq.singleton e.DeclarationLocation + | SymbolMemberType.Property when e.IsFSharpRecord -> + let properties = entities |> Seq.filter isProperty |> Seq.map getLocation + let fields = tryFindFieldByName name e + Seq.append properties fields + | SymbolMemberType.Constructor -> entities |> Seq.filter isConstructor |> Seq.map getLocation + // When navigating to property for the record, it will be in members bag for custom ones, but will be in the fields in fields. + | SymbolMemberType.Event // Events are just properties` + | SymbolMemberType.Property -> entities |> Seq.filter isProperty |> Seq.map getLocation + + filteredEntities + + let tryFindVal + (name: string) + (documentCommentId: string) + (symbolMemberType: SymbolMemberType) + (genericParametersCount: int) + (e: FSharpEntity) + = + let entities = e.TryGetMembersFunctionsAndValues() + + // First, try and find entity by exact xml signature, return if found, + // otherwise, just try and match by parsed name and number of arguments. + + let entitiesByXmlSig = + entities + |> Seq.filter (fun e -> e.XmlDocSig = documentCommentId) + |> Seq.map (fun e -> e.DeclarationLocation) + + if Seq.isEmpty entitiesByXmlSig then + tryFindValByNameAndType name symbolMemberType genericParametersCount e entities + else + entitiesByXmlSig + + /// Convert a documentation comment ID to a navigation path. + /// Uses the shared XmlDocSigParser from FSharp.Compiler.Symbols. + static member internal DocCommentIdToPath(docId: string) = + // Use the shared parser from FSharp.Compiler.Symbols + match XmlDocSigParser.parseDocCommentId docId with + | ParsedDocCommentId.Type path -> DocCommentId.Type path + + | ParsedDocCommentId.Member(typePath, memberName, genericArity, kind) -> + // Convert constructor name format (.ctor in parser, ``.ctor`` needed for F# lookup) + let memberOrValName = if memberName = ".ctor" then "``.ctor``" else memberName + + let symbolMemberType = + match kind with + | DocCommentIdKind.Method -> + if memberName = ".ctor" then + SymbolMemberType.Constructor + else + SymbolMemberType.Method + | DocCommentIdKind.Property -> SymbolMemberType.Property + | DocCommentIdKind.Event -> SymbolMemberType.Event + | _ -> SymbolMemberType.Other + + DocCommentId.Member( + { + EntityPath = typePath + MemberOrValName = memberOrValName + GenericParameters = genericArity + }, + symbolMemberType + ) + + | ParsedDocCommentId.Field(typePath, fieldName) -> + DocCommentId.Field + { + EntityPath = typePath + MemberOrValName = fieldName + GenericParameters = 0 + } + + | ParsedDocCommentId.None -> DocCommentId.None + + interface IFSharpCrossLanguageSymbolNavigationService with + member _.TryGetNavigableLocationAsync + (assemblyName: string, documentationCommentId: string, cancellationToken: CancellationToken) + : Task = + let path = + FSharpCrossLanguageSymbolNavigationService.DocCommentIdToPath documentationCommentId + + cancellableTask { + let projects = + workspace.CurrentSolution.Projects + |> Seq.filter (fun p -> p.IsFSharp && p.AssemblyName = assemblyName) + + let mutable locations = Seq.empty + + for project in projects do + let! checker, _, _, options = project.GetFSharpCompilationOptionsAsync() + let! result = checker.ParseAndCheckProject(options) + + match path with + | DocCommentId.Member({ + EntityPath = entityPath + MemberOrValName = memberOrVal + GenericParameters = genericParametersCount + }, + memberType) -> + let entity = result.AssemblySignature.FindEntityByPath(entityPath) + + entity + |> Option.iter (fun e -> + locations <- + e + |> tryFindVal memberOrVal documentationCommentId memberType genericParametersCount + |> Seq.map (fun m -> (m, project)) + |> Seq.append locations) + | DocCommentId.Field { + EntityPath = entityPath + MemberOrValName = memberOrVal + } -> + let entity = result.AssemblySignature.FindEntityByPath(entityPath) + + entity + |> Option.iter (fun e -> + locations <- + e + |> tryFindFieldByName memberOrVal + |> Seq.map (fun m -> (m, project)) + |> Seq.append locations) + | DocCommentId.Type entityPath -> + let entity = result.AssemblySignature.FindEntityByPath(entityPath) + + entity + |> Option.iter (fun e -> locations <- Seq.append locations [ e.DeclarationLocation, project ]) + | DocCommentId.None -> () + + // TODO: Figure out the way of giving the user choice where to navigate, if there are more than one result + // For now, we only take 1st one, since it's usually going to be only one result (given we process names correctly). + // More results can theoretically be returned in case of method overloads, or when we have both signature and implementation files. + if locations.Count() >= 1 then + let (location, project) = locations.First() + return FSharpNavigableLocation(metadataAsSource, location, project) :> IFSharpNavigableLocation + else + return Unchecked.defaultof<_> // returning null here, so Roslyn can fallback to default source-as-metadata implementation. + } + |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs b/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs index 6bc86ae57a3..4903a46cf23 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs @@ -822,239 +822,3 @@ type internal FSharpNavigation(metadataAsSource: FSharpMetadataAsSourceService, with exc -> TelemetryReporter.ReportFault(TelemetryEvents.GoToDefinition, FaultSeverity.General, exc) false - -[] -type internal SymbolMemberType = - | Event - | Property - | Method - | Constructor - | Other - -type internal SymbolPath = - { - EntityPath: string list - MemberOrValName: string - GenericParameters: int - } - -[] -type internal DocCommentId = - | Member of SymbolPath * SymbolMemberType: SymbolMemberType - | Field of SymbolPath - | Type of EntityPath: string list - | None - -type FSharpNavigableLocation(metadataAsSource: FSharpMetadataAsSourceService, symbolRange: range, project: Project) = - interface IFSharpNavigableLocation with - member _.NavigateToAsync(_options: FSharpNavigationOptions2, cancellationToken: CancellationToken) : Task = - cancellableTask { - let targetPath = symbolRange.FileName - - let! cancellationToken = CancellableTask.getCancellationToken () - - let targetDoc = - project.Solution.TryGetDocumentFromFSharpRange(symbolRange, project.Id) - - match targetDoc with - | None -> return false - | Some targetDoc -> - let! targetSource = targetDoc.GetTextAsync(cancellationToken) - let gtd = GoToDefinition(metadataAsSource) - - let (|Signature|Implementation|) filepath = - if isSignatureFile filepath then - Signature - else - Implementation - - match targetPath with - | Signature -> return! gtd.NavigateToSymbolDefinitionAsync(targetDoc, targetSource, symbolRange) - | Implementation -> return! gtd.NavigateToSymbolDeclarationAsync(targetDoc, targetSource, symbolRange) - } - |> CancellableTask.start cancellationToken - -[)>] -[)>] -type FSharpCrossLanguageSymbolNavigationService() = - let componentModel = - Package.GetGlobalService(typeof) :?> ComponentModelHost.IComponentModel - - let workspace = componentModel.GetService() - - let metadataAsSource = - componentModel.DefaultExportProvider.GetExport().Value - - let tryFindFieldByName (name: string) (e: FSharpEntity) = - let fields = - e.FSharpFields - |> Seq.filter (fun x -> x.DisplayName = name && not x.IsCompilerGenerated) - |> Seq.map (fun e -> e.DeclarationLocation) - - if fields.Count() <= 0 && (e.IsFSharpUnion || e.IsFSharpRecord) then - Seq.singleton e.DeclarationLocation - else - fields - - let tryFindValByNameAndType - (name: string) - (symbolMemberType: SymbolMemberType) - (genericParametersCount: int) - (e: FSharpEntity) - (entities: FSharpMemberOrFunctionOrValue seq) - = - - let defaultFilter (e: FSharpMemberOrFunctionOrValue) = - (e.DisplayName = name || e.CompiledName = name) - && e.GenericParameters.Count = genericParametersCount - - let isProperty (e: FSharpMemberOrFunctionOrValue) = defaultFilter e && e.IsProperty - let isConstructor (e: FSharpMemberOrFunctionOrValue) = defaultFilter e && e.IsConstructor - - let getLocation (e: FSharpMemberOrFunctionOrValue) = e.DeclarationLocation - - let filteredEntities: range seq = - match symbolMemberType with - | SymbolMemberType.Other - | SymbolMemberType.Method -> entities |> Seq.filter defaultFilter |> Seq.map getLocation - // F# record-specific logic, if navigating to the record's ctor, then navigate to record declaration. - // If we navigating to F# record property, we first check if it's "custom" property, if it's one of the record fields, we search for it in the fields. - | SymbolMemberType.Constructor when e.IsFSharpRecord -> Seq.singleton e.DeclarationLocation - | SymbolMemberType.Property when e.IsFSharpRecord -> - let properties = entities |> Seq.filter isProperty |> Seq.map getLocation - let fields = tryFindFieldByName name e - Seq.append properties fields - | SymbolMemberType.Constructor -> entities |> Seq.filter isConstructor |> Seq.map getLocation - // When navigating to property for the record, it will be in members bag for custom ones, but will be in the fields in fields. - | SymbolMemberType.Event // Events are just properties` - | SymbolMemberType.Property -> entities |> Seq.filter isProperty |> Seq.map getLocation - - filteredEntities - - let tryFindVal - (name: string) - (documentCommentId: string) - (symbolMemberType: SymbolMemberType) - (genericParametersCount: int) - (e: FSharpEntity) - = - let entities = e.TryGetMembersFunctionsAndValues() - - // First, try and find entity by exact xml signature, return if found, - // otherwise, just try and match by parsed name and number of arguments. - - let entitiesByXmlSig = - entities - |> Seq.filter (fun e -> e.XmlDocSig = documentCommentId) - |> Seq.map (fun e -> e.DeclarationLocation) - - if Seq.isEmpty entitiesByXmlSig then - tryFindValByNameAndType name symbolMemberType genericParametersCount e entities - else - entitiesByXmlSig - - /// Convert a documentation comment ID to a navigation path. - /// Uses the shared XmlDocSigParser from FSharp.Compiler.Symbols. - static member internal DocCommentIdToPath(docId: string) = - // Use the shared parser from FSharp.Compiler.Symbols - match XmlDocSigParser.parseDocCommentId docId with - | ParsedDocCommentId.Type path -> DocCommentId.Type path - - | ParsedDocCommentId.Member(typePath, memberName, genericArity, kind) -> - // Convert constructor name format (.ctor in parser, ``.ctor`` needed for F# lookup) - let memberOrValName = if memberName = ".ctor" then "``.ctor``" else memberName - - let symbolMemberType = - match kind with - | DocCommentIdKind.Method -> - if memberName = ".ctor" then - SymbolMemberType.Constructor - else - SymbolMemberType.Method - | DocCommentIdKind.Property -> SymbolMemberType.Property - | DocCommentIdKind.Event -> SymbolMemberType.Event - | _ -> SymbolMemberType.Other - - DocCommentId.Member( - { - EntityPath = typePath - MemberOrValName = memberOrValName - GenericParameters = genericArity - }, - symbolMemberType - ) - - | ParsedDocCommentId.Field(typePath, fieldName) -> - DocCommentId.Field - { - EntityPath = typePath - MemberOrValName = fieldName - GenericParameters = 0 - } - - | ParsedDocCommentId.None -> DocCommentId.None - - interface IFSharpCrossLanguageSymbolNavigationService with - member _.TryGetNavigableLocationAsync - (assemblyName: string, documentationCommentId: string, cancellationToken: CancellationToken) - : Task = - let path = - FSharpCrossLanguageSymbolNavigationService.DocCommentIdToPath documentationCommentId - - cancellableTask { - let projects = - workspace.CurrentSolution.Projects - |> Seq.filter (fun p -> p.IsFSharp && p.AssemblyName = assemblyName) - - let mutable locations = Seq.empty - - for project in projects do - let! checker, _, _, options = project.GetFSharpCompilationOptionsAsync() - let! result = checker.ParseAndCheckProject(options) - - match path with - | DocCommentId.Member({ - EntityPath = entityPath - MemberOrValName = memberOrVal - GenericParameters = genericParametersCount - }, - memberType) -> - let entity = result.AssemblySignature.FindEntityByPath(entityPath) - - entity - |> Option.iter (fun e -> - locations <- - e - |> tryFindVal memberOrVal documentationCommentId memberType genericParametersCount - |> Seq.map (fun m -> (m, project)) - |> Seq.append locations) - | DocCommentId.Field { - EntityPath = entityPath - MemberOrValName = memberOrVal - } -> - let entity = result.AssemblySignature.FindEntityByPath(entityPath) - - entity - |> Option.iter (fun e -> - locations <- - e - |> tryFindFieldByName memberOrVal - |> Seq.map (fun m -> (m, project)) - |> Seq.append locations) - | DocCommentId.Type entityPath -> - let entity = result.AssemblySignature.FindEntityByPath(entityPath) - - entity - |> Option.iter (fun e -> locations <- Seq.append locations [ e.DeclarationLocation, project ]) - | DocCommentId.None -> () - - // TODO: Figure out the way of giving the user choice where to navigate, if there are more than one result - // For now, we only take 1st one, since it's usually going to be only one result (given we process names correctly). - // More results can theoretically be returned in case of method overloads, or when we have both signature and implementation files. - if locations.Count() >= 1 then - let (location, project) = locations.First() - return FSharpNavigableLocation(metadataAsSource, location, project) :> IFSharpNavigableLocation - else - return Unchecked.defaultof<_> // returning null here, so Roslyn can fallback to default source-as-metadata implementation. - } - |> CancellableTask.start cancellationToken From 6d920efdf5db130ed62b6fe3de579214f4912d71 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:27:04 +0200 Subject: [PATCH 2/6] Locate C# and VB navigation targets in F# without checking the whole project Go To Definition from C# or Visual Basic into an F# project ran a full ParseAndCheckProject for every target-framework instance of the project whose assembly name matched, on every keystroke of F12 and without any cache. Cold, that exceeded the time Roslyn waits for a cross-language location and it fell back to its own decompiled view. The parsed navigable items of a document name every type and module it declares, so the files that can hold the declaration are known from parse results alone, without a type check. Only those files are checked, one at a time in compile order, and the member is matched by its exact compiled id first, with the name-and-shape heuristics reserved for the last candidate whose partial signature holds every member of the entity. The whole-project check remains the fallback. One instance per project file goes first; the service gets its dependencies through the MEF constructor so the lookup runs against a plain Solution in tests. Co-Authored-By: Claude Fable 5.1 --- .../FSharp.Editor/Common/CancellableTasks.fs | 11 + .../CrossLanguageSymbolNavigation.fs | 377 ++++++++++++------ 2 files changed, 263 insertions(+), 125 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs index 7520395a084..07d96a8b421 100644 --- a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs +++ b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs @@ -1147,6 +1147,17 @@ module CancellableTasks = return results } + /// Runs the chooser over the items one at a time and stops at the first ValueSome. + let rec tryPick (chooser: 'T -> CancellableTask<'U voption>) (items: 'T list) : CancellableTask<'U voption> = + match items with + | [] -> singleton ValueNone + | item :: rest -> + cancellableTask { + match! chooser item with + | ValueSome picked -> return ValueSome picked + | ValueNone -> return! tryPick chooser rest + } + let inline ignore ([] ctask: CancellableTask<_>) = toUnit ctask /// If this CancellableTask gets canceled for another reason than the token being canceled, return the specified value. diff --git a/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs b/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs index 60e8ecd0e16..af7d535c7d6 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs @@ -3,17 +3,16 @@ namespace Microsoft.VisualStudio.FSharp.Editor open System +open System.Collections.Generic open System.Composition -open System.Linq open System.Threading open System.Threading.Tasks open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation -open Microsoft.VisualStudio -open Microsoft.VisualStudio.Shell open Microsoft.VisualStudio.LanguageServices +open FSharp.Compiler.EditorServices open FSharp.Compiler.Symbols open FSharp.Compiler.Text open CancellableTasks @@ -69,29 +68,64 @@ type FSharpNavigableLocation(metadataAsSource: FSharpMetadataAsSourceService, sy } |> CancellableTask.start cancellationToken -[)>] -[)>] -type FSharpCrossLanguageSymbolNavigationService() = - let componentModel = - Package.GetGlobalService(typeof) :?> ComponentModelHost.IComponentModel +/// Locates the F# declaration Roslyn names by assembly and documentation comment id when C# or +/// Visual Basic navigates into an F# project. Kept apart from the MEF service so it can be exercised +/// without a Visual Studio workspace. +module internal CrossLanguageSymbolNavigation = + + [] + let private UserOpName = "CrossLanguageSymbolNavigation" - let workspace = componentModel.GetService() + let docCommentIdToPath (docId: string) = + match XmlDocSigParser.parseDocCommentId docId with + | ParsedDocCommentId.Type path -> DocCommentId.Type path - let metadataAsSource = - componentModel.DefaultExportProvider.GetExport().Value + | ParsedDocCommentId.Member(typePath, memberName, genericArity, kind) -> + // The parser reports constructors as .ctor; the F# lookup needs the backticked form. + let memberOrValName = if memberName = ".ctor" then "``.ctor``" else memberName + + let symbolMemberType = + match kind with + | DocCommentIdKind.Method -> + if memberName = ".ctor" then + SymbolMemberType.Constructor + else + SymbolMemberType.Method + | DocCommentIdKind.Property -> SymbolMemberType.Property + | DocCommentIdKind.Event -> SymbolMemberType.Event + | _ -> SymbolMemberType.Other - let tryFindFieldByName (name: string) (e: FSharpEntity) = + DocCommentId.Member( + { + EntityPath = typePath + MemberOrValName = memberOrValName + GenericParameters = genericArity + }, + symbolMemberType + ) + + | ParsedDocCommentId.Field(typePath, fieldName) -> + DocCommentId.Field + { + EntityPath = typePath + MemberOrValName = fieldName + GenericParameters = 0 + } + + | ParsedDocCommentId.None -> DocCommentId.None + + let private tryFindFieldByName (name: string) (e: FSharpEntity) = let fields = e.FSharpFields |> Seq.filter (fun x -> x.DisplayName = name && not x.IsCompilerGenerated) |> Seq.map (fun e -> e.DeclarationLocation) - if fields.Count() <= 0 && (e.IsFSharpUnion || e.IsFSharpRecord) then + if Seq.isEmpty fields && (e.IsFSharpUnion || e.IsFSharpRecord) then Seq.singleton e.DeclarationLocation else fields - let tryFindValByNameAndType + let private tryFindValByNameAndType (name: string) (symbolMemberType: SymbolMemberType) (genericParametersCount: int) @@ -126,130 +160,223 @@ type FSharpCrossLanguageSymbolNavigationService() = filteredEntities - let tryFindVal - (name: string) - (documentCommentId: string) - (symbolMemberType: SymbolMemberType) - (genericParametersCount: int) - (e: FSharpEntity) + /// The members of the entity the id names: those whose compiled id matches exactly and, when + /// `byShape`, those whose name, kind and arity fit when no id matched. + let private memberLocations + (byShape: bool) + (documentationCommentId: string) + (symbolPath: SymbolPath) + (memberType: SymbolMemberType) + (entity: FSharpEntity) = - let entities = e.TryGetMembersFunctionsAndValues() + let members = entity.TryGetMembersFunctionsAndValues() - // First, try and find entity by exact xml signature, return if found, - // otherwise, just try and match by parsed name and number of arguments. - - let entitiesByXmlSig = - entities - |> Seq.filter (fun e -> e.XmlDocSig = documentCommentId) - |> Seq.map (fun e -> e.DeclarationLocation) + let exact = + members + |> Seq.filter (fun m -> m.XmlDocSig = documentationCommentId) + |> Seq.map _.DeclarationLocation - if Seq.isEmpty entitiesByXmlSig then - tryFindValByNameAndType name symbolMemberType genericParametersCount e entities + if byShape && Seq.isEmpty exact then + tryFindValByNameAndType symbolPath.MemberOrValName memberType symbolPath.GenericParameters entity members else - entitiesByXmlSig - - /// Convert a documentation comment ID to a navigation path. - /// Uses the shared XmlDocSigParser from FSharp.Compiler.Symbols. - static member internal DocCommentIdToPath(docId: string) = - // Use the shared parser from FSharp.Compiler.Symbols - match XmlDocSigParser.parseDocCommentId docId with - | ParsedDocCommentId.Type path -> DocCommentId.Type path - - | ParsedDocCommentId.Member(typePath, memberName, genericArity, kind) -> - // Convert constructor name format (.ctor in parser, ``.ctor`` needed for F# lookup) - let memberOrValName = if memberName = ".ctor" then "``.ctor``" else memberName - - let symbolMemberType = - match kind with - | DocCommentIdKind.Method -> - if memberName = ".ctor" then - SymbolMemberType.Constructor + exact + + let private declarationsIn (byShape: bool) (signature: FSharpAssemblySignature) (documentationCommentId: string) (path: DocCommentId) = + let inEntity entityPath (locationsOf: FSharpEntity -> range seq) = + signature.FindEntityByPath entityPath + |> Option.map locationsOf + |> Option.defaultValue Seq.empty + + match path with + | DocCommentId.Member(symbolPath, memberType) -> + inEntity symbolPath.EntityPath (memberLocations byShape documentationCommentId symbolPath memberType) + | DocCommentId.Field symbolPath -> inEntity symbolPath.EntityPath (tryFindFieldByName symbolPath.MemberOrValName) + | DocCommentId.Type entityPath -> inEntity entityPath (fun entity -> Seq.singleton entity.DeclarationLocation) + | DocCommentId.None -> Seq.empty + + let private entityPathOf (path: DocCommentId) = + match path with + | DocCommentId.Member(symbolPath, _) + | DocCommentId.Field symbolPath -> symbolPath.EntityPath + | DocCommentId.Type entityPath -> entityPath + | DocCommentId.None -> [] + + /// A compiled segment of a doc id against a source segment: the generic arity suffix and the + /// `Module` suffix of `CompilationRepresentation(ModuleSuffix)` exist only in compiled names. + let private segmentMatches (compiled: ReadOnlySpan) (source: ReadOnlySpan) = + let compiled = + match compiled.IndexOf '`' with + | -1 -> compiled + | arity -> compiled.Slice(0, arity) + + compiled.Equals(source, StringComparison.Ordinal) + || (compiled.Length = source.Length + "Module".Length + && compiled.Slice(0, source.Length).Equals(source, StringComparison.Ordinal) + && compiled.Slice(source.Length).Equals("Module".AsSpan(), StringComparison.Ordinal)) + + let rec private pathMatches (entityPath: string list) (source: ReadOnlySpan) = + match entityPath with + | [] -> source.IsEmpty + | [ last ] -> source.IndexOf '.' = -1 && segmentMatches (last.AsSpan()) source + | segment :: rest -> + match source.IndexOf '.' with + | -1 -> false + | dot -> + segmentMatches (segment.AsSpan()) (source.Slice(0, dot)) + && pathMatches rest (source.Slice(dot + 1)) + + /// Whether the parsed item declares the entity the doc id names. + let declaresEntity (entityPath: string list) (item: NavigableItem) = + match item.Kind with + | NavigableItemKind.Module + | NavigableItemKind.Type + | NavigableItemKind.Exception -> + match item.Container.FullName with + | "" -> pathMatches entityPath (item.Name.AsSpan()) + | container -> pathMatches entityPath ($"{container}.{item.Name}".AsSpan()) + | _ -> false + + /// The project's documents whose parse tree declares the entity, in compile order. + let candidateDocuments (entityPath: string list) (project: Project) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! _, _, _, options = project.GetFSharpCompilationOptionsAsync() + + let compileOrder = Dictionary(StringComparer.OrdinalIgnoreCase) + + options.SourceFiles + |> Array.iteri (fun index path -> compileOrder[path] <- index) + + let declaresIn (document: Document) = + cancellableTask { + ct.ThrowIfCancellationRequested() + let! parseResults = document.GetFSharpParseResultsAsync UserOpName + + if + NavigateTo.GetNavigableItems parseResults.ParseTree + |> Array.exists (declaresEntity entityPath) + then + return ValueSome document else - SymbolMemberType.Method - | DocCommentIdKind.Property -> SymbolMemberType.Property - | DocCommentIdKind.Event -> SymbolMemberType.Event - | _ -> SymbolMemberType.Other + return ValueNone + } - DocCommentId.Member( - { - EntityPath = typePath - MemberOrValName = memberOrValName - GenericParameters = genericArity - }, - symbolMemberType - ) + let! candidates = + project.Documents + |> Seq.filter (fun document -> isFSharpSourceFile document.FilePath) + |> Seq.map declaresIn + // Throttle to avoid launching a parse per document in the project all at once. + |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) + + return + candidates + |> Array.chooseV id + |> Array.sortBy (fun document -> + match compileOrder.TryGetValue document.FilePath with + | true, index -> index + | _ -> Int32.MaxValue) + |> List.ofArray + } + + let private tryLocateInDocument (byShape: bool) (documentationCommentId: string) (path: DocCommentId) (document: Document) = + cancellableTask { + let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync UserOpName + + return + declarationsIn byShape checkResults.PartialAssemblySignature documentationCommentId path + |> Seq.tryHeadV + } + + /// Checks only the documents that declare the entity. An exact id match in any of them wins; + /// the name-and-shape heuristics run only on the last one, whose partial signature holds every + /// member the entity gets from the files that declare it. + let tryLocateViaNavigableItems (documentationCommentId: string) (path: DocCommentId) (project: Project) = + cancellableTask { + let! candidates = candidateDocuments (entityPathOf path) project + + match! + candidates + |> CancellableTask.tryPick (tryLocateInDocument false documentationCommentId path) + with + | ValueSome range -> return ValueSome range + | ValueNone -> + match List.tryLast candidates with + | Some last -> return! tryLocateInDocument true documentationCommentId path last + | None -> return ValueNone + } + + /// Checks the whole project. + let tryLocateInProject (documentationCommentId: string) (path: DocCommentId) (project: Project) = + cancellableTask { + let! checker, _, _, options = project.GetFSharpCompilationOptionsAsync() + let! result = checker.ParseAndCheckProject(options) + + return + declarationsIn true result.AssemblySignature documentationCommentId path + |> Seq.tryHeadV + } + + /// The declaration's range and the project holding it, for the assembly name and doc id Roslyn passes. + let tryFindDeclaration (solution: Solution) (assemblyName: string) (documentationCommentId: string) = + match docCommentIdToPath documentationCommentId with + | DocCommentId.None -> CancellableTask.singleton ValueNone + | path -> + cancellableTask { + // The target frameworks of one project declare the same entities in the same files apart + // from conditional compilation, so one instance per project file goes first. + let instances = + solution.Projects + |> Seq.filter (fun p -> p.IsFSharp && p.AssemblyName = assemblyName) + |> Seq.groupBy _.FilePath + |> Seq.map (snd >> List.ofSeq) + |> List.ofSeq + + let ordered = + [ + for instance in instances -> instance.Head + for instance in instances do + yield! instance.Tail + ] + + let located (locate: Project -> CancellableTask) (project: Project) = + locate project + |> CancellableTask.map (ValueOption.map (fun range -> struct (range, project))) + + match! + ordered + |> CancellableTask.tryPick (located (tryLocateViaNavigableItems documentationCommentId path)) + with + | ValueSome found -> return ValueSome found + | ValueNone -> + return! + ordered + |> CancellableTask.tryPick (located (tryLocateInProject documentationCommentId path)) + } - | ParsedDocCommentId.Field(typePath, fieldName) -> - DocCommentId.Field - { - EntityPath = typePath - MemberOrValName = fieldName - GenericParameters = 0 - } +[)>] +[)>] +type internal FSharpCrossLanguageSymbolNavigationService + [] + (metadataAsSource: FSharpMetadataAsSourceService, [] workspace: VisualStudioWorkspace) = - | ParsedDocCommentId.None -> DocCommentId.None + static member internal DocCommentIdToPath(docId: string) = + CrossLanguageSymbolNavigation.docCommentIdToPath docId interface IFSharpCrossLanguageSymbolNavigationService with member _.TryGetNavigableLocationAsync (assemblyName: string, documentationCommentId: string, cancellationToken: CancellationToken) : Task = - let path = - FSharpCrossLanguageSymbolNavigationService.DocCommentIdToPath documentationCommentId - cancellableTask { - let projects = - workspace.CurrentSolution.Projects - |> Seq.filter (fun p -> p.IsFSharp && p.AssemblyName = assemblyName) - - let mutable locations = Seq.empty - - for project in projects do - let! checker, _, _, options = project.GetFSharpCompilationOptionsAsync() - let! result = checker.ParseAndCheckProject(options) - - match path with - | DocCommentId.Member({ - EntityPath = entityPath - MemberOrValName = memberOrVal - GenericParameters = genericParametersCount - }, - memberType) -> - let entity = result.AssemblySignature.FindEntityByPath(entityPath) - - entity - |> Option.iter (fun e -> - locations <- - e - |> tryFindVal memberOrVal documentationCommentId memberType genericParametersCount - |> Seq.map (fun m -> (m, project)) - |> Seq.append locations) - | DocCommentId.Field { - EntityPath = entityPath - MemberOrValName = memberOrVal - } -> - let entity = result.AssemblySignature.FindEntityByPath(entityPath) - - entity - |> Option.iter (fun e -> - locations <- - e - |> tryFindFieldByName memberOrVal - |> Seq.map (fun m -> (m, project)) - |> Seq.append locations) - | DocCommentId.Type entityPath -> - let entity = result.AssemblySignature.FindEntityByPath(entityPath) - - entity - |> Option.iter (fun e -> locations <- Seq.append locations [ e.DeclarationLocation, project ]) - | DocCommentId.None -> () - - // TODO: Figure out the way of giving the user choice where to navigate, if there are more than one result - // For now, we only take 1st one, since it's usually going to be only one result (given we process names correctly). - // More results can theoretically be returned in case of method overloads, or when we have both signature and implementation files. - if locations.Count() >= 1 then - let (location, project) = locations.First() - return FSharpNavigableLocation(metadataAsSource, location, project) :> IFSharpNavigableLocation - else - return Unchecked.defaultof<_> // returning null here, so Roslyn can fallback to default source-as-metadata implementation. + match workspace with + | null -> return null + | workspace -> + match! + CrossLanguageSymbolNavigation.tryFindDeclaration workspace.CurrentSolution assemblyName documentationCommentId + with + | ValueSome(struct (range, project)) -> + return FSharpNavigableLocation(metadataAsSource, range, project) :> IFSharpNavigableLocation + | ValueNone -> + // Roslyn falls back to its own metadata-as-source when no location comes back. + return null } |> CancellableTask.start cancellationToken From 509898209b2c5c18c00a651d5caa95d31385a78a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 11:27:05 +0200 Subject: [PATCH 3/6] Test cross-language navigation into F# declarations Co-Authored-By: Claude Fable 5.1 --- .../CrossLanguageSymbolNavigationTests.fs | 178 ++++++++++++++++++ .../FSharp.Editor.Tests.fsproj | 1 + 2 files changed, 179 insertions(+) create mode 100644 vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs diff --git a/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs new file mode 100644 index 00000000000..fbcee68093f --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module FSharp.Editor.Tests.CrossLanguageSymbolNavigationTests + +open System +open System.Threading +open Xunit +open Microsoft.CodeAnalysis +open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks +open FSharp.Compiler.EditorServices +open FSharp.Compiler.Text +open FSharp.Editor.Tests.Helpers +open FSharp.Test.ProjectGeneration + +let private source = + """ +module Widgets + +type Counter(start: int) = + member val Value = start with get, set + member this.Bump() = this.Value <- this.Value + 1 + +let twice x = x * 2 + +[] +let thrice x = x * 3 + +type Shape = + | Circle of radius: float + | Square of side: float + +[] +module Shape = + let area shape = + match shape with + | Circle r -> Math.PI * r * r + | Square s -> s * s + +type Box<'T> = { Value: 'T } + +type Point = { X: int; Y: int } + +exception MyError of string +""" + +let private document = RoslynTestHelpers.GetFsDocument source +let private project = document.Project + +let private run computation = + computation |> CancellableTask.start CancellationToken.None |> _.Result + +/// The 1-based line of the first source line containing the text. +let private lineOf (text: string) = + source.Split('\n') + |> Array.findIndex (fun line -> line.IndexOf(text, StringComparison.Ordinal) >= 0) + |> (+) 1 + +let private items = + document.GetFSharpParseResultsAsync "test" + |> run + |> _.ParseTree + |> NavigateTo.GetNavigableItems + +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +let ``the fast path finds the declaration and agrees with the whole project check`` (docId: string, declaration: string) = + let path = CrossLanguageSymbolNavigation.docCommentIdToPath docId + + let fast = + CrossLanguageSymbolNavigation.tryLocateViaNavigableItems docId path project + |> run + + let full = + CrossLanguageSymbolNavigation.tryLocateInProject docId path project |> run + + match fast, full with + | ValueSome fast, ValueSome full -> + Assert.Equal(full, fast) + Assert.Equal(document.FilePath, fast.FileName) + Assert.Equal(lineOf declaration, fast.StartLine) + | fast, full -> failwith $"fast path: %A{fast}, whole project: %A{full}" + +[] +[] +[] +[] +let ``an unknown or malformed id yields no location`` (docId: string) = + let found = + CrossLanguageSymbolNavigation.tryFindDeclaration project.Solution project.AssemblyName docId + |> run + + Assert.True(found.IsNone, $"%A{found}") + +[] +[] +[] +[] +[] +[] +let ``the parsed declarations of an entity are recognised through compiled-name artifacts`` (entityPath: string, declaration: string) = + let declaring = + items + |> Array.filter (CrossLanguageSymbolNavigation.declaresEntity (List.ofArray (entityPath.Split '.'))) + |> Array.map _.Name + + Assert.Equal([ declaration ], List.ofArray declaring |> List.distinct) + +[] +let ``members and unknown entities never pass as declarations`` () = + for entityPath in [ [ "Widgets"; "twice" ]; [ "Widgets"; "Counter"; "Bump" ]; [ "Nope" ] ] do + Assert.Empty(items |> Array.filter (CrossLanguageSymbolNavigation.declaresEntity entityPath)) + +[] +let ``candidate documents come in compile order, signature first`` () = + let syntheticProject = + SyntheticProject.Create( + sourceFile "First" [], + { sourceFile "Second" [] with + SignatureFile = AutoGenerated + }, + sourceFile "Third" [ "Second" ] + ) + + let solution, _ = RoslynTestHelpers.CreateSolution syntheticProject + let project = solution.Projects |> Seq.exactlyOne + + let candidates = + CrossLanguageSymbolNavigation.candidateDocuments [ syntheticProject.Name; "ModuleSecond" ] project + |> run + |> List.map _.FilePath + + Assert.Equal( + [ + syntheticProject.GetSignatureFilePath "Second" + syntheticProject.GetFilePath "Second" + ], + candidates + ) + +[] +let ``the first instance of a multi-targeted project answers`` () = + let instance () = + let id = ProjectId.CreateNewId() + + id, RoslynTestHelpers.CreateProjectInfo id "C:\\test.fsproj" [ RoslynTestHelpers.CreateDocumentInfo id "C:\\test.fs" source ] + + let firstId, first = instance () + let secondId, second = instance () + let solution = RoslynTestHelpers.CreateSolution [ first; second ] + + let options = + { RoslynTestHelpers.DefaultProjectOptions with + OtherOptions = [| "--targetprofile:netcore"; "--nowarn:3384" |] + } + + for id in [ firstId; secondId ] do + RoslynTestHelpers.SetProjectOptions id solution options + + let found = + CrossLanguageSymbolNavigation.tryFindDeclaration solution "test.dll" "M:Widgets.twice(System.Int32)" + |> run + + match found with + | ValueSome(struct (_, project)) -> Assert.Equal(firstId, project.Id) + | ValueNone -> failwith "declaration not found" diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..c1a856f0d67 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -32,6 +32,7 @@ + From ca474e9288e577b0277f9c3140eca9bdbfa2366f Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 13:55:41 +0200 Subject: [PATCH 4/6] Add the release note for PR #20465 --- 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 ba03f663967..1a4ed7f21d4 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -15,6 +15,7 @@ * Fix doubled F# diagnostics in tooltips. ([Issue #16360](https://github.com/dotnet/fsharp/issues/16360)) * Fix `NotSupportedException` in the memory-mapped-file optimization when copying `ReadOnlyMemory` into `MemoryMappedFileViewStream`. ([Issue #20263](https://github.com/dotnet/fsharp/issues/20263)) * Reduce allocations in the VS project options reactor: the command-line options and project options caches and the mailbox reply payloads now hold struct tuples, and `IProjectSite.CompilationBinOutputPath` returns `string voption` picked with a new `Array.tryPickV`. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413)) +* Go To Definition from C# or Visual Basic into an F# project no longer type checks the whole project: the file declaring the symbol is found from parsed declarations and checked alone, with the whole-project check as a fallback. ([PR #20465](https://github.com/dotnet/fsharp/pull/20465)) ### Changed From 34011522bb09aa00507e3ac271d02faf02bab63a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 18:40:41 +0200 Subject: [PATCH 5/6] Navigate to union cases and module literals from their compiled members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go To Definition from C# on `Shape.NewCircle(…)`, `shape.IsCircle` or a nullary case property found no F# declaration: the compiled members of a union case are not among the entity's members, so both the exact and the shape lookup came back empty and Roslyn decompiled instead. A module literal has the same fate: C# sees a const field, and the F# side only searched the entity's fields. Both now map back to their declaration. Co-Authored-By: Claude Fable 5.1 --- .../CrossLanguageSymbolNavigation.fs | 39 ++++++++++++++++--- .../CrossLanguageSymbolNavigationTests.fs | 9 +++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs b/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs index af7d535c7d6..bff1e56a73f 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs @@ -114,16 +114,26 @@ module internal CrossLanguageSymbolNavigation = | ParsedDocCommentId.None -> DocCommentId.None + /// The fields of the entity, and the literals of a module, which compile to fields. let private tryFindFieldByName (name: string) (e: FSharpEntity) = let fields = e.FSharpFields |> Seq.filter (fun x -> x.DisplayName = name && not x.IsCompilerGenerated) - |> Seq.map (fun e -> e.DeclarationLocation) + |> Seq.map _.DeclarationLocation + + let literals = + e.TryGetMembersFunctionsAndValues() + |> Seq.filter (fun v -> v.LiteralValue.IsSome && (v.CompiledName = name || v.DisplayName = name)) + |> Seq.map _.DeclarationLocation - if Seq.isEmpty fields && (e.IsFSharpUnion || e.IsFSharpRecord) then + if + Seq.isEmpty fields + && Seq.isEmpty literals + && (e.IsFSharpUnion || e.IsFSharpRecord) + then Seq.singleton e.DeclarationLocation else - fields + Seq.append fields literals let private tryFindValByNameAndType (name: string) @@ -160,6 +170,18 @@ module internal CrossLanguageSymbolNavigation = filteredEntities + /// The union case behind its compiled members: the `NewCase` factory, the `IsCase` tester and + /// the `Case` property of a nullary case. + let private unionCaseLocations (name: string) (entity: FSharpEntity) = + if entity.IsFSharpUnion then + entity.UnionCases + |> Seq.filter (fun unionCase -> + let compiled = unionCase.CompiledName + name = compiled || name = $"New{compiled}" || name = $"Is{compiled}") + |> Seq.map _.DeclarationLocation + else + Seq.empty + /// The members of the entity the id names: those whose compiled id matches exactly and, when /// `byShape`, those whose name, kind and arity fit when no id matched. let private memberLocations @@ -172,9 +194,14 @@ module internal CrossLanguageSymbolNavigation = let members = entity.TryGetMembersFunctionsAndValues() let exact = - members - |> Seq.filter (fun m -> m.XmlDocSig = documentationCommentId) - |> Seq.map _.DeclarationLocation + seq { + yield! + members + |> Seq.filter (fun m -> m.XmlDocSig = documentationCommentId) + |> Seq.map _.DeclarationLocation + + yield! unionCaseLocations symbolPath.MemberOrValName entity + } if byShape && Seq.isEmpty exact then tryFindValByNameAndType symbolPath.MemberOrValName memberType symbolPath.GenericParameters entity members diff --git a/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs index fbcee68093f..c68f9abec9a 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs @@ -29,6 +29,7 @@ let thrice x = x * 3 type Shape = | Circle of radius: float | Square of side: float + | Dot [] module Shape = @@ -36,6 +37,10 @@ module Shape = match shape with | Circle r -> Math.PI * r * r | Square s -> s * s + | Dot -> 0.0 + +[] +let Answer = 42 type Box<'T> = { Value: 'T } @@ -71,6 +76,10 @@ let private items = [] [] [] +[] +[] +[] +[] [] [] [] From a38af57dd324800d2829ca2f1d72fba8aa52ce12 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 18:02:47 +0200 Subject: [PATCH 6/6] Test the fast path's document scope directly One test checks that a composed multi-file project narrows to the single file declaring the entity, among others that do not; the other covers a multi-targeted project whose first instance does not declare the entity at all under conditional compilation, and only a later one does. Co-Authored-By: Claude Sonnet 5 --- .../CrossLanguageSymbolNavigationTests.fs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs index c68f9abec9a..ce5c3d23bf5 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs @@ -185,3 +185,77 @@ let ``the first instance of a multi-targeted project answers`` () = match found with | ValueSome(struct (_, project)) -> Assert.Equal(firstId, project.Id) | ValueNone -> failwith "declaration not found" + +[] +let ``candidate documents narrow to the file that declares the entity among several`` () = + let syntheticProject = + SyntheticProject.Create( + sourceFile "First" [], + { sourceFile "Second" [] with + ExtraSource = "let onlyHere x = x * 5\n" + }, + sourceFile "Third" [] + ) + + let solution, _ = RoslynTestHelpers.CreateSolution syntheticProject + let project = solution.Projects |> Seq.exactlyOne + + let candidates = + CrossLanguageSymbolNavigation.candidateDocuments [ syntheticProject.Name; "ModuleSecond" ] project + |> run + |> List.map _.FilePath + + Assert.Equal([ syntheticProject.GetFilePath "Second" ], candidates) + + let docId = $"M:{syntheticProject.Name}.ModuleSecond.onlyHere(System.Int32)" + let path = CrossLanguageSymbolNavigation.docCommentIdToPath docId + + match + CrossLanguageSymbolNavigation.tryLocateViaNavigableItems docId path project + |> run + with + | ValueSome range -> Assert.Equal(syntheticProject.GetFilePath "Second", range.FileName) + | ValueNone -> failwith "declaration not found" + +[] +let ``a later instance of a multi-targeted project answers when an earlier one does not declare the entity`` () = + let conditionalSource = + """ +module Widgets + +#if LATER +let onlyLater x = x * 4 +#endif +""" + + let instance () = + let id = ProjectId.CreateNewId() + + id, + RoslynTestHelpers.CreateProjectInfo id "C:\\test.fsproj" [ RoslynTestHelpers.CreateDocumentInfo id "C:\\test.fs" conditionalSource ] + + let firstId, first = instance () + let secondId, second = instance () + let solution = RoslynTestHelpers.CreateSolution [ first; second ] + + RoslynTestHelpers.SetProjectOptions + firstId + solution + { RoslynTestHelpers.DefaultProjectOptions with + OtherOptions = [| "--targetprofile:netcore"; "--nowarn:3384" |] + } + + RoslynTestHelpers.SetProjectOptions + secondId + solution + { RoslynTestHelpers.DefaultProjectOptions with + OtherOptions = [| "--targetprofile:netcore"; "--nowarn:3384"; "--define:LATER" |] + } + + let found = + CrossLanguageSymbolNavigation.tryFindDeclaration solution "test.dll" "M:Widgets.onlyLater(System.Int32)" + |> run + + match found with + | ValueSome(struct (_, project)) -> Assert.Equal(secondId, project.Id) + | ValueNone -> failwith "declaration not found"