diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index ef64e1a75c4..fc6887e7301 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/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..7a5da42a1a2
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs
@@ -0,0 +1,348 @@
+// 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.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 FSharp.Compiler.EditorServices
+open CancellableTasks
+
+/// 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 =
+
+ []
+ 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"
+
+ let private fsharpDocuments (solution: Solution) =
+ solution.Projects
+ |> Seq.where (fun project -> project.Language = FSharpConstants.FSharpLanguageName)
+ |> Seq.collect _.Documents
+
+ let describe (item: NavigableItem) (document: Document) =
+ let container =
+ match item.Container.FullName with
+ | "" -> Path.GetFileName document.FilePath
+ | name -> name
+
+ if document.IsFSharpSignatureFile then
+ $"signature, {container} - {document.Project.Name}"
+ else
+ $"{container} - {document.Project.Name}"
+
+ /// Declarations whose fully qualified name matches `searchText`, best match first, one entry per name.
+ let search (cache: FSharpNavigableItemsCache) (solution: Solution) (searchText: string) =
+ cancellableTask {
+ let! ct = CancellableTask.getCancellationToken ()
+ let tryMatch = cache.CreateMatcherFor searchText
+
+ let matchesIn (document: Document) =
+ cancellableTask {
+ ct.ThrowIfCancellationRequested()
+ let! items = cache.GetNavigableItems document
+
+ return
+ items
+ |> Seq.chooseV (fun item ->
+ tryMatch item
+ |> ValueOption.map (fun patternMatch -> struct (patternMatch.Kind, item, document)))
+ }
+
+ let! hits =
+ fsharpDocuments solution
+ |> Seq.map matchesIn
+ // Throttle to avoid launching a parse per document in the solution all at once.
+ |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount)
+
+ return
+ hits
+ |> Seq.collect id
+ |> Seq.sortBy (fun (struct (kind, item: NavigableItem, document: Document)) ->
+ document.IsFSharpSignatureFile, kind, item.Name.Length)
+ |> Seq.distinctBy (fun (struct (_, item, _)) -> CopilotSymbolMapping.fullyQualifiedName item)
+ |> Seq.truncate MaxMentions
+ |> Seq.map (fun (struct (_, item, document)) -> struct (item, document))
+ |> Seq.toArray
+ }
+
+ /// Declarations carrying exactly this fully qualified name. Signature files answer only when no
+ /// implementation declares the name.
+ let declarationsOf (cache: FSharpNavigableItemsCache) (solution: Solution) (fullyQualifiedName: string) =
+ cancellableTask {
+ let! ct = CancellableTask.getCancellationToken ()
+
+ let matchesIn (document: Document) =
+ cancellableTask {
+ ct.ThrowIfCancellationRequested()
+ let! items = cache.GetNavigableItems document
+
+ return
+ items
+ |> Seq.chooseV (fun item ->
+ if CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName item then
+ ValueSome struct (item, document)
+ else
+ ValueNone)
+ }
+
+ let! hits =
+ fsharpDocuments solution
+ |> Seq.map matchesIn
+ // Throttle to avoid launching a parse per document in the solution all at once.
+ |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount)
+ |> CancellableTask.map (Seq.collect id)
+
+ let implementations =
+ hits
+ |> Seq.filter (fun (struct (_, document: Document)) -> not document.IsFSharpSignatureFile)
+
+ let preferred =
+ if Seq.isEmpty implementations then
+ hits :> _ seq
+ else
+ implementations
+
+ return preferred |> Seq.truncate MaxDeclarations |> Seq.toArray
+ }
+
+ /// The source of the whole declaration `item` names, together with the span it occupies.
+ let snippetOf (item: NavigableItem) (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())
+
+ let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree
+
+ let struct (firstLine, lastLine) =
+ CopilotSymbolSnippets.definitionLines sourceLines scopes item
+
+ let firstLine = max 1 firstLine
+ let lastLine = min sourceText.Lines.Count lastLine
+
+ let span =
+ TextSpan.FromBounds(sourceText.Lines[firstLine - 1].Start, sourceText.Lines[lastLine - 1].End)
+
+ return struct (sourceText.GetSubText(span).ToString(), span)
+ }
+
+ let symbolContext (cache: FSharpNavigableItemsCache) (solution: Solution) (fullyQualifiedName: string) =
+ cancellableTask {
+ let! declarations = declarationsOf cache solution fullyQualifiedName
+
+ match Array.tryHeadV declarations with
+ | ValueNone -> return ValueNone
+ | ValueSome(struct (first, _)) ->
+ let snippets = ResizeArray()
+ let locations = ResizeArray()
+
+ for struct (item, document) in declarations do
+ let! struct (text, span) = snippetOf item document
+ 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, [] workspace: VisualStudioWorkspace | null) =
+
+ 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
+ )
+ |]
+ )
+
+ static let members = [| descriptor |] :> IReadOnlyList
+
+ static let memberNames = [| CopilotSymbolMapping.SymbolMember |] :> IReadOnlyList
+
+ static let noMentions =
+ Array.empty :> IReadOnlyCollection
+
+ let mentionFor (item: NavigableItem) (document: Document) =
+ let inputs = Dictionary(StringComparer.Ordinal)
+
+ inputs[CopilotSymbolMapping.FullyQualifiedNameInput] <-
+ CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item)
+
+ let description = CopilotSymbolQuery.describe item document
+
+ CopilotQueriedContextMention(
+ moniker,
+ descriptor,
+ inputs,
+ item.Name,
+ Description = description,
+ Tooltip = description,
+ Icon = Nullable(CopilotSymbolMapping.icon item.Kind),
+ IsNavigable = true
+ )
+ :> CopilotQueriedMention
+
+ /// The user is still typing, so the trailing input is the search text. It is preceded by the member
+ /// name once the mention has been committed, as in "#fsharpSymbol:Namespace.Type".
+ let searchTextOf (query: CopilotMentionQuery) =
+ match query.Type, query.Inputs with
+ | CopilotMentionType.Context, null -> ValueNone
+ | CopilotMentionType.Context, inputs when inputs.Count > 0 ->
+ match inputs[inputs.Count - 1] with
+ | text when String.IsNullOrWhiteSpace text -> ValueNone
+ | text when String.Equals(text, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) -> ValueNone
+ | text -> ValueSome text
+ | _ -> ValueNone
+
+ let mentionsFor (searchText: string voption) =
+ cancellableTask {
+ match workspace, searchText with
+ | null, _
+ | _, ValueNone -> return noMentions
+ | workspace, ValueSome searchText ->
+ let! hits = CopilotSymbolQuery.search cache workspace.CurrentSolution searchText
+
+ return
+ hits |> Array.map (fun (struct (item, document)) -> mentionFor item document)
+ :> IReadOnlyCollection
+ }
+
+ 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
+
+ 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.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 (searchTextOf query) |> 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 solution fullyQualifiedName
+
+ match Array.tryHeadV 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.
+ // Each distinct search text scans the solution once, and the scans run side by side.
+ interface ICopilotMentionBatchQueryable with
+ member _.QueryMentionBatchAsync(queries, cancellationToken) : Task>> =
+ cancellableTask {
+ let searchTexts = queries |> Seq.map searchTextOf |> Seq.toArray
+ let distinct = Array.distinct searchTexts
+ let! mentions = distinct |> Array.map mentionsFor |> CancellableTask.whenAll
+ let byText = Array.zip distinct mentions |> dict
+
+ return searchTexts |> Array.map (fun text -> byText[text]) :> 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..21e296c0476
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs
@@ -0,0 +1,74 @@
+// 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 Microsoft.VisualStudio.Copilot
+open Microsoft.VisualStudio.Imaging
+
+open FSharp.Compiler.EditorServices
+
+/// 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"
+
+/// 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)
+
+/// Dotted path that both drives the picker's pattern matching and identifies a picked mention
+/// when it is resolved back to source.
+let fullyQualifiedName (item: NavigableItem) =
+ match item.Container.FullName with
+ | "" -> item.Name
+ | container -> $"{container}.{item.Name}"
+
+/// 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.FullName
+ let name = item.Name.AsSpan()
+
+ if container.Length = 0 then
+ candidate.Equals(name, StringComparison.Ordinal)
+ else
+ candidate.Length = container.Length + 1 + name.Length
+ && candidate[container.Length] = '.'
+ && candidate.Slice(0, container.Length).Equals(container.AsSpan(), StringComparison.Ordinal)
+ && candidate.Slice(container.Length + 1).Equals(name, StringComparison.Ordinal)
diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs
new file mode 100644
index 00000000000..b31d9c192bc
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs
@@ -0,0 +1,53 @@
+// 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 declaration `item` names, including its doc comment.
+let definitionLines (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
+
+ let firstLine = docCommentStart firstLine
+
+ struct (firstLine, min lastLine (firstLine + MaxSnippetLines - 1))
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
index 319bdd5a264..3176e1b964c 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
@@ -94,6 +94,9 @@
+
+
+
@@ -179,6 +182,7 @@
+
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs
index 427baf0c6ab..68bb0b038e7 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,47 @@ type internal FSharpPackage() as this =
|> CancellableTask.startAsTask cancellationToken)
)
+ override this.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) =
+ base.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks)
+
+ afterPackageLoadedTasks.AddTask(
+ false,
+ fun _ cancellationToken ->
+ task {
+ try
+ 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.
+ let! registration =
+ container
+ .GetFullAccessServiceBroker()
+ .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken)
+
+ use registration = registration
+
+ match registration with
+ | null -> ()
+ | registration ->
+ let moniker =
+ ServiceMoniker(
+ FSharpConstants.copilotSymbolProviderName,
+ Version CopilotDescriptors.CurrentContextProviderVersion
+ )
+
+ do! registration.RegisterContextProviderAsync(moniker, cancellationToken)
+ | _ -> ()
+ // Package load runs its tasks back to back on one loop, so a Copilot failure - a contract
+ // version the installed build does not serve, say - must not take the F# package down with it.
+ with ex when not (ex :? OperationCanceledException) ->
+ DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider")
+ }
+ :> Task
+ )
+
#if DEBUG
- override _.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) =
afterPackageLoadedTasks.AddTask(
false,
fun _ _ ->
diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs
index 546b00e1b16..75a349040b2 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,71 @@ 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
}
+ 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 +153,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 +165,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 +195,9 @@ type internal FSharpNavigateToSearchService
)
)
| _ -> ()
- |]
+ }
- return processed
+ return processed |> Seq.toImmutableArray
}
interface IFSharpNavigateToSearchService with
@@ -194,31 +207,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/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs
new file mode 100644
index 00000000000..c7ad810ab4a
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs
@@ -0,0 +1,141 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace FSharp.Editor.Tests
+
+open System.Threading
+
+open Xunit
+
+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
+
+ let private cache =
+ MefHelpers.createExportProvider().GetExportedValue()
+
+ let private run computation =
+ computation |> CancellableTask.start CancellationToken.None |> _.Result
+
+ let private search pattern =
+ CopilotSymbolQuery.search cache solution pattern
+ |> run
+ |> Array.map (fun (struct (item, _)) -> CopilotSymbolMapping.fullyQualifiedName item)
+
+ let private symbolContext name =
+ CopilotSymbolQuery.symbolContext cache solution name |> run
+
+ let private contextOf name =
+ match symbolContext name with
+ | ValueSome context -> context
+ | ValueNone -> failwith $"expected a symbol context for {name}"
+
+ []
+ []
+ []
+ []
+ []
+ 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) =
+ let item =
+ CopilotSymbolQuery.search cache solution "Counter"
+ |> run
+ |> Array.pick (fun (struct (item, _)) ->
+ if CopilotSymbolMapping.fullyQualifiedName item = "Widgets.Counter" then
+ Some item
+ else
+ None)
+
+ Assert.Equal(expected, CopilotSymbolMapping.hasFullyQualifiedName candidate item)
+
+ []
+ 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((symbolContext "Widgets.NoSuchThing").IsNone)
+
+ []
+ 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 = solution.Projects |> Seq.exactlyOne |> _.Documents |> Seq.exactlyOne
+
+ Assert.Equal(document.FilePath, location.FilePath)
+ Assert.Equal(context.Snippet.Length, location.Span.Length)
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 @@
+