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 @@
+