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 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/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..bff1e56a73f --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs @@ -0,0 +1,409 @@ +// 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.Collections.Generic +open System.Composition +open System.Threading +open System.Threading.Tasks + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation +open Microsoft.VisualStudio.LanguageServices + +open FSharp.Compiler.EditorServices +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 + +/// 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 docCommentIdToPath (docId: string) = + match XmlDocSigParser.parseDocCommentId docId with + | ParsedDocCommentId.Type path -> DocCommentId.Type path + + | 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 + + 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 + + /// 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 _.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 + && Seq.isEmpty literals + && (e.IsFSharpUnion || e.IsFSharpRecord) + then + Seq.singleton e.DeclarationLocation + else + Seq.append fields literals + + let private 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 + + /// 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 + (byShape: bool) + (documentationCommentId: string) + (symbolPath: SymbolPath) + (memberType: SymbolMemberType) + (entity: FSharpEntity) + = + let members = entity.TryGetMembersFunctionsAndValues() + + let exact = + 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 + else + 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 + return ValueNone + } + + 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)) + } + +[)>] +[)>] +type internal FSharpCrossLanguageSymbolNavigationService + [] + (metadataAsSource: FSharpMetadataAsSourceService, [] workspace: VisualStudioWorkspace) = + + static member internal DocCommentIdToPath(docId: string) = + CrossLanguageSymbolNavigation.docCommentIdToPath docId + + interface IFSharpCrossLanguageSymbolNavigationService with + member _.TryGetNavigableLocationAsync + (assemblyName: string, documentationCommentId: string, cancellationToken: CancellationToken) + : Task = + cancellableTask { + 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 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 diff --git a/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs new file mode 100644 index 00000000000..ce5c3d23bf5 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs @@ -0,0 +1,261 @@ +// 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 + | Dot + +[] +module Shape = + let area 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 } + +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" + +[] +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" 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 @@ +