diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index 2d032095814..6f9d34e2f62 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -4,6 +4,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))
* Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485))
+* 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 fb6753942db..5a15e374dda 100644
--- a/eng/Packages.props
+++ b/eng/Packages.props
@@ -65,6 +65,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..6065aca2c67 100644
--- a/vsintegration/src/FSharp.Editor/Common/Constants.fs
+++ b/vsintegration/src/FSharp.Editor/Common/Constants.fs
@@ -43,6 +43,10 @@ module internal FSharpConstants =
/// "F# Language Service"
let FSharpLanguageServiceCallbackName = "F# Language Service"
+ []
+ 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..be21e47256c
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs
@@ -0,0 +1,725 @@
+// 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.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
+
+[]
+type internal PickedDeclaration =
+ {
+ File: string
+ ProjectName: string
+ Line: int
+ }
+
+[]
+type internal DocumentFocus =
+ | Selected
+ | Focused
+ | Open
+ | Elsewhere
+
+module internal CopilotSymbolQuery =
+
+ []
+ let private MaxMentions = 20
+
+ []
+ let private MaxDeclarations = 4
+
+ []
+ let private UserOpName = "CopilotSymbolContext"
+
+ /// Copilot drops the partial answer of a cancelled query, so a long cold scan stops itself instead.
+ []
+ let private ColdSearchBudgetMs = 1500L
+
+ []
+ let private MinSolutionWideSearchLength = 3
+
+ let private parallelism = max 1 (Environment.ProcessorCount - 1)
+
+ 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)
+
+ let private documentFocusOf
+ (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
+
+ []
+ type private Outline =
+ {
+ Text: SourceText
+ Lines: string array
+ Declared: Dictionary
+ }
+
+ 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
+ Declared =
+ Structure.getOutliningRanges sourceLines parseResults.ParseTree
+ |> CopilotSymbolSnippets.declaredByLine
+ }
+ }
+
+ let private notSelected (_: NavigableItem) = false
+
+ let private isSelectedIn (focus: EditorFocus) (document: Document) =
+ cancellableTask {
+ let! outline = outlineOf document
+
+ return
+ fun (item: NavigableItem) ->
+ let struct (firstLine, lastLine) =
+ CopilotSymbolSnippets.declarationLines outline.Lines outline.Declared item
+
+ firstLine <= focus.LastLine && focus.FirstLine <= lastLine
+ }
+
+ let 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
+ }
+
+ // Navigate To fills the same cache between the two walks: cold is what the cached walk did not answer.
+ let answered = HashSet()
+
+ let cached =
+ seq {
+ for document in unopened do
+ match cache.TryGetCachedNavigableItems document with
+ | ValueSome items ->
+ answered.Add document.Id |> ignore
+ struct (document, items)
+ | ValueNone -> ()
+ }
+
+ let cold =
+ seq {
+ for document in unopened do
+ if not (answered.Contains document.Id) then
+ document
+ }
+
+ struct (opened, cached, cold)
+
+ 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! Task.parallelDoLimit parallelism ct (opened |> Seq.map 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 {
+ // Gates the next parse rather than cancelling one: a session's first query must survive its first parse.
+ if budget.ElapsedMilliseconds < budgetMs && not (enough ()) then
+ do! parseAndCollect document
+ }
+
+ do! Task.parallelDoLimit parallelism ct (cold |> Seq.map 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
+
+ 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
+
+ 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 -> isSelectedIn 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 = documentFocusOf 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())
+ }
+
+ let private declarationsFound
+ (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.toArray
+
+ return
+ match implementations with
+ | [||] -> hits.ToArray()
+ | implementations -> implementations
+ }
+
+ /// The picked project's copy of a linked file answers first: its defines decide what is declared where.
+ let private declarationsPickedAt
+ (cache: FSharpNavigableItemsCache)
+ (solution: Solution)
+ (fullyQualifiedName: string)
+ (picked: PickedDeclaration)
+ =
+ cancellableTask {
+ let inFile =
+ solution.GetDocumentIdsWithFilePath picked.File
+ |> Seq.map (fun documentId -> solution.GetDocument documentId)
+ |> Seq.filter (fun document -> document.Project.IsFSharp && not document.IsFSharpSignatureFile)
+ |> Seq.toArray
+
+ let isPickedProject (document: Document) =
+ String.Equals(document.Project.Name, picked.ProjectName, StringComparison.Ordinal)
+
+ let documents =
+ match Array.filter isPickedProject inFile with
+ | [||] -> inFile
+ | inPickedProject -> inPickedProject
+
+ let found = ResizeArray()
+
+ for document in documents do
+ let! items = cache.GetNavigableItems document
+
+ for item in items do
+ if CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName item then
+ found.Add(struct (item, document))
+
+ return
+ found
+ |> Seq.sortBy (fun (struct (item, _)) -> item.Range.StartLine <> picked.Line)
+ |> Seq.toArray
+ }
+
+ let declarationsOf
+ (cache: FSharpNavigableItemsCache)
+ (openDocumentIds: DocumentId seq)
+ (solution: Solution)
+ (fullyQualifiedName: string)
+ (picked: PickedDeclaration voption)
+ =
+ cancellableTask {
+ let! pickedDeclarations =
+ match picked with
+ | ValueSome picked -> declarationsPickedAt cache solution fullyQualifiedName picked
+ | ValueNone -> CancellableTask.singleton Array.empty
+
+ match pickedDeclarations with
+ | [||] -> return! declarationsFound cache openDocumentIds solution fullyQualifiedName
+ | declarations -> return declarations
+ }
+
+ let private snippetOf (outline: Outline) (item: NavigableItem) =
+ let struct (firstLine, lastLine) =
+ CopilotSymbolSnippets.definitionLines outline.Lines outline.Declared 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)
+ (picked: PickedDeclaration voption)
+ =
+ cancellableTask {
+ let! declarations = declarationsOf cache openDocumentIds solution fullyQualifiedName picked
+ let declarations = Array.truncate MaxDeclarations declarations
+
+ 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
+
+ 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()
+ )
+ )
+ }
+
+[; typeof |],
+ Audience = (ServiceAudience.PublicSdk ||| ServiceAudience.Local))>]
+type internal FSharpCopilotContextProvider
+ []
+ (
+ cache: FSharpNavigableItemsCache,
+ [)>] serviceBroker: IServiceBroker,
+ [] workspace: VisualStudioWorkspace | null
+ ) =
+
+ // Never disposed: disposing it would dispose the broker it wraps.
+ let documentContexts =
+ new ServiceBrokerClient(serviceBroker, ThreadHelper.JoinableTaskFactory)
+
+ let editorFocus () =
+ 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.DeclarationFileInput,
+ "File the picked declaration is in.",
+ CopilotDefaultTypes.StringName,
+ IsRequired = false
+ )
+ CopilotInputDescriptor(
+ CopilotSymbolMapping.DeclarationProjectInput,
+ "Project the picked declaration is compiled in.",
+ CopilotDefaultTypes.StringName,
+ IsRequired = false
+ )
+ 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(4, StringComparer.Ordinal)
+
+ inputs[CopilotSymbolMapping.FullyQualifiedNameInput] <-
+ CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item)
+
+ match document.FilePath with
+ | null -> ()
+ | filePath ->
+ inputs[CopilotSymbolMapping.DeclarationFileInput] <- CopilotValue(CopilotDefaultTypes.StringName, filePath)
+ inputs[CopilotSymbolMapping.DeclarationProjectInput] <- CopilotValue(CopilotDefaultTypes.StringName, document.Project.Name)
+
+ 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
+
+ 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 = editorFocus ()
+
+ 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 =
+ CopilotSymbolMapping.textOf CopilotSymbolMapping.FullyQualifiedNameInput inputs
+
+ let pickedAt inputs =
+ match
+ CopilotSymbolMapping.textOf CopilotSymbolMapping.DeclarationFileInput inputs,
+ CopilotSymbolMapping.textOf CopilotSymbolMapping.DeclarationProjectInput inputs,
+ CopilotSymbolMapping.inputOf CopilotSymbolMapping.DeclarationLineInput inputs
+ with
+ | ValueSome file, ValueSome project, ValueSome line ->
+ ValueSome
+ {
+ File = file
+ ProjectName = project
+ Line = line
+ }
+ | _ -> 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
+ (pickedAt inputs)
+
+ 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
+ (pickedAt mention.Inputs)
+
+ 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
+
+ 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..cf2d3cd7dca
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs
@@ -0,0 +1,175 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+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
+
+/// Also the mention prefix the user types and sees: "#fsharpSymbol:Namespace.Type.Member".
+[]
+let SymbolMember = "fsharpSymbol"
+
+[]
+let FullyQualifiedNameInput = "fullyQualifiedName"
+
+[]
+let DeclarationLineInput = "declarationLine"
+
+[]
+let DeclarationFileInput = "declarationFile"
+
+[]
+let DeclarationProjectInput = "declarationProject"
+
+let inputOf<'T> name (inputs: IReadOnlyDictionary | null) =
+ match inputs with
+ | null -> ValueNone
+ | inputs ->
+ match inputs.TryGetValue name with
+ | true, value ->
+ match value.TryGetValue<'T>() with
+ | true, input -> ValueSome input
+ | _ -> ValueNone
+ | _ -> ValueNone
+
+let textOf name inputs =
+ inputOf name inputs
+ |> ValueOption.filter (fun text -> not (String.IsNullOrWhiteSpace text))
+
+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)
+
+/// FCS joins dotted paths: only a container's last segment can be quoted, and a file's top-level module not at all.
+let private isQuoted (item: NavigableItem) =
+ item.NeedsBackticks && not (item.Kind.IsModule && item.Container.Type.IsFile)
+
+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
+
+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)
+
+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}"
+
+/// The suffix test spares a solution-wide scan from building the name of every declaration it passes.
+let hasFullyQualifiedName (candidate: string) (item: NavigableItem) =
+ (candidate.EndsWith(item.Name, StringComparison.Ordinal)
+ || candidate.EndsWith("``", StringComparison.Ordinal))
+ && String.Equals(candidate, fullyQualifiedName item, StringComparison.Ordinal)
+
+/// The typed text split on ':' ("#fsharpSymbol:Ns.Type:15" arrives as three inputs), asked as Unknown before the picker resolves it.
+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
+
+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
+
+/// Copilot widens an empty selection to the whole file for F#, so a whole-file range means no line is known.
+let editorFocusOf (document: DocumentContext) =
+ match document.FilePath with
+ | null -> ValueNone
+ | filePath ->
+ let focusOn firstLine lastLine =
+ ValueSome
+ {
+ FilePath = filePath
+ FirstLine = firstLine
+ LastLine = lastLine
+ }
+
+ match document.Selections |> Seq.tryHeadV with
+ | ValueSome selection when selection.Caret.HasValue ->
+ let line = lineOf document.Content selection.Caret.Value
+ focusOn line line
+ | ValueSome selection when selection.LineRange.HasValue ->
+ let range = selection.LineRange.Value
+
+ let totalLines =
+ document.TotalLinesInFile
+ |> ValueOption.ofNullable
+ |> ValueOption.defaultWith (fun () -> lineOf document.Content document.Content.Length)
+
+ if range.StartLine <= 1 && range.EndLine >= totalLines then
+ focusOn 0 0
+ else
+ focusOn range.StartLine range.EndLine
+ | _ -> focusOn 0 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..525adab479f
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs
@@ -0,0 +1,51 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolSnippets
+
+open System
+open System.Collections.Generic
+
+open FSharp.Compiler.EditorServices
+
+[]
+let MaxSnippetLines = 200
+
+/// A scope's header runs from its Range start, over its doc comment, to its CollapseRange start.
+let declaredByLine (scopes: Structure.ScopeRange seq) =
+ let declared = Dictionary()
+
+ for scope in scopes do
+ if
+ scope.Scope <> Structure.Scope.Comment
+ && scope.Scope <> Structure.Scope.XmlDocComment
+ then
+ for line in scope.Range.StartLine .. scope.CollapseRange.StartLine do
+ match declared.TryGetValue line with
+ | true, widest when widest.Range.EndLine >= scope.Range.EndLine -> ()
+ | _ -> declared[line] <- scope
+
+ declared
+
+let declarationLines (sourceLines: string array) (declared: Dictionary) (item: NavigableItem) =
+ let declarationLine = item.Range.StartLine
+
+ let firstLine, lastLine =
+ match declared.TryGetValue declarationLine with
+ | true, scope when scope.Range.EndLine >= item.Range.EndLine -> scope.Range.StartLine, scope.Range.EndLine
+ | _ -> declarationLine, item.Range.EndLine
+
+ // Outlining reports no scope for a one-line "///", so walk back over it by text.
+ 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)
+
+let definitionLines (sourceLines: string array) (declared: Dictionary) (item: NavigableItem) =
+ let struct (firstLine, lastLine) = declarationLines sourceLines declared 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..d5d80548c94
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs
@@ -0,0 +1,12 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace Microsoft.VisualStudio.FSharp.Editor
+
+/// Lines are 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 ac8ceffd4f9..4a00ac8ff16 100644
--- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs
+++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs
@@ -6,10 +6,13 @@ 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.Options
+open Microsoft.ServiceHub.Framework
+open Microsoft.VisualStudio.Copilot
open FSharp.Compiler
open FSharp.Compiler.CodeAnalysis
open FSharp.NativeInterop
@@ -20,6 +23,7 @@ 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
@@ -411,6 +415,9 @@ type internal FSharpPackage() as this =
#if DEBUG
override _.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) =
+ // The base schedules LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync, which registers Copilot.
+ base.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks)
+
afterPackageLoadedTasks.AddTask(
false,
fun _ _ ->
@@ -422,6 +429,49 @@ type internal FSharpPackage() as this =
)
#endif
+ member private this.RegisterCopilotContextProviderAsync(cancellationToken: CancellationToken) : Task =
+ task {
+ try
+ // Copilot's registration part blocks on the main thread while MEF constructs it, so a background
+ // request deadlocks against the Git provider's request for it on the main thread at solution open.
+ do! this.JoinableTaskFactory.SwitchToMainThreadAsync(alwaysYield = true, cancellationToken = cancellationToken)
+
+ let! container = this.GetServiceAsync(typeof)
+
+ match container with
+ | :? IBrokeredServiceContainer as container ->
+ // The Interactions service also serves ICopilotRegistrationService.
+ 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)
+ | _ -> ()
+ with ex when not (ex :? OperationCanceledException) ->
+ DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider")
+ }
+
+ override this.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) : Task =
+ 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..ced67f2412e 100644
--- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs
+++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs
@@ -19,8 +19,8 @@ open Microsoft.VisualStudio.Text.PatternMatching
open FSharp.Compiler.EditorServices
open CancellableTasks
-[); Shared>]
-type internal FSharpNavigateToSearchService
+[]
+type internal FSharpNavigableItemsCache
[]
(patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) =
@@ -33,7 +33,7 @@ type internal FSharpNavigateToSearchService
if e.NewSolution.Id <> e.OldSolution.Id then
cache.Clear()
- let getNavigableItems (document: Document) =
+ member _.GetNavigableItems(document: Document) =
cancellableTask {
let! ct = CancellableTask.getCancellationToken ()
let! currentVersion = document.GetTextVersionAsync(ct)
@@ -41,12 +41,51 @@ type internal FSharpNavigateToSearchService
match cache.TryGetValue document.Id with
| true, (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
return items
}
+ /// Uses the version the document already knows: asking for it could read a closed file from disk.
+ member _.TryGetCachedNavigableItems(document: Document) =
+ match cache.TryGetValue document.Id, document.TryGetTextVersion() with
+ | (true, (version, items)), (true, currentVersion) when version = currentVersion -> 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 +154,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 {
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..daa5bfd4ace
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs
@@ -0,0 +1,615 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace FSharp.Editor.Tests
+
+open System
+open System.Collections.Generic
+
+open Xunit
+
+open Microsoft.CodeAnalysis
+open Microsoft.CodeAnalysis.Text
+open Microsoft.VisualStudio.Copilot
+open Microsoft.VisualStudio.FSharp.Editor
+
+open FSharp.Compiler.EditorServices
+open FSharp.Editor.Tests.Helpers
+open CancellableTasks
+
+module CopilotContextProviderTests =
+
+ let private widgets =
+ """
+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 private solution = RoslynTestHelpers.CreateSolution widgets
+
+ let private cache =
+ MefHelpers.createExportProvider().GetExportedValue()
+
+ let private run computation =
+ CancellableTask.runSynchronouslyWithoutCancellation computation
+
+ let private manyProject = "C:\\many.fsproj"
+
+ let private solutionOfProjects projects =
+ let projects =
+ [
+ for projectPath, defines, files in projects ->
+ let projectId = ProjectId.CreateNewId()
+
+ let documents =
+ files
+ |> List.map (fun (path, source) -> RoslynTestHelpers.CreateDocumentInfo projectId path source)
+
+ struct (projectId, defines, files, RoslynTestHelpers.CreateProjectInfo projectId projectPath documents)
+ ]
+
+ let solution =
+ RoslynTestHelpers.CreateSolution [ for struct (_, _, _, info) in projects -> info ]
+
+ for struct (projectId, defines, files, _) in projects do
+ { RoslynTestHelpers.DefaultProjectOptions with
+ SourceFiles = files |> List.map fst |> Array.ofList
+ OtherOptions = defines
+ }
+ |> RoslynTestHelpers.SetProjectOptions projectId solution
+
+ solution
+
+ let private solutionOf files =
+ solutionOfProjects [ manyProject, [||], files ]
+
+ let private documentAt (filePath: string) (solution: Solution) =
+ solution.Projects
+ |> Seq.collect _.Documents
+ |> Seq.find (fun document -> document.FilePath = filePath)
+
+ let private parse (document: Document) =
+ cache.GetNavigableItems document |> run |> ignore
+
+ let private isParsed (document: Document) =
+ (cache.TryGetCachedNavigableItems document).IsSome
+
+ let private hitsIn 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 namesIn openDocumentIds focus solution pattern =
+ hitsIn openDocumentIds focus solution pattern |> namesOf
+
+ let private itemNamed fullyQualifiedName =
+ hitsIn Seq.empty ValueNone solution fullyQualifiedName
+ |> Array.pick (fun (struct (item, _, _)) ->
+ if CopilotSymbolMapping.fullyQualifiedName item = fullyQualifiedName then
+ Some item
+ else
+ None)
+
+ let private contextIn solution (name: string) picked =
+ CopilotSymbolQuery.symbolContext cache Seq.empty solution name picked
+ |> run
+ |> ValueOption.defaultWith (fun () -> failwith $"expected a symbol context for {name}")
+
+ let private declarationsPickedIn solution name picked =
+ CopilotSymbolQuery.declarationsOf cache Seq.empty solution name picked |> run
+
+ let private lineOf (struct (item: NavigableItem, _: Document)) = item.Range.StartLine
+
+ let private fileOf (struct (_: NavigableItem, document: Document)) = document.FilePath
+
+ let private pickedIn file line =
+ ValueSome
+ {
+ File = file
+ ProjectName = manyProject
+ Line = line
+ }
+
+ let private focusOn filePath firstLine lastLine =
+ ValueSome
+ {
+ FilePath = filePath
+ FirstLine = firstLine
+ LastLine = lastLine
+ }
+
+ let private caretOn filePath line = focusOn filePath line line
+
+ 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 coldPath = "C:\\cold.fs"
+
+ let private coldFile = coldPath, "module Cold\n\nlet widgetCounter = 1\n"
+
+ let private openPath = "C:\\open.fs"
+
+ let private multilinePath = "C:\\multiline.fs"
+
+ let private multilineSolution =
+ solutionOf
+ [
+ multilinePath,
+ "module Multiline\n\n/// Adds.\nlet add\n (x: int)\n (y: int) =\n x + y\n\ntype Calculator() =\n member _.Sum\n (\n x: int,\n y: int\n ) =\n x * y\n"
+ ]
+
+ let private overloadsPath = "C:\\overloads.fs"
+
+ let private overloadsSolution =
+ let overloads =
+ [ "int"; "string"; "float"; "bool"; "char"; "byte" ]
+ |> List.map (fun parameterType ->
+ $" member _.Bump(step: {parameterType}) =\n ignore step\n \"{parameterType}\"\n")
+ |> String.concat "\n"
+
+ solutionOf [ overloadsPath, $"module Overloads\n\ntype Counter() =\n{overloads}" ]
+
+ 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 ``search finds a declaration by its fully qualified name`` (pattern: string, expected: string) =
+ Assert.Contains(expected, namesIn Seq.empty ValueNone solution 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))
+
+ []
+ []
+ []
+ []
+ []
+ []
+ []
+ 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 = namesIn Seq.empty ValueNone solution "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" ValueNone
+ |> run)
+ .IsNone
+ )
+
+ []
+ []
+ []
+ let ``a known file answers without parsing the rest of the solution`` (isOpen: bool) =
+ let solution = solutionOf [ "C:\\known.fs", manyDeclarations "Widget" 25; coldFile ]
+ let known = documentAt "C:\\known.fs" solution
+
+ let openDocumentIds =
+ if isOpen then
+ [ known.Id ]
+ else
+ parse known
+ []
+
+ let names = namesIn openDocumentIds ValueNone solution "Widget"
+
+ Assert.Equal(20, names.Length)
+ Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal))
+ Assert.False(isParsed (documentAt coldPath solution))
+
+ []
+ let ``the search stops once it has enough declarations`` () =
+ let solution =
+ solutionOf
+ [
+ for i in 1..150 -> $"C:\\cold{i}.fs", $"module Cold{i}\n\ntype Counter{i}() =\n member _.Value = {i}\n"
+ ]
+
+ Assert.Equal(20, (namesIn Seq.empty ValueNone solution "Counter").Length)
+ Assert.NotEmpty(solution.Projects |> Seq.collect _.Documents |> Seq.filter (isParsed >> not))
+
+ []
+ []
+ []
+ []
+ let ``an unopened document is visited once whenever the cache learns of it`` (cachedBefore: bool, cachedBetween: bool) =
+ let solution = solutionOf [ coldFile ]
+ let document = documentAt coldPath solution
+
+ if cachedBefore then
+ parse document
+
+ let struct (_, cached, cold) = CopilotSymbolQuery.tiers cache (HashSet()) solution
+ let answeredFromCache = Seq.length cached
+
+ if cachedBetween then
+ parse document
+
+ Assert.Equal(1, answeredFromCache + Seq.length cold)
+
+ []
+ let ``a parse of an earlier text does not answer for a document`` () =
+ let solution =
+ solutionOf
+ [
+ "C:\\edited.fs", "module Edited\n\nlet before = 1\n\n\n\n\nlet removedLater = 2\n"
+ ]
+
+ let document = documentAt "C:\\edited.fs" solution
+ parse document
+
+ let edited = document.WithText(SourceText.From("module Edited\n\nlet after = 1\n"))
+ let solution = edited.Project.Solution
+
+ Assert.False(isParsed edited)
+ Assert.Equal([| "Edited.after" |], namesIn Seq.empty ValueNone solution "after")
+ Assert.Empty(namesIn Seq.empty ValueNone solution "removedLater")
+
+ Assert.True(
+ (CopilotSymbolQuery.symbolContext cache Seq.empty solution "Edited.removedLater" ValueNone
+ |> run)
+ .IsNone
+ )
+
+ /// WidgetHolder loses to Widget on name length, so it can only come first through where its file sits.
+ []
+ []
+ []
+ []
+ []
+ let ``the file the user works in answers first`` (holderIsOpen: bool, holderIsFocused: bool, expected: string) =
+ let holderPath = "C:\\holder.fs"
+
+ let solution =
+ solutionOf
+ [
+ "C:\\elsewhere.fs", "module Elsewhere\n\ntype Widget() =\n member _.Value = 1\n"
+ holderPath, "module Holder\n\ntype WidgetHolder() =\n member _.Value = 2\n"
+ ]
+
+ let openDocumentIds =
+ [
+ if holderIsOpen then
+ (documentAt holderPath solution).Id
+ ]
+
+ let focus = if holderIsFocused then caretOn holderPath 1 else ValueNone
+ let names = namesIn openDocumentIds focus solution "Widget"
+
+ Assert.Equal(expected, Array.head names)
+ Assert.Equal(2, names.Length)
+
+ []
+ let ``no text at all answers with the declarations of the open files`` () =
+ let solution = solutionOf [ openPath, manyDeclarations "Widget" 3; coldFile ]
+
+ let names =
+ namesIn [ (documentAt openPath solution).Id ] (caretOn openPath 1) solution ""
+
+ Assert.Contains("WidgetModule.WidgetHolder", names)
+ Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal))
+ Assert.False(isParsed (documentAt coldPath solution))
+
+ []
+ []
+ []
+ let ``a short text is looked up in the open files alone`` (pattern: string) =
+ let solution = solutionOf [ openPath, "module Open\n\nlet other = 1\n"; coldFile ]
+ let names = namesIn [ (documentAt openPath solution).Id ] ValueNone solution pattern
+
+ Assert.Equal(pattern.Length >= 3, Array.contains "Cold.widgetCounter" names)
+
+ /// AroundTheCaret loses to Short on name length, so it can only come first by holding the caret in its member's body.
+ []
+ []
+ []
+ let ``the declaration around the caret answers before the rest of the focused file`` (caretLine: int, expected: string) =
+ let path = "C:\\selection.fs"
+
+ let source =
+ "module Selection\n\ntype Short() =\n member _.Value = 1\n\ntype AroundTheCaret() =\n member _.Compute() =\n 2\n"
+
+ let solution = solutionOf [ path, source ]
+
+ let names =
+ namesIn [ (documentAt path solution).Id ] (caretOn path caretLine) solution ""
+ |> Array.filter (fun name -> name = "Selection.Short" || name = "Selection.AroundTheCaret")
+
+ Assert.Equal(expected, Array.head names)
+
+ let private overloadsPickedAt picked =
+ declarationsPickedIn overloadsSolution "Overloads.Counter.Bump" picked
+ |> Array.map lineOf
+
+ []
+ let ``navigating a mention goes to the overload it was picked for`` () =
+ let lines = overloadsPickedAt ValueNone
+ Assert.Equal(6, (Array.distinct lines).Length)
+
+ for line in lines do
+ let picked = overloadsPickedAt (pickedIn overloadsPath line)
+ Assert.Equal(line, Array.head picked)
+ Assert.Equal(6, picked.Length)
+
+ // A stale line leaves the file's own order; a renamed file falls back to the solution-wide answer.
+ Assert.Equal(lines, overloadsPickedAt (pickedIn overloadsPath -1))
+ Assert.Equal(lines, overloadsPickedAt (pickedIn "C:\\renamed.fs" lines[5]))
+
+ []
+ let ``a context starts from the overload its mention was picked for`` () =
+ let lastOverload = Array.last (overloadsPickedAt ValueNone)
+
+ let context =
+ contextIn overloadsSolution "Overloads.Counter.Bump" (pickedIn overloadsPath lastOverload)
+
+ Assert.StartsWith("member _.Bump(step: byte)", context.Snippet.TrimStart(), StringComparison.Ordinal)
+ Assert.Equal(4, Seq.length context.SnippetLocations)
+
+ []
+ []
+ []
+ []
+ let ``a mention goes to the file it was picked in when two files declare its name on one line`` (filePath: string, line: int) =
+ let twin = "namespace Twins\n\ntype Twin() =\n member _.Value = 1\n"
+ let solution = solutionOf [ "C:\\first.fs", twin; "C:\\second.fs", twin ]
+
+ let declarations =
+ declarationsPickedIn solution "Twins.Twin.Value" (pickedIn filePath line)
+
+ Assert.Equal([| filePath |], declarations |> Array.map fileOf)
+
+ []
+ []
+ []
+ []
+ []
+ []
+ let ``a mention goes to the project it was picked in when a file is linked into two``
+ (project: string, line: int, expectedProject: string, expectedLine: int)
+ =
+ let linked =
+ "C:\\shared.fs", "module M\n#if A\nlet shared = 1\n#else\nlet shared = \"B\"\n#endif\n"
+
+ let solution =
+ solutionOfProjects
+ [
+ "C:\\a.fsproj", [| "--define:A" |], [ linked ]
+ "C:\\b.fsproj", [||], [ linked ]
+ ]
+
+ let picked =
+ ValueSome
+ {
+ File = fst linked
+ ProjectName = project
+ Line = line
+ }
+
+ let struct (item, document) =
+ declarationsPickedIn solution "M.shared" picked |> Array.head
+
+ Assert.Equal(expectedProject, document.Project.Name)
+ Assert.Equal(expectedLine, item.Range.StartLine)
+
+ []
+ let ``a mention picked in a signature file goes to the implementation`` () =
+ let solution =
+ solutionOf
+ [
+ "C:\\lib.fsi", "module Lib\n\nval twice: int -> int\n"
+ "C:\\lib.fs", "module Lib\n\n\nlet twice x = x * 2\n"
+ ]
+
+ let declarations =
+ declarationsPickedIn solution "Lib.twice" (pickedIn "C:\\lib.fsi" 3)
+
+ Assert.Equal([| "C:\\lib.fs" |], declarations |> Array.map fileOf)
+
+ []
+ []
+ []
+ let ``a header over several lines keeps its declaration whole`` (name: string, firstLine: string, lastLine: string) =
+ let snippet = (contextIn multilineSolution name ValueNone).Snippet.Trim()
+
+ Assert.StartsWith(firstLine, snippet, StringComparison.Ordinal)
+ Assert.EndsWith(lastLine, snippet, StringComparison.Ordinal)
+
+ []
+ []
+ []
+ let ``the caret in a body under a header over several lines selects its declaration`` (caretLine: int, around: string, apart: string) =
+ let focused = documentAt multilinePath multilineSolution
+
+ let hits =
+ hitsIn [ focused.Id ] (caretOn multilinePath caretLine) multilineSolution ""
+
+ let focusOf name =
+ hits
+ |> Array.pick (fun (struct (item, _, focus)) ->
+ if CopilotSymbolMapping.fullyQualifiedName item = name then
+ Some focus
+ else
+ None)
+
+ Assert.Equal(DocumentFocus.Selected, focusOf around)
+ Assert.Equal(DocumentFocus.Focused, focusOf apart)
+
+ []
+ let ``a batch of texts answers like the same texts one by one`` () =
+ 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(namesIn Seq.empty ValueNone solution "alpha", batched[0])
+ Assert.Equal(namesIn Seq.empty ValueNone solution "beta", batched[1])
+
+ []
+ let ``a type context carries the whole declaration and its doc comment`` () =
+ let context = contextIn solution "Widgets.Counter" ValueNone
+
+ 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 = contextIn solution "Widgets.Counter.Bump" ValueNone
+
+ Assert.Contains("value <- value + 1", context.Snippet)
+ Assert.DoesNotContain("type Counter", context.Snippet)
+
+ []
+ let ``a one-line declaration keeps its doc comment`` () =
+ let context = contextIn solution "Widgets.twice" ValueNone
+
+ 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, (contextIn solution name ValueNone).SymbolType)
+
+ []
+ let ``a context points back at the source it was taken from`` () =
+ let context = contextIn solution "Widgets.Counter" ValueNone
+ 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 = namesIn Seq.empty ValueNone 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 dottedSolution fullyQualifiedName ValueNone).Snippet.Trim())
+
+ 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
+
+ /// Offset 10 starts "let a = 1"; a range over all six lines of the file means "focused, no line known".
+ []
+ []
+ []
+ []
+ let ``the caret or selection maps onto a focused line range``
+ (caretOffset: int)
+ (startLine: int)
+ (endLine: int)
+ (expectedFirst: int)
+ (expectedLast: int)
+ =
+ Assert.Equal(focusOn focusFilePath expectedFirst expectedLast, documentFocus caretOffset startLine endLine)
+
+ []
+ let ``no selection at all is focused with no line known`` () =
+ let document = DocumentContext(focusContent, FilePath = focusFilePath)
+
+ Assert.Equal(focusOn focusFilePath 0 0, CopilotSymbolMapping.editorFocusOf document)
+
+ []
+ let ``a document with no file path is not focused`` () =
+ Assert.True((CopilotSymbolMapping.editorFocusOf (DocumentContext(focusContent))).IsNone)
diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
index a62b25af1cf..87f071ddbfd 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
+++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
@@ -33,6 +33,7 @@
+