diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 639843a3a0d..5ac8e6aa5e4 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,6 +2,7 @@ * Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880)) * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) +* 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)) ### Fixed diff --git a/eng/Packages.props b/eng/Packages.props index c6b2eabb7a2..5c5405d922d 100644 --- a/eng/Packages.props +++ b/eng/Packages.props @@ -64,6 +64,9 @@ ComponentModelHost would otherwise stay at 17.x; that 17.x/18.x split makes S/IComponentModel ambiguous (CS0433). Pin to the SDK 18.9.496 version so those types resolve to a single assembly. --> + + diff --git a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs index 7520395a084..a8a9271f9b1 100644 --- a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs +++ b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs @@ -1130,6 +1130,13 @@ module CancellableTasks = return! allTask } + /// Runs the work over the items with at most maxDegreeOfParallelism of them in flight. + let forEachThrottled maxDegreeOfParallelism (work: 'T -> CancellableTask) (items: 'T seq) = + cancellableTask { + let! ct = getCancellationToken () + return! Task.parallelDoLimit maxDegreeOfParallelism ct (items |> Seq.map work) + } + let inline whenAllTasks (tasks: CancellableTask seq) = cancellableTask { let! ct = getCancellationToken () diff --git a/vsintegration/src/FSharp.Editor/Common/Constants.fs b/vsintegration/src/FSharp.Editor/Common/Constants.fs index ead451467cf..d0f493af6df 100644 --- a/vsintegration/src/FSharp.Editor/Common/Constants.fs +++ b/vsintegration/src/FSharp.Editor/Common/Constants.fs @@ -43,6 +43,11 @@ module internal FSharpConstants = /// "F# Language Service" let FSharpLanguageServiceCallbackName = "F# Language Service" + [] + /// Brokered service offering F# declarations to the Copilot chat "#" mention picker. + let copilotSymbolProviderName = + "Microsoft.VisualStudio.FSharp.CopilotSymbolContextProvider" + [] /// "FSharp" let FSharpLanguageLongName = "FSharp" diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs new file mode 100644 index 00000000000..39ee621448a --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -0,0 +1,681 @@ +// 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.ComponentModel.Composition +open System.IO +open System.Diagnostics +open System.Threading +open System.Threading.Tasks + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation +open Microsoft.CodeAnalysis.Text +open Microsoft.ServiceHub.Framework +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.LanguageServices +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.Shell.ServiceBroker +open Microsoft.VisualStudio.Text.PatternMatching + +open FSharp.Compiler.EditorServices +open CancellableTasks + +/// Where a declaration sits relative to what the user is working on, best first. The picker merges +/// answers from every provider and ranks them by the priority each one reports, so a match has to say +/// where it sits rather than rely on its position. +[] +type internal DocumentFocus = + /// Declared around the caret or selection of the focused file. + | Selected + | Focused + | Open + | Elsewhere + +/// Solution-wide lookup of F# declarations behind the Copilot chat "#" mention picker. +/// Kept apart from the brokered service so it can be exercised without a Visual Studio workspace. +module internal CopilotSymbolQuery = + + /// Also the point at which the search stops parsing files nobody has opened, so it bounds the cold + /// scan as much as the answer. + [] + let private MaxMentions = 20 + + /// Overloads and partial definitions share one fully qualified name; a handful of them is plenty of context. + [] + let private MaxDeclarations = 4 + + [] + let private UserOpName = "CopilotSymbolContext" + + /// How long a query keeps parsing files nobody has opened yet. Copilot cancels on its own schedule + /// and takes no partial results, so an answer from what is already parsed beats a complete answer. + [] + let private ColdSearchBudgetMs = 1500L + + /// A shorter text matches too much of the solution to be worth parsing it for, so it is looked up in the + /// files the user has open. + [] + let private MinSolutionWideSearchLength = 3 + + let private parallelism = max 1 (Environment.ProcessorCount - 1) + + /// A bare "#" asks with no text at all, and is answered with every declaration of the open files. + let private matcherFor (cache: FSharpNavigableItemsCache) (searchText: string) = + match searchText with + | "" -> fun (_: NavigableItem) -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) + | searchText -> cache.CreateMatcherFor searchText + + let private fsharpDocuments (solution: Solution) = + solution.Projects |> Seq.where _.IsFSharp |> Seq.collect _.Documents + + let private isFocused (focus: EditorFocus) (document: Document) = + String.Equals(focus.FilePath, document.FilePath, StringComparison.OrdinalIgnoreCase) + + /// Focus counts only while the file is still open: the tracker is not told when its tab closes. + let private focusOf + (focus: EditorFocus voption) + (openIds: HashSet) + (isSelected: NavigableItem -> bool) + (document: Document) + (item: NavigableItem) + = + if not (openIds.Contains document.Id) then + DocumentFocus.Elsewhere + else + match focus with + | ValueSome focus when isFocused focus document -> + if isSelected item then + DocumentFocus.Selected + else + DocumentFocus.Focused + | _ -> DocumentFocus.Open + + let private rankOf focus = + match focus with + | DocumentFocus.Selected -> 0 + | DocumentFocus.Focused -> 1 + | DocumentFocus.Open -> 2 + | DocumentFocus.Elsewhere -> 3 + + /// The source of a document and the outlining of its declarations. + [] + type private Outline = + { + Text: SourceText + Lines: string array + Scopes: Structure.ScopeRange array + } + + let private outlineOf (document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync ct + let! parseResults = document.GetFSharpParseResultsAsync UserOpName + + let sourceLines = + Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) + + return + { + Text = sourceText + Lines = sourceLines + Scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree |> Seq.toArray + } + } + + let private notSelected (_: NavigableItem) = false + + /// Whether a declaration of the focused document spans a line the caret or selection is on - the whole + /// declaration, so the caret in a member's body selects the member, its type and the modules around them. + let private selectionIn (focus: EditorFocus) (document: Document) = + cancellableTask { + let! outline = outlineOf document + + return + fun (item: NavigableItem) -> + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.declarationLines outline.Lines outline.Scopes item + + firstLine <= focus.LastLine && focus.FirstLine <= lastLine + } + + /// The documents in the order a query visits them: the ones the user has open, the ones already + /// parsed into the cache, and the ones that would have to be parsed to answer. Only the open ones + /// are resolved up front, by id rather than by walking the solution; the other two tiers are walked + /// when they are reached, so a query the open files already answer never looks at the rest. + let private tiers (cache: FSharpNavigableItemsCache) (openIds: HashSet) (solution: Solution) = + let opened = + openIds + |> Seq.chooseV (fun id -> + match solution.GetDocument id with + | null -> ValueNone + | document when document.Project.IsFSharp -> ValueSome document + | _ -> ValueNone) + |> ResizeArray + + let unopened = + seq { + for document in fsharpDocuments solution do + if not (openIds.Contains document.Id) then + document + } + + let cached = + seq { + for document in unopened do + match cache.TryGetCachedNavigableItems document.Id with + | ValueSome items -> struct (document, items) + | ValueNone -> () + } + + let cold = + seq { + for document in unopened do + if (cache.TryGetCachedNavigableItems document.Id).IsNone then + document + } + + struct (opened, cached, cold) + + /// Visits the documents tier by tier until `enough` answers, parsing the cold ones for at most + /// `budgetMs`. The budget stops handing out documents rather than cancelling a parse under way: + /// the first query of a session has to survive its first parse to answer at all. + let private scanTiers + (cache: FSharpNavigableItemsCache) + (openIds: HashSet) + (solution: Solution) + (budgetMs: int64) + (enough: unit -> bool) + (collect: Document -> NavigableItem array -> unit) + = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let struct (opened, cached, cold) = tiers cache openIds solution + + let parseAndCollect (document: Document) = + cancellableTask { + let! items = cache.GetNavigableItems document + collect document items + } + + do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect + + if not (enough ()) then + for struct (document, items) in cached do + ct.ThrowIfCancellationRequested() + collect document items + + if not (enough ()) then + let budget = Stopwatch.StartNew() + + let scan (document: Document) = + cancellableTask { + if budget.ElapsedMilliseconds < budgetMs && not (enough ()) then + do! parseAndCollect document + } + + do! cold |> CancellableTask.forEachThrottled parallelism scan + } + + [] + type private Hit = + { + Kind: PatternMatchKind + Item: NavigableItem + Document: Document + Name: string + } + + let private compareRanked (struct (rank1: int, hit1: Hit)) (struct (rank2: int, hit2: Hit)) = + match compare rank1 rank2 with + | 0 -> + match compare (int hit1.Kind) (int hit2.Kind) with + | 0 -> compare hit1.Item.Name.Length hit2.Item.Name.Length + | order -> order + | order -> order + + /// Declarations whose fully qualified name matches each search text, best match first, one entry + /// per name. Every document is visited once for all of the texts. + let search + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (focus: EditorFocus voption) + (solution: Solution) + (searchTexts: string[]) + = + cancellableTask { + let openIds = HashSet openDocumentIds + + let queries = + searchTexts + |> Array.map (fun text -> + struct {| + TryMatch = matcherFor cache text + SolutionWide = text.Length >= MinSolutionWideSearchLength + Hits = ResizeArray() + Names = HashSet StringComparer.Ordinal + |}) + + let solutionWide = queries |> Array.filter _.SolutionWide + let gate = obj () + let mutable focusedDocument = ValueNone + + // Matching runs outside the lock; a document's hits join the shared lists in one step. + let collect (document: Document) (items: NavigableItem array) = + let queries = + if openIds.Contains document.Id then + queries + else + solutionWide + + let matched = + queries + |> Array.map (fun query -> + let hits = ResizeArray() + + for item in items do + match query.TryMatch item with + | ValueSome patternMatch -> + hits.Add + { + Kind = patternMatch.Kind + Item = item + Document = document + Name = CopilotSymbolMapping.fullyQualifiedName item + } + | ValueNone -> () + + struct (query, hits)) + + lock gate (fun () -> + for struct (query, hits) in matched do + query.Hits.AddRange hits + + if query.SolutionWide then + for hit in hits do + if query.Names.Count < MaxMentions then + query.Names.Add hit.Name |> ignore + + match focus with + | ValueSome focus when + isFocused focus document + && matched |> Array.exists (fun (struct (_, hits)) -> hits.Count > 0) + -> + focusedDocument <- ValueSome document + | _ -> ()) + + let enough () = + lock gate (fun () -> solutionWide |> Array.forall (fun query -> query.Names.Count >= MaxMentions)) + + do! scanTiers cache openIds solution ColdSearchBudgetMs enough collect + + let! isSelected = + match focus, focusedDocument with + | ValueSome focus, ValueSome document -> selectionIn focus document + | _ -> CancellableTask.singleton notSelected + + return + queries + |> Array.map (fun query -> + let seen = HashSet StringComparer.Ordinal + let mentions = ResizeArray MaxMentions + + let ranked = + query.Hits + |> Seq.map (fun hit -> + let focus = focusOf focus openIds isSelected hit.Document hit.Item + struct (rankOf focus * 2 + (if hit.Document.IsFSharpSignatureFile then 1 else 0), hit), focus) + |> Seq.sortWith (fun (ranked1, _) (ranked2, _) -> compareRanked ranked1 ranked2) + + for struct (_, hit), focus in ranked do + if mentions.Count < MaxMentions && seen.Add hit.Name then + mentions.Add(struct (hit.Item, hit.Document, focus)) + + mentions.ToArray()) + } + + /// Declarations carrying exactly this fully qualified name. Signature files answer only when no + /// implementation declares the name, so the search stops once an implementation has answered. + let declarationsOf + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (solution: Solution) + (fullyQualifiedName: string) + = + cancellableTask { + let hits = ResizeArray() + let mutable declaredInImplementation = false + + let collect (document: Document) (items: NavigableItem array) = + let declared = + items + |> Array.filter (CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName) + + if declared.Length > 0 then + lock hits (fun () -> + for item in declared do + hits.Add(struct (item, document)) + + if not document.IsFSharpSignatureFile then + declaredInImplementation <- true) + + let enough () = + lock hits (fun () -> declaredInImplementation) + + do! scanTiers cache (HashSet openDocumentIds) solution Int64.MaxValue enough collect + + let implementations = + hits + |> Seq.filter (fun (struct (_, document: Document)) -> not document.IsFSharpSignatureFile) + |> Seq.truncate MaxDeclarations + |> Seq.toArray + + return + match implementations with + | [||] -> hits |> Seq.truncate MaxDeclarations |> Seq.toArray + | implementations -> implementations + } + + /// The declaration among `declarations` that a mention was picked for, when its line is still known + /// and still matches one of them - several overloads and partial definitions answer to the same + /// fully qualified name, so the first one found is no substitute once a line has been recorded. + let declarationAt (line: int voption) (declarations: struct (NavigableItem * Document) array) = + match line with + | ValueSome line -> + declarations + |> Array.tryFindV (fun (struct (item, _)) -> item.Range.StartLine = line) + |> ValueOption.orElseWith (fun () -> Array.tryHeadV declarations) + | ValueNone -> Array.tryHeadV declarations + + /// The source of the whole declaration `item` names, together with the span it occupies. + let private snippetOf (outline: Outline) (item: NavigableItem) = + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.definitionLines outline.Lines outline.Scopes item + + let text = outline.Text + let firstLine = max 1 firstLine + let lastLine = min text.Lines.Count lastLine + + let span = + TextSpan.FromBounds(text.Lines[firstLine - 1].Start, text.Lines[lastLine - 1].End) + + struct (text.ToString span, span) + + let symbolContext + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (solution: Solution) + (fullyQualifiedName: string) + = + cancellableTask { + let! declarations = declarationsOf cache openDocumentIds solution fullyQualifiedName + + match Array.tryHeadV declarations with + | ValueNone -> return ValueNone + | ValueSome(struct (first, _)) -> + let snippets = ResizeArray() + let locations = ResizeArray() + let mutable outlined: struct (DocumentId * Outline) voption = ValueNone + + // Overloads and partial definitions of one name mostly share a file: outline it once. + for struct (item, document: Document) in declarations do + let! outline = + match outlined with + | ValueSome(struct (id, outline)) when id = document.Id -> CancellableTask.singleton outline + | _ -> outlineOf document + + outlined <- ValueSome(struct (document.Id, outline)) + let struct (text, span) = snippetOf outline item + snippets.Add text + locations.Add(SnippetLocation(document.FilePath, CopilotSpan(span.Start, span.Length))) + + return + ValueSome( + CopilotSymbolContext( + fullyQualifiedName, + first.Name, + String.Join(Environment.NewLine + Environment.NewLine, snippets), + CopilotSymbolMapping.symbolContextType first.Kind, + locations.ToArray() + ) + ) + } + +/// Offers F# declarations to Copilot chat, which merges them into the picker shown for "#". +/// Copilot's own symbol provider reads the Roslyn compilation, which F# projects do not have. +[; typeof |], + Audience = (ServiceAudience.PublicSdk ||| ServiceAudience.Local))>] +type internal FSharpCopilotContextProvider + [] + ( + cache: FSharpNavigableItemsCache, + [)>] serviceBroker: IServiceBroker, + [] workspace: VisualStudioWorkspace | null + ) = + + /// The client Copilot's own document-context provider uses to reach the broker; never disposed, since + /// disposing it would dispose the broker it wraps, and it is meant to outlive every query. + let documentContexts = + new ServiceBrokerClient(serviceBroker, ThreadHelper.JoinableTaskFactory) + + /// Where the user is, from the service that answers the same question for Copilot's own C# provider. + /// The proxy is null when Copilot is not installed; the picker then ranks nothing as focused. + let focusOf () = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + use! rental = documentContexts.GetProxyAsync(CopilotDescriptors.Context.Document, ct).AsTask() + + match rental.Proxy with + | null -> return ValueNone + | proxy -> + let! context = proxy.GetActiveDocumentAsync(CopilotCorrelationId.New(), ct) + + match context with + | null -> return ValueNone + | context -> + match context.TryGetValue() with + | true, document -> return CopilotSymbolMapping.editorFocusOf document + | _ -> return ValueNone + } + + static let moniker = + ServiceMoniker(FSharpConstants.copilotSymbolProviderName, Version CopilotDescriptors.CurrentContextProviderVersion) + + static let descriptor = + CopilotContextDescriptor( + CopilotSymbolMapping.SymbolMember, + "An F# type, module, member or value declared in the current solution.", + CopilotDefaultTypes.SymbolContextName, + [| + CopilotInputDescriptor( + CopilotSymbolMapping.FullyQualifiedNameInput, + "Fully qualified name of the F# declaration.", + CopilotDefaultTypes.StringName, + IsRequired = true + ) + CopilotInputDescriptor( + CopilotSymbolMapping.DeclarationLineInput, + "Line the picked overload or partial definition is declared on.", + CopilotDefaultTypes.IntegerName, + IsRequired = false + ) + |] + ) + + static let members = [| descriptor |] :> IReadOnlyList + + static let memberNames = [| CopilotSymbolMapping.SymbolMember |] :> IReadOnlyList + + static let noMentions = + Array.empty :> IReadOnlyCollection + + let priorityOf focus = + match focus with + | DocumentFocus.Selected -> CopilotQueriedMentionPriority.Selection + | DocumentFocus.Focused -> CopilotQueriedMentionPriority.High + | DocumentFocus.Open -> CopilotQueriedMentionPriority.Low + | DocumentFocus.Elsewhere -> CopilotQueriedMentionPriority.None + + let mentionFor (item: NavigableItem) (document: Document) focus = + let inputs = Dictionary(2, StringComparer.Ordinal) + + inputs[CopilotSymbolMapping.FullyQualifiedNameInput] <- + CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item) + + inputs[CopilotSymbolMapping.DeclarationLineInput] <- CopilotValue(CopilotDefaultTypes.IntegerName, item.Range.StartLine) + + let fileName = Path.GetFileName document.FilePath + + let tooltip = + String.Format( + SR.CopilotSymbolTooltip(), + CopilotSymbolMapping.symbolContextType item.Kind, + fileName, + CopilotSymbolMapping.tooltipName item + ) + + CopilotQueriedContextMention( + moniker, + descriptor, + inputs, + item.Name, + Description = fileName, + Tooltip = tooltip, + Icon = Nullable(CopilotSymbolMapping.icon item.Kind), + IsNavigable = true, + Priority = priorityOf focus + ) + :> CopilotQueriedMention + + /// One pass over the solution for the whole batch: Copilot's picker asks for several texts at once + /// and each of them would otherwise walk the same documents. + let mentionsFor (searchTexts: string voption[]) = + cancellableTask { + match workspace, searchTexts |> Seq.chooseV id |> Seq.distinct |> Seq.toArray with + | null, _ + | _, [||] -> return Array.create searchTexts.Length noMentions + | workspace, distinct -> + let! focus = focusOf () + + let! hits = CopilotSymbolQuery.search cache (workspace.GetOpenDocumentIds()) focus workspace.CurrentSolution distinct + + let byText = Dictionary(StringComparer.Ordinal) + + for index in 0 .. distinct.Length - 1 do + byText[distinct[index]] <- + hits[index] + |> Array.map (fun (struct (item, document, focus)) -> mentionFor item document focus) + :> IReadOnlyCollection + + return + searchTexts + |> Array.map (function + | ValueSome text -> byText[text] + | ValueNone -> noMentions) + } + + let fullyQualifiedNameOf (inputs: IReadOnlyDictionary | null) = + match inputs with + | null -> ValueNone + | inputs -> + match inputs.TryGetValue CopilotSymbolMapping.FullyQualifiedNameInput with + | true, value -> + match value.TryGetValue() with + | true, name when not (String.IsNullOrWhiteSpace name) -> ValueSome name + | _ -> ValueNone + | _ -> ValueNone + + /// Which overload or partial definition of a fully qualified name a mention was picked for - + /// several of them share the same name, so navigating one has to tell them apart by more than that. + let declarationLineOf (inputs: IReadOnlyDictionary | null) = + match inputs with + | null -> ValueNone + | inputs -> + match inputs.TryGetValue CopilotSymbolMapping.DeclarationLineInput with + | true, value -> + match value.TryGetValue() with + | true, line -> ValueSome line + | _ -> ValueNone + | _ -> ValueNone + + interface IExportedBrokeredService with + member _.Descriptor = CopilotDescriptors.CreateContextProviderDescriptor moniker + + member _.InitializeAsync _cancellationToken = Task.CompletedTask + + interface ICopilotContextReducer with + member _.ReduceAsync(context, _reduction, _counter, _cancellationToken) = Task.FromResult context + + interface ICopilotContextProvider with + member _.GetMembersAsync _cancellationToken = + ValueTask> members + + member _.GetMembersAsync(_requestId, _cancellationToken) = Task.FromResult memberNames + + member _.StoreAsync(_requestId, _cancellationToken) = ValueTask() + + member _.ReleaseAsync(_requestId, _cancellationToken) = ValueTask() + + member _.GetContextAsync(requestId, memberName, inputs, cancellationToken) : Task = + match workspace, fullyQualifiedNameOf inputs with + | null, _ + | _, ValueNone -> Task.FromResult null + | workspace, ValueSome fullyQualifiedName when + String.Equals(memberName, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) + -> + cancellableTask { + let! symbol = + CopilotSymbolQuery.symbolContext cache (workspace.GetOpenDocumentIds()) workspace.CurrentSolution fullyQualifiedName + + match symbol with + | ValueNone -> return null + | ValueSome symbol -> return CopilotContext(moniker, descriptor, requestId, symbol, CanReduce = false) + } + |> CancellableTask.start cancellationToken + | _ -> Task.FromResult null + + interface ICopilotMentionQueryable with + member _.QueryMentionAsync(query, cancellationToken) : Task> = + mentionsFor [| CopilotSymbolMapping.searchTextOf query |] + |> CancellableTask.map Array.head + |> CancellableTask.start cancellationToken + + member _.NavigateToMentionableAsync(mention, cancellationToken) : Task = + match workspace, fullyQualifiedNameOf mention.Inputs with + | null, _ + | _, ValueNone -> Task.FromResult false + | workspace, ValueSome fullyQualifiedName -> + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let solution = workspace.CurrentSolution + + let! declarations = CopilotSymbolQuery.declarationsOf cache (workspace.GetOpenDocumentIds()) solution fullyQualifiedName + + match CopilotSymbolQuery.declarationAt (declarationLineOf mention.Inputs) declarations with + | ValueNone -> return false + | ValueSome(struct (item, document)) -> + let! sourceText = document.GetTextAsync ct + + match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) with + | ValueNone -> return false + | ValueSome span -> + do! ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync ct + + let navigation = + solution.Workspace.Services.GetService() + + return navigation.TryNavigateToSpan(solution.Workspace, document.Id, span, ct) + } + |> CancellableTask.start cancellationToken + + // Copilot's own picker providers answer through the batch interface, one result collection per query. + interface ICopilotMentionBatchQueryable with + member _.QueryMentionBatchAsync(queries, cancellationToken) : Task>> = + mentionsFor (queries |> Seq.map CopilotSymbolMapping.searchTextOf |> Seq.toArray) + |> CancellableTask.map (fun mentions -> mentions :> IReadOnlyList>) + |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs new file mode 100644 index 00000000000..b1fc3084d57 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Translates F# navigable items into the shapes the Copilot chat "#" mention picker understands. +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolMapping + +open System +open System.Collections.Generic + +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.Imaging + +open FSharp.Compiler.EditorServices +open FSharp.Compiler.Syntax + +/// Name of the context member. It becomes the mention prefix the user sees and re-types, +/// as in "#fsharpSymbol:Namespace.Type.Member". +[] +let SymbolMember = "fsharpSymbol" + +[] +let FullyQualifiedNameInput = "fullyQualifiedName" + +/// Disambiguates which overload or partial definition a mention was picked for - several of them share +/// one fully qualified name, and only the one at this line is the one the user meant. +[] +let DeclarationLineInput = "declarationLine" + +/// The parse tree cannot tell an interface, struct or record apart from a plain class, so every +/// type-like declaration is reported as a class. +let symbolContextType kind = + match kind with + | NavigableItemKind.Module + | NavigableItemKind.ModuleAbbreviation + | NavigableItemKind.Exception + | NavigableItemKind.Type -> CopilotSymbolContextType.Class + | NavigableItemKind.ModuleValue -> CopilotSymbolContextType.Function + | NavigableItemKind.Field + | NavigableItemKind.Property -> CopilotSymbolContextType.Field + | NavigableItemKind.Constructor + | NavigableItemKind.Member -> CopilotSymbolContextType.Method + | NavigableItemKind.EnumCase -> CopilotSymbolContextType.Constant + | NavigableItemKind.UnionCase -> CopilotSymbolContextType.Union + +let private imageId kind = + match kind with + | NavigableItemKind.Module + | NavigableItemKind.ModuleAbbreviation -> KnownImageIds.ModulePublic + | NavigableItemKind.Exception -> KnownImageIds.ExceptionPublic + | NavigableItemKind.Type -> KnownImageIds.ClassPublic + | NavigableItemKind.ModuleValue + | NavigableItemKind.Constructor + | NavigableItemKind.Member -> KnownImageIds.MethodPublic + | NavigableItemKind.Field -> KnownImageIds.FieldPublic + | NavigableItemKind.Property -> KnownImageIds.PropertyPublic + | NavigableItemKind.EnumCase + | NavigableItemKind.UnionCase -> KnownImageIds.EnumerationItemPublic + +let icon kind = + CopilotImageMoniker(Guid = KnownImageIds.ImageCatalogGuid, Id = imageId kind) + +/// A name F# spells in double backticks keeps them: bare, a value ``a.b`` in module M and the value b of +/// M's nested module a would both be "M.a.b". A file's top-level module is left bare - NavigateTo names it +/// by its whole dotted path, joined, so its segments are not known apart. +let private isQuoted (item: NavigableItem) = + item.NeedsBackticks && not (item.Kind.IsModule && item.Container.Type.IsFile) + +/// FCS joins a container's path into one string, so of its segments only the last can be spelled. +let private isContainerQuoted (container: NavigableContainer) = + container.Name.Length > 0 + && not container.Type.IsFile + && PrettyNaming.DoesIdentifierNeedBackticks container.Name + +let private containerPath (container: NavigableContainer) = + let path = container.FullName + + if isContainerQuoted container then + String.Concat(path.Substring(0, path.Length - container.Name.Length), "``", container.Name, "``") + else + path + +/// Dotted path that identifies a picked mention when it is resolved back to source. +let fullyQualifiedName (item: NavigableItem) = + let name = + if isQuoted item then + String.Concat("``", item.Name, "``") + else + item.Name + + match containerPath item.Container with + | "" -> name + | container -> String.Concat(container, ".", name) + +/// How Copilot's own tooltip names a declaration: a member by the container it is declared in, a type +/// or module by itself. +let tooltipName (item: NavigableItem) = + match symbolContextType item.Kind, item.Container.Type, item.Container.Name with + | CopilotSymbolContextType.Class, _, _ + | _, NavigableContainerType.File, _ + | _, _, "" -> item.Name + | _, _, container -> $"{container}.{item.Name}" + +/// How much of `candidate` is left in front of `segment` - spelled in double backticks when `quoted` - +/// or -1 when the candidate does not end with it. +let private lengthBefore (candidate: ReadOnlySpan) (segment: string) quoted = + let quotes = if quoted then 2 else 0 + let length = segment.Length + quotes * 2 + + if candidate.Length < length then + -1 + else + let tail = candidate.Slice(candidate.Length - length) + + if + tail.Slice(quotes, segment.Length).Equals(segment.AsSpan(), StringComparison.Ordinal) + && (not quoted + || tail.StartsWith("``".AsSpan(), StringComparison.Ordinal) + && tail.EndsWith("``".AsSpan(), StringComparison.Ordinal)) + then + candidate.Length - length + else + -1 + +/// Answers what comparing against `fullyQualifiedName` would, without building the dotted path - +/// a solution-wide scan asks this of every declaration it walks past. +let hasFullyQualifiedName (candidate: string) (item: NavigableItem) = + let candidate = candidate.AsSpan() + let container = item.Container + let path = container.FullName + let beforeName = lengthBefore candidate item.Name (isQuoted item) + + if beforeName < 0 then + false + elif path.Length = 0 then + beforeName = 0 + elif beforeName = 0 || candidate[beforeName - 1] <> '.' then + false + else + let spelledPath = candidate.Slice(0, beforeName - 1) + let enclosing = path.Length - container.Name.Length + + lengthBefore spelledPath container.Name (isContainerQuoted container) = enclosing + && spelledPath.Slice(0, enclosing).Equals(path.AsSpan(0, enclosing), StringComparison.Ordinal) + +/// The text a picker query searches for. The inputs are positional: the member name first once the +/// mention has been committed, as in "#fsharpSymbol:Namespace.Type", then the search text, then +/// qualifiers the search ignores. The picker asks before it has resolved what kind of mention is typed. +let searchTextOf (query: CopilotMentionQuery) = + let searchTextAt index (inputs: IReadOnlyList) = + if index < inputs.Count then + ValueSome( + match inputs[index] with + | null -> "" + | text -> text.Trim() + ) + else + ValueNone + + match query.Type, query.Inputs with + | (CopilotMentionType.Context | CopilotMentionType.Unknown), inputs when inputs.Count > 0 -> + if String.Equals(inputs[0], SymbolMember, StringComparison.Ordinal) then + searchTextAt 1 inputs + else + searchTextAt 0 inputs + | _ -> ValueNone + +/// The number of lines before `offset` in `content`, 1-based - `DocumentSelection.Caret` is always the +/// offset of the start of a line, so no column arithmetic is needed. +let private lineOf (content: string) offset = + let mutable line = 1 + + for i in 0 .. offset - 1 do + if content[i] = '\n' then + line <- line + 1 + + line + +let private totalLinesIn (content: string) = lineOf content content.Length + +/// Where Copilot's document context says the user is. Copilot widens an empty selection to the +/// enclosing block, and to the whole file when a language has no block expander, as F# has not: the +/// caret line then comes from the selection's caret, which Copilot leaves out when the caret is on the +/// range's first or last line - so a caret on the file's first or last line reports no line. +let editorFocusOf (document: DocumentContext) = + match document.FilePath with + | null -> ValueNone + | filePath -> + match document.Selections |> Seq.tryHeadV with + | ValueSome selection when selection.Caret.HasValue -> + let line = lineOf document.Content selection.Caret.Value + + ValueSome + { + FilePath = filePath + FirstLine = line + LastLine = line + } + | ValueSome selection when selection.LineRange.HasValue -> + let range = selection.LineRange.Value + + let totalLines = + document.TotalLinesInFile + |> ValueOption.ofNullable + |> ValueOption.defaultWith (fun () -> totalLinesIn document.Content) + + if range.StartLine <= 1 && range.EndLine >= totalLines then + ValueSome + { + FilePath = filePath + FirstLine = 0 + LastLine = 0 + } + else + ValueSome + { + FilePath = filePath + FirstLine = range.StartLine + LastLine = range.EndLine + } + | _ -> + ValueSome + { + FilePath = filePath + FirstLine = 0 + LastLine = 0 + } diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs new file mode 100644 index 00000000000..758f366448a --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Widens the identifier range of a navigable item to the declaration a reader would recognise. +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolSnippets + +open System + +open FSharp.Compiler.EditorServices + +/// A module scope can span a whole file, which is more than a chat prompt can usefully carry. +[] +let MaxSnippetLines = 200 + +/// Inclusive, 1-based line bounds of the whole declaration `item` names, including its doc comment. +let declarationLines (sourceLines: string array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = + let declarationLine = item.Range.StartLine + + // A construct's outlining range reaches back over the doc comment in front of it, so it is the + // collapse range - the body proper - that tells which construct is declared on this line. + let declaredHere (scope: Structure.ScopeRange) = + scope.CollapseRange.StartLine = declarationLine + && scope.Range.EndLine >= item.Range.EndLine + && scope.Scope <> Structure.Scope.Comment + && scope.Scope <> Structure.Scope.XmlDocComment + + let mutable widest = ValueNone + + for scope in scopes do + if declaredHere scope then + match widest with + | ValueSome(previous: Structure.ScopeRange) when previous.Range.EndLine >= scope.Range.EndLine -> () + | _ -> widest <- ValueSome scope + + // A one-line member declares no scope of its own; it stands for itself rather than for the type around it. + let firstLine, lastLine = + match widest with + | ValueSome scope -> scope.Range.StartLine, scope.Range.EndLine + | ValueNone -> declarationLine, item.Range.EndLine + + // Outlining reports a doc comment only once it spans several lines, so a one-line "///" in front of + // a declaration is invisible to the scopes above. + let isDocComment line = + sourceLines[line - 1].AsSpan().TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) + + let rec docCommentStart line = + if line > 1 && isDocComment (line - 1) then + docCommentStart (line - 1) + else + line + + struct (docCommentStart firstLine, lastLine) + +/// The lines of the declaration `item` names that a chat prompt carries. +let definitionLines (sourceLines: string array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = + let struct (firstLine, lastLine) = declarationLines sourceLines scopes item + struct (firstLine, min lastLine (firstLine + MaxSnippetLines - 1)) diff --git a/vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs b/vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs new file mode 100644 index 00000000000..6f75b2bc4af --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +/// The file the user last edited and the lines the caret or selection covers, 1-based and inclusive; +/// 0 and 0 when the file is known but no line is. +[] +type internal EditorFocus = + { + FilePath: string + FirstLine: int + LastLine: int + } diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..f5d6a8e0b04 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -94,6 +94,10 @@ + + + + @@ -179,6 +183,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx index 1f1f632d770..8d1a5854269 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx @@ -368,4 +368,9 @@ Use live (unsaved) buffers for analysis Returns: + + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 427baf0c6ab..96c4041db06 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -6,27 +6,31 @@ open System open System.ComponentModel.Design open System.Runtime.InteropServices open System.Threading +open System.Threading.Tasks open System.IO open System.Collections.Immutable open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp +open Microsoft.CodeAnalysis.Host.Mef open Microsoft.CodeAnalysis.Options -open FSharp.Compiler -open FSharp.Compiler.CodeAnalysis -open FSharp.NativeInterop +open Microsoft.ServiceHub.Framework open Microsoft.VisualStudio +open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.LanguageServices open Microsoft.VisualStudio.LanguageServices.Implementation.LanguageService open Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem open Microsoft.VisualStudio.Shell open Microsoft.VisualStudio.Shell.Interop +open Microsoft.VisualStudio.Shell.ServiceBroker open Microsoft.VisualStudio.Text.Outlining -open Microsoft.CodeAnalysis.ExternalAccess.FSharp -open Microsoft.CodeAnalysis.Host.Mef +open Microsoft.VisualStudio.Editor open Microsoft.VisualStudio.FSharp.Editor.Telemetry -open CancellableTasks +open FSharp.Compiler +open FSharp.Compiler.CodeAnalysis +open FSharp.NativeInterop open FSharp.Compiler.Text -open Microsoft.VisualStudio.Editor +open CancellableTasks #nowarn "9" // NativePtr.toNativeInt #nowarn "57" // Experimental stuff @@ -408,8 +412,10 @@ type internal FSharpPackage() as this = |> CancellableTask.startAsTask cancellationToken) ) + override this.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = + base.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks) + #if DEBUG - override _.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = afterPackageLoadedTasks.AddTask( false, fun _ _ -> @@ -421,6 +427,62 @@ type internal FSharpPackage() as this = ) #endif + /// Copilot's registration service is an exported brokered service whose MEF part constructor blocks waiting + /// for the main thread. Asking for the proxy from a background thread therefore deadlocks against anyone + /// asking for it from the main thread - the Git provider does, while creating its services at solution open - + /// so take the main thread dependency deliberately, the way Roslyn does for a proxy that has one. + member private this.RegisterCopilotContextProviderAsync(cancellationToken: CancellationToken) : Task = + task { + try + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registering context provider (switching to main thread)…" + do! this.JoinableTaskFactory.SwitchToMainThreadAsync(alwaysYield = true, cancellationToken = cancellationToken) + + DebugHelpers.FSharpOutputPane.logInfo "Copilot: getting brokered service container…" + let! container = this.GetServiceAsync(typeof) + + match container with + | :? IBrokeredServiceContainer as container -> + // The Interactions service also serves the registration interface. It is absent when + // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. + DebugHelpers.FSharpOutputPane.logInfo "Copilot: getting registration service proxy…" + + let! registration = + container + .GetFullAccessServiceBroker() + .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken) + + use registration = registration + + match registration with + | null -> DebugHelpers.FSharpOutputPane.logInfo "Copilot: service proxy is null (Copilot not installed)" + | registration -> + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registering F# context provider…" + + let moniker = + ServiceMoniker( + FSharpConstants.copilotSymbolProviderName, + Version CopilotDescriptors.CurrentContextProviderVersion + ) + + do! registration.RegisterContextProviderAsync(moniker, cancellationToken) + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registration complete" + | _ -> DebugHelpers.FSharpOutputPane.logInfo "Copilot: container is not IBrokeredServiceContainer" + // A Copilot failure - a contract version the installed build does not serve, say - must not take the + // rest of the post-load work down with it. + with ex when not (ex :? OperationCanceledException) -> + DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider") + } + + override this.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) : Task = + // 'base' cannot be captured by the state machine, so start the base work before entering it. + let baseComponents = + base.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) + + task { + do! baseComponents + do! this.RegisterCopilotContextProviderAsync(cancellationToken) + } + override _.RoslynLanguageName = FSharpConstants.FSharpLanguageName (*override this.CreateWorkspace() = this.ComponentModel.GetService() *) override this.CreateLanguageService() = FSharpLanguageService(this) diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index 546b00e1b16..8eac9921f81 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -7,8 +7,9 @@ open System.IO open System.Composition open System.Collections.Immutable open System.Collections.Concurrent -open System.Threading.Tasks open System.Globalization +open System.Linq +open System.Threading.Tasks open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation @@ -19,34 +20,78 @@ open Microsoft.VisualStudio.Text.PatternMatching open FSharp.Compiler.EditorServices open CancellableTasks -[); Shared>] -type internal FSharpNavigateToSearchService +/// Parse-tree navigable items per document, cached on the document's text version. +/// Shared by NavigateTo and by the Copilot chat mention provider. +[] +type internal FSharpNavigableItemsCache [] (patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) = - let cache = ConcurrentDictionary() + let cache = + ConcurrentDictionary() do - if workspace <> null then - workspace.WorkspaceChanged.Add - <| fun e -> + match workspace with + | null -> () + | workspace -> + workspace.WorkspaceChanged.Add(fun e -> if e.NewSolution.Id <> e.OldSolution.Id then - cache.Clear() + cache.Clear()) - let getNavigableItems (document: Document) = + member _.GetNavigableItems(document: Document) = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! currentVersion = document.GetTextVersionAsync(ct) match cache.TryGetValue document.Id with - | true, (version, items) when version = currentVersion -> return items + | true, struct (version, items) when version = currentVersion -> return items | _ -> - let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) + let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigableItemsCache)) let items = NavigateTo.GetNavigableItems parseResults.ParseTree - cache[document.Id] <- currentVersion, items + cache[document.Id] <- struct (currentVersion, items) return items } + /// The items of the document's last parse, whatever version they came from. Reads no text, so a + /// closed document costs nothing; a caller that needs the items of the current text asks for them. + member _.TryGetCachedNavigableItems(documentId: DocumentId) = + match cache.TryGetValue documentId with + | true, struct (_, items) -> ValueSome items + | _ -> ValueNone + + member _.CreateMatcherFor(searchPattern: string) = + let patternMatcher = + patternMatcherFactory.CreatePatternMatcher( + searchPattern, + PatternMatcherCreationOptions( + cultureInfo = CultureInfo.CurrentUICulture, + flags = PatternMatcherCreationFlags.AllowFuzzyMatching, + containerSplitCharacters = [ '.' ] + ) + ) + + fun (item: NavigableItem) -> + // PatternMatcher will not match operators and some backtick escaped identifiers. + // To handle them, we fall back to simple substring match. + let name = item.Name + + if item.NeedsBackticks then + match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with + | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) + | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) + | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) + | _ -> ValueNone + else + // full name with dots allows for path matching, e.g. + // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" + patternMatcher.TryMatch $"{item.Container.FullName}.{name}" + |> ValueOption.ofNullable + +[); Shared>] +type internal FSharpNavigateToSearchService [] (itemsCache: FSharpNavigableItemsCache) = + + let getNavigableItems (document: Document) = itemsCache.GetNavigableItems document + let kindsProvided = ImmutableHashSet.Create( FSharpNavigateToItemKind.Module, @@ -115,33 +160,8 @@ type internal FSharpNavigateToSearchService | PatternMatchKind.Fuzzy -> FSharpNavigateToMatchKind.Fuzzy | _ -> FSharpNavigateToMatchKind.None - let createMatcherFor searchPattern = - let patternMatcher = - patternMatcherFactory.CreatePatternMatcher( - searchPattern, - PatternMatcherCreationOptions( - cultureInfo = CultureInfo.CurrentUICulture, - flags = PatternMatcherCreationFlags.AllowFuzzyMatching, - containerSplitCharacters = [ '.' ] - ) - ) - - fun (item: NavigableItem) -> - // PatternMatcher will not match operators and some backtick escaped identifiers. - // To handle them, we fall back to simple substring match. - let name = item.Name - - if item.NeedsBackticks then - match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with - | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) - | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) - | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) - | _ -> ValueNone - else - // full name with dots allows for path matching, e.g. - // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" - patternMatcher.TryMatch $"{item.Container.FullName}.{name}" - |> ValueOption.ofNullable + let createMatcherFor (searchPattern: string) = + itemsCache.CreateMatcherFor searchPattern let processDocument (tryMatch: NavigableItem -> PatternMatch voption) (kinds: IImmutableSet) (document: Document) = cancellableTask { @@ -152,7 +172,7 @@ type internal FSharpNavigateToSearchService let! items = getNavigableItems document let processed = - [| + seq { for item in items do let contains = kinds.Contains(navigateToItemKindToRoslynKind item.Kind) let patternMatch = tryMatch item @@ -182,9 +202,9 @@ type internal FSharpNavigateToSearchService ) ) | _ -> () - |] + } - return processed + return processed |> Seq.toImmutableArray } interface IFSharpNavigateToSearchService with @@ -194,31 +214,17 @@ type internal FSharpNavigateToSearchService cancellableTask { let tryMatch = createMatcherFor searchPattern - let tasks = - [| - for doc in project.Documents do - yield processDocument tryMatch kinds doc - |] - - let! results = CancellableTask.whenAll tasks - - let results' = ImmutableArray.CreateBuilder() - - for navResults in results do - for navResult in navResults do - results'.Add navResult - - return results'.ToImmutable() + let! results = + project.Documents + |> Seq.map (processDocument tryMatch kinds) + |> CancellableTask.whenAll + return results |> Seq.collect _.AsEnumerable() |> Seq.toImmutableArray } |> CancellableTask.start cancellationToken member _.SearchDocumentAsync(document: Document, searchPattern, kinds, cancellationToken) = - cancellableTask { - let! result = processDocument (createMatcherFor searchPattern) kinds document - return Array.toImmutableArray result - } - |> CancellableTask.start cancellationToken + processDocument (createMatcherFor searchPattern) kinds document cancellationToken member _.KindsProvided = kindsProvided diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index cd8c46bf705..5af8dfaab66 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -105,6 +105,13 @@ Navrhnout názvy pro nerozpoznané identifikátory; Použít místo negace odčítání + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Uvolnitelné hodnoty jazyka F# (místní) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf index bce1941f0b1..e7cf1e34711 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -105,6 +105,13 @@ Namen für nicht aufgelöste Bezeichner vorschlagen; Subtraktion anstelle von Negation verwenden + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Disposable-Werte in F# (lokal) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index fa8cb62c422..a763e64aca8 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -105,6 +105,13 @@ Sugerir nombres para identificadores sin resolver; Usar la resta en lugar de la negación + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Valores de F# descartables (locales) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index e7ec71e839e..6fd805187e5 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -105,6 +105,13 @@ Suggérer des noms pour les identificateurs non résolus ; Utiliser la soustraction à la place de la négation + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Valeurs F# pouvant être supprimées (variables locales) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index 327a7ca362f..766483153f0 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -105,6 +105,13 @@ Suggerisci i nomi per gli identificatori non risolti; Usare la sottrazione invece della negazione + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Valori eliminabili F# (variabili locali) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index d45234c011a..30834f89e84 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -105,6 +105,13 @@ Suggest names for unresolved identifiers; 否定の代わりに減算を使用する + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) F# の破棄可能な値 (ローカル) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index 3248e0641ea..1ef22e3047d 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -105,6 +105,13 @@ Suggest names for unresolved identifiers; 부정 대신 빼기 사용 + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) F# 삭제 가능한 값(로컬) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index abc39f15da5..1091913daa2 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -105,6 +105,13 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów; Użyj odejmowania zamiast negacji + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Wartości możliwe do likwidacji języka F# (lokalne) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf index dfde43120f5..28e5ff703d2 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf @@ -105,6 +105,13 @@ Sugerir nomes para identificadores não resolvidos; Use a subtração em vez da negação + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Valores F# Descartáveis (locais) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 47cda215312..5787d9818f3 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -105,6 +105,13 @@ Suggest names for unresolved identifiers; Используйте вычитание вместо отрицания. + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Освобождаемые значения F# (локальные) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index 58aa5d54c43..933e42779d4 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -105,6 +105,13 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner; Negatif yapma yerine çıkarmayı kullanın + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) F# Atılabilir Değerleri (yereller) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf index 4fa703776fb..69381cc377d 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf @@ -105,6 +105,13 @@ Suggest names for unresolved identifiers; 使用减法代替求反 + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) F# 可释放值(局部值) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf index fd46ef9919a..4bdafbf0121 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf @@ -105,6 +105,13 @@ Suggest names for unresolved identifiers; 使用減號代替否定 + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) F# 可處置的值 (區域) diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs new file mode 100644 index 00000000000..4fd407f7a40 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -0,0 +1,503 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Editor.Tests + +open System + +open Xunit + +open Microsoft.CodeAnalysis +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Editor.Tests.Helpers +open CancellableTasks + +module CopilotContextProviderTests = + + let fileContents = + """ +module Widgets + +/// Counts things that matter. +type Counter(start: int) = + let mutable value = start + + member _.Value = value + + member _.Bump() = + value <- value + 1 + value + +type Shape = + | Circle of radius: float + | Square of side: float + +let describeShape shape = + match shape with + | Circle r -> $"circle {r}" + | Square s -> $"square {s}" + +/// Twice the value. +let twice x = x * 2 +""" + + let solution = RoslynTestHelpers.CreateSolution fileContents + + /// One matcher and one parse cache per test, so what a test leaves parsed cannot answer the next one. + let private freshCache () = + MefHelpers.createExportProvider().GetExportedValue() + + let private cache = freshCache () + + let private run computation = + CancellableTask.runSynchronouslyWithoutCancellation computation + + let private solutionOf files = + let projectId = ProjectId.CreateNewId() + + let documents = + files + |> List.map (fun (path, source) -> RoslynTestHelpers.CreateDocumentInfo projectId path source) + + let solution = + RoslynTestHelpers.CreateSolution [ RoslynTestHelpers.CreateProjectInfo projectId "C:\\many.fsproj" documents ] + + { RoslynTestHelpers.DefaultProjectOptions with + SourceFiles = files |> List.map fst |> Array.ofList + } + |> RoslynTestHelpers.SetProjectOptions projectId solution + + solution + + let private documentsOf (solution: Solution) = + solution.Projects |> Seq.exactlyOne |> _.Documents |> Seq.toArray + + let private documentNamed (name: string) solution = + documentsOf solution + |> Array.find _.FilePath.EndsWith(name, StringComparison.Ordinal) + + let private hitsIn cache openDocumentIds focus solution pattern = + CopilotSymbolQuery.search cache openDocumentIds focus solution [| pattern |] + |> run + |> Array.head + + let private namesOf hits = + hits + |> Array.map (fun (struct (item, _, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + + let private searchFocused cache openDocumentIds focus solution pattern = + hitsIn cache openDocumentIds focus solution pattern |> namesOf + + let private searchIn cache openDocumentIds solution pattern = + searchFocused cache openDocumentIds ValueNone solution pattern + + let private search pattern = + searchIn cache Seq.empty solution pattern + + let private itemNamed (fullyQualifiedName: string) = + hitsIn cache Seq.empty ValueNone solution fullyQualifiedName + |> Array.tryPick (fun (struct (item, _, _)) -> + if CopilotSymbolMapping.fullyQualifiedName item = fullyQualifiedName then + Some item + else + None) + |> Option.defaultWith (fun () -> failwith $"no declaration named {fullyQualifiedName}") + + let private contextIn cache solution (name: string) = + CopilotSymbolQuery.symbolContext cache Seq.empty solution name + |> run + |> ValueOption.defaultWith (fun () -> failwith $"expected a symbol context for {name}") + + let private contextOf name = contextIn cache solution name + + /// A file holding more declarations matching `name` than one query reports. + let private manyDeclarations name count = + let members = + [ for i in 1..count -> $" member _.{name}{i} = {i}" ] |> String.concat "\n" + + $"module {name}Module\n\ntype {name}Holder() =\n{members}\n" + + let private coldFile = "C:\\cold.fs", "module Cold\n\nlet widgetCounter = 1\n" + + /// The holder's declaration loses on every other part of the ordering - the name it is matched + /// against is longer - so it can only come first through where its file sits. + let private twoWidgets = + [ + "C:\\elsewhere.fs", "module Elsewhere\n\ntype Widget() =\n member _.Value = 1\n" + "C:\\holder.fs", "module Holder\n\ntype WidgetHolder() =\n member _.Value = 2\n" + ] + + /// A value and a module whose names hold a dot, beside the nested paths that would spell the same + /// without the double backticks. + let private dottedSolution = + solutionOf + [ + "C:\\dotted.fs", + "module M\n\nlet ``a.b`` = 1\n\nmodule a =\n let b = 2\n\nmodule ``x.y`` =\n let z = 3\n\nmodule x =\n module y =\n let z = 4\n" + ] + + let private caretOn filePath line = + ValueSome + { + FilePath = filePath + FirstLine = line + LastLine = line + } + + [] + [] + [] + [] + [] + let ``search finds a declaration by its fully qualified name`` (pattern: string, expected: string) = + Assert.Contains(expected, search pattern) + + [] + [] + [] + [] + [] + [] + [] + let ``a name matches only the declaration it spells out`` (candidate: string, expected: bool) = + Assert.Equal(expected, CopilotSymbolMapping.hasFullyQualifiedName candidate (itemNamed "Widgets.Counter")) + + [] + [] + [] + [] + [] + let ``a tooltip names a member by its container and a type by itself`` (fullyQualifiedName: string, expected: string) = + Assert.Equal(expected, CopilotSymbolMapping.tooltipName (itemNamed fullyQualifiedName)) + + /// The inputs of a query are split on ':', so "#fsharpSymbol:Ns.Type:15" arrives as three of them. + [] + [] + [] + [] + [] + [] + [] + let ``the search text is the input after the member name`` (inputs: string, expected: string) = + let query = CopilotMentionQuery(CopilotMentionType.Unknown, inputs.Split ':') + + Assert.Equal(ValueOption.ofObj expected, CopilotSymbolMapping.searchTextOf query) + + [] + let ``only context mentions are searched for`` () = + let query = CopilotMentionQuery(CopilotMentionType.Command, [| "Widget" |]) + + Assert.True((CopilotSymbolMapping.searchTextOf query).IsNone) + + [] + let ``search reports each declaration once`` () = + let names = search "Counter" + Assert.Equal((Array.distinct names).Length, names.Length) + + [] + let ``an unknown name has no context`` () = + Assert.True( + (CopilotSymbolQuery.symbolContext cache Seq.empty solution "Widgets.NoSuchThing" + |> run) + .IsNone + ) + + [] + [] + [] + let ``a known file answers without parsing the rest of the solution`` (isOpen: bool) = + let cache = freshCache () + let solution = solutionOf [ "C:\\known.fs", manyDeclarations "Widget" 25; coldFile ] + let known = documentNamed "known.fs" solution + + let openDocumentIds = + if isOpen then + [ known.Id ] + else + cache.GetNavigableItems known |> run |> ignore + [] + + let names = searchIn cache openDocumentIds solution "Widget" + + Assert.Equal(20, names.Length) + Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal)) + Assert.True((cache.TryGetCachedNavigableItems (documentNamed "cold.fs" solution).Id).IsNone) + + [] + let ``the search stops once it has enough declarations`` () = + let cache = freshCache () + + let solution = + solutionOf + [ + for i in 1..150 -> $"C:\\cold{i}.fs", $"module Cold{i}\n\ntype Counter{i}() =\n member _.Value = {i}\n" + ] + + let names = searchIn cache Seq.empty solution "Counter" + + Assert.Equal(20, names.Length) + + Assert.NotEmpty( + documentsOf solution + |> Array.filter (fun document -> (cache.TryGetCachedNavigableItems document.Id).IsNone) + ) + + /// Focus outranks being open, which outranks the rest; a focused file that is no longer open counts + /// for nothing, since nothing tells the tracker that its tab has closed. + [] + [] + [] + [] + [] + let ``the file the user works in answers first`` (holderIsOpen: bool, holderIsFocused: bool, expected: string) = + let cache = freshCache () + let solution = solutionOf twoWidgets + let holder = documentNamed "holder.fs" solution + + let openDocumentIds = + [ + if holderIsOpen then + holder.Id + ] + + let focus = + if holderIsFocused then + caretOn "C:\\holder.fs" 1 + else + ValueNone + + let names = searchFocused cache openDocumentIds focus solution "Widget" + + Assert.Equal(expected, Array.head names) + Assert.Equal(2, names.Length) + + /// A bare "#" is the first thing the picker asks, and Copilot's own provider fills it from the open files. + [] + let ``no text at all answers with the declarations of the open files`` () = + let cache = freshCache () + let solution = solutionOf [ "C:\\open.fs", manyDeclarations "Widget" 3; coldFile ] + + let names = + searchFocused cache [ (documentNamed "open.fs" solution).Id ] (caretOn "C:\\open.fs" 1) solution "" + + Assert.Contains("WidgetModule.WidgetHolder", names) + Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal)) + Assert.True((cache.TryGetCachedNavigableItems (documentNamed "cold.fs" solution).Id).IsNone) + + [] + [] + [] + let ``a short text is looked up in the open files alone`` (pattern: string) = + let cache = freshCache () + + let solution = + solutionOf [ "C:\\open.fs", "module Open\n\nlet other = 1\n"; coldFile ] + + let names = + searchIn cache [ (documentNamed "open.fs" solution).Id ] solution pattern + + Assert.Equal(pattern.Length >= 3, Array.contains "Cold.widgetCounter" names) + + /// The type around the caret loses on name length to the other one, so it can only come first by + /// holding the caret - on a line of its member's body, not of its own name. + [] + [] + [] + let ``the declaration around the caret answers before the rest of the focused file`` (caretLine: int, expected: string) = + let cache = freshCache () + + let source = + "module Selection\n\ntype Short() =\n member _.Value = 1\n\ntype AroundTheCaret() =\n member _.Compute() =\n 2\n" + + let solution = solutionOf [ "C:\\selection.fs", source ] + let focused = documentNamed "selection.fs" solution + + let names = + searchFocused cache [ focused.Id ] (caretOn "C:\\selection.fs" caretLine) solution "" + |> Array.filter (fun name -> name = "Selection.Short" || name = "Selection.AroundTheCaret") + + Assert.Equal(expected, Array.head names) + + /// Two overloads answer to the same fully qualified name, so navigating one after the picker has + /// closed - when there is no caret to consult any more - has to go by the line recorded when it was + /// picked, not by whichever overload a solution-wide scan happens to reach first. + [] + let ``navigating a mention goes to the overload it was picked for`` () = + let cache = freshCache () + + let source = + "module Overloads\n\ntype Counter() =\n member _.Bump() =\n 1\n\n member _.Bump(step: int) =\n step\n" + + let solution = solutionOf [ "C:\\overloads.fs", source ] + + let declarations = + CopilotSymbolQuery.declarationsOf cache Seq.empty solution "Overloads.Counter.Bump" + |> run + + let lineOf index = + let struct (item: FSharp.Compiler.EditorServices.NavigableItem, _) = + declarations[index] + + item.Range.StartLine + + Assert.Equal(2, declarations.Length) + + let firstLine, secondLine = lineOf 0, lineOf 1 + + let picked line = + CopilotSymbolQuery.declarationAt (ValueSome line) declarations + |> ValueOption.map (fun (struct (item, _)) -> item.Range.StartLine) + + Assert.Equal(ValueSome firstLine, picked firstLine) + Assert.Equal(ValueSome secondLine, picked secondLine) + + // An unknown line, or none at all, falls back to the first - the only choice before this line + // was tracked, and still the answer for a mention picked before this change shipped. + Assert.Equal(ValueSome firstLine, picked -1) + + Assert.Equal( + ValueSome firstLine, + CopilotSymbolQuery.declarationAt ValueNone declarations + |> ValueOption.map (fun (struct (item, _)) -> item.Range.StartLine) + ) + + [] + let ``a batch of texts answers like the same texts one by one`` () = + let cache = freshCache () + + let solution = + solutionOf + [ + "C:\\a.fs", "module A\n\nlet alpha = 1\n" + "C:\\b.fs", "module B\n\nlet beta = 2\n" + ] + + let batched = + CopilotSymbolQuery.search cache Seq.empty ValueNone solution [| "alpha"; "beta" |] + |> run + |> Array.map namesOf + + Assert.Equal(searchIn cache Seq.empty solution "alpha", batched[0]) + Assert.Equal(searchIn cache Seq.empty solution "beta", batched[1]) + + [] + let ``a type context carries the whole declaration and its doc comment`` () = + let context = contextOf "Widgets.Counter" + + Assert.Equal("Widgets.Counter", context.FullyQualifiedName) + Assert.Equal("Counter", context.UnqualifiedName) + Assert.Contains("Counts things that matter.", context.Snippet) + Assert.Contains("member _.Bump()", context.Snippet) + + [] + let ``a member context carries the member body alone`` () = + let context = contextOf "Widgets.Counter.Bump" + + Assert.Contains("value <- value + 1", context.Snippet) + Assert.DoesNotContain("type Counter", context.Snippet) + + [] + let ``a one-line declaration keeps its doc comment`` () = + let context = contextOf "Widgets.twice" + + Assert.Contains("Twice the value.", context.Snippet) + Assert.Contains("let twice x", context.Snippet) + Assert.DoesNotContain("describeShape", context.Snippet) + + [] + [] + [] + [] + [] + [] + let ``declaration kinds map onto Copilot symbol types`` (name: string, expected: CopilotSymbolContextType) = + Assert.Equal(expected, (contextOf name).SymbolType) + + [] + let ``a context points back at the source it was taken from`` () = + let context = contextOf "Widgets.Counter" + let location = Assert.Single context.SnippetLocations + let document = RoslynTestHelpers.GetSingleDocument solution + + Assert.Equal(document.FilePath, location.FilePath) + Assert.Equal(context.Snippet.Length, location.Span.Length) + + [] + let ``names that differ only in double backticks answer as two mentions`` () = + let names = searchIn cache Seq.empty dottedSolution "a.b" + + Assert.Contains("M.``a.b``", names) + Assert.Contains("M.a.b", names) + + [] + [] + [] + [] + [] + let ``a name holding a dot resolves to its own declaration`` (fullyQualifiedName: string, declaration: string) = + Assert.Equal(declaration, (contextIn cache dottedSolution fullyQualifiedName).Snippet.Trim()) + + /// Six lines, counting newlines: "module M", "", "let a = 1", "", "let b = 2", and the blank line a + /// trailing newline leaves after it. + let private focusContent = "module M\n\nlet a = 1\n\nlet b = 2\n" + + let private focusFilePath = "C:\\focus.fs" + + let private documentFocus caretOffset startLine endLine = + let caret = if caretOffset < 0 then Nullable() else Nullable caretOffset + + let lineRange = + if startLine < 0 then + Nullable() + else + Nullable(Microsoft.VisualStudio.RpcContracts.Utilities.Range(startLine, -1, endLine, -1)) + + let selection = + DocumentSelection(0, focusContent.Length, Caret = caret, LineRange = lineRange) + + DocumentContext(focusContent, FilePath = focusFilePath, TotalLinesInFile = 6, Selections = [| selection |]) + |> CopilotSymbolMapping.editorFocusOf + + /// Copilot reports the caret as the offset of its line's start (10, the start of "let a = 1"), a + /// line range as 1-based line numbers, and the whole file as a range covering every line - which no + /// declaration can be "around", so it answers "focused, no line known", the same as no selection at all. + [] + [] + [] + [] + let ``the caret or selection maps onto a focused line range`` + (caretOffset: int) + (startLine: int) + (endLine: int) + (expectedFirst: int) + (expectedLast: int) + = + let expected = + ValueSome + { + FilePath = focusFilePath + FirstLine = expectedFirst + LastLine = expectedLast + } + + Assert.Equal(expected, documentFocus caretOffset startLine endLine) + + [] + let ``no selection at all is focused with no line known`` () = + let document = DocumentContext(focusContent, FilePath = focusFilePath) + + let expected = + ValueSome + { + FilePath = focusFilePath + FirstLine = 0 + LastLine = 0 + } + + Assert.Equal(expected, CopilotSymbolMapping.editorFocusOf document) + + [] + let ``a document with no file path is not focused`` () = + let document = DocumentContext(focusContent) + Assert.True((CopilotSymbolMapping.editorFocusOf document).IsNone) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..eadb8905ab4 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 @@ +