From c284123b0c81b7b8c726dbf8b0657d6642575836 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 31 Aug 2026 23:03:16 +0200 Subject: [PATCH 1/5] Offer F# declarations to the Copilot chat "#" mention picker Copilot's built-in symbol provider reads symbols off the Roslyn compilation, which F# projects do not have, so F# declarations never appeared in the picker shown for "#". Proffer a brokered service from FSharp.Editor implementing Copilot's context-provider and mention-queryable contracts. Declarations come from the NavigateTo parse-tree cache, so the picker answers without waiting for a project check; that cache moves into a shared FSharpNavigableItemsCache used by both features. A picked mention resolves by fully qualified name against the current solution, so it survives a file moving, and carries the whole declaration - doc comment included - as its snippet. FSharpPackage now registers the provider moniker with Copilot after package load. The override is no longer DEBUG-only, so it calls its base implementation, which registers the editor factories. Co-Authored-By: Claude Fable 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + eng/Packages.props | 3 + .../src/FSharp.Editor/Common/Constants.fs | 5 + .../Copilot/CopilotContextProvider.fs | 327 ++++++++++++++++++ .../Copilot/CopilotSymbolMapping.fs | 62 ++++ .../Copilot/CopilotSymbolSnippets.fs | 40 +++ .../src/FSharp.Editor/FSharp.Editor.fsproj | 4 + .../LanguageService/LanguageService.fs | 40 ++- .../Navigation/NavigateToSearchService.fs | 72 ++-- .../CopilotContextProviderTests.fs | 110 ++++++ .../FSharp.Editor.Tests.fsproj | 1 + 11 files changed, 633 insertions(+), 32 deletions(-) create mode 100644 vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs create mode 100644 vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs create mode 100644 vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ef64e1a75c4..371eb96461b 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. ### 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..c11ac6f22ad --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -0,0 +1,327 @@ +// 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) = + seq { + for project in solution.Projects do + if project.Language = FSharpConstants.FSharpLanguageName then + yield! project.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 hits = ResizeArray() + + for document in fsharpDocuments solution do + ct.ThrowIfCancellationRequested() + let! items = cache.GetNavigableItems document + + for item in items do + match tryMatch item with + | ValueSome patternMatch -> hits.Add(struct (patternMatch.Kind, item, document)) + | ValueNone -> () + + return + hits + |> 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 hits = ResizeArray() + + for document in fsharpDocuments solution do + ct.ThrowIfCancellationRequested() + let! items = cache.GetNavigableItems document + + for item in items do + if String.Equals(CopilotSymbolMapping.fullyQualifiedName item, fullyQualifiedName, StringComparison.Ordinal) then + hits.Add(struct (item, document)) + + 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 firstLine, lastLine = CopilotSymbolSnippets.definitionLines 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.tryHead declarations with + | None -> return ValueNone + | Some(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) = + + 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 queryMentions (query: CopilotMentionQuery) = + cancellableTask { + match workspace, searchTextOf query 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) = + 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> = + queryMentions 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.tryHead declarations with + | None -> return false + | Some(struct (item, document)) -> + let! sourceText = document.GetTextAsync ct + + match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) with + | ValueNone -> return false + | ValueSome span -> + do! ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync ct + + let navigation = + solution.Workspace.Services.GetService() + + return navigation.TryNavigateToSpan(solution.Workspace, document.Id, span, ct) + } + |> CancellableTask.start cancellationToken + + // Copilot's own picker providers answer through the batch interface, one result collection per query. + interface ICopilotMentionBatchQueryable with + member _.QueryMentionBatchAsync(queries, cancellationToken) : Task>> = + cancellableTask { + let results = ResizeArray queries.Count + + for query in queries do + let! mentions = queryMentions query + results.Add mentions + + return results :> 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..b155ccdc1d8 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.Imaging + +open FSharp.Compiler.EditorServices + +/// Translates F# navigable items into the shapes the Copilot chat "#" mention picker understands. +module internal CopilotSymbolMapping = + + /// 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 = + let mutable moniker = CopilotImageMoniker() + moniker.Guid <- KnownImageIds.ImageCatalogGuid + moniker.Id <- imageId kind + moniker + + /// 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}" diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs new file mode 100644 index 00000000000..a3c2b4b58e1 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Compiler.EditorServices + +/// Widens the identifier range of a navigable item to the declaration a reader would recognise. +module internal CopilotSymbolSnippets = + + /// 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 (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 + + 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..995bb2d56c1 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -6,6 +6,7 @@ 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 @@ -13,13 +14,16 @@ 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 @@ -408,8 +412,42 @@ type internal FSharpPackage() as this = |> CancellableTask.startAsTask cancellationToken) ) + override this.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = + base.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks) + + afterPackageLoadedTasks.AddTask( + false, + fun _ cancellationToken -> + task { + 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) + | _ -> () + } + :> 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..b3273e36a19 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -19,8 +19,10 @@ 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) = @@ -33,7 +35,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 +43,45 @@ 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 } + 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 +150,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/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs new file mode 100644 index 00000000000..cd20c8e2d4d --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -0,0 +1,110 @@ +// 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}" +""" + + 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 ``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 ``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 + + Assert.Equal("C:\\test.fs", 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 @@ + From 308c05e6fff3bcb150349a4c9a4d13f1dc1b1bcf Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 1 Sep 2026 00:41:18 +0200 Subject: [PATCH 2/5] Parallelize Copilot symbol lookup and cut allocations on the hot cache path Sequential per-document scanning made "search" and "declarationsOf" as slow as the slowest single file; run them across documents concurrently instead, throttled the same way FindReferencesAsync throttles its per-document typechecks, so a solution-wide scan does not launch a parse per document all at once. FSharpNavigableItemsCache's version-stamp entries move to struct tuples and its null workspace check to a match, matching this repo's allocation and null-narrowing conventions on a path every keystroke in the mention picker hits. CopilotSymbolMapping collapses its wrapping module into a single qualified top-level module declaration. Co-Authored-By: Claude Fable 5 --- .../Copilot/CopilotContextProvider.fs | 79 +++++++++----- .../Copilot/CopilotSymbolMapping.fs | 103 +++++++++--------- .../Copilot/CopilotSymbolSnippets.fs | 64 ++++++----- .../LanguageService/LanguageService.fs | 14 +-- .../Navigation/NavigateToSearchService.fs | 51 ++++----- 5 files changed, 157 insertions(+), 154 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index c11ac6f22ad..f63398e8c69 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -35,11 +35,9 @@ module internal CopilotSymbolQuery = let private UserOpName = "CopilotSymbolContext" let private fsharpDocuments (solution: Solution) = - seq { - for project in solution.Projects do - if project.Language = FSharpConstants.FSharpLanguageName then - yield! project.Documents - } + solution.Projects + |> Seq.where (fun project -> project.Language = FSharpConstants.FSharpLanguageName) + |> Seq.collect _.Documents let describe (item: NavigableItem) (document: Document) = let container = @@ -57,19 +55,28 @@ module internal CopilotSymbolQuery = cancellableTask { let! ct = CancellableTask.getCancellationToken () let tryMatch = cache.CreateMatcherFor searchText - let hits = ResizeArray() - for document in fsharpDocuments solution do - ct.ThrowIfCancellationRequested() - let! items = cache.GetNavigableItems document + 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))) + } - for item in items do - match tryMatch item with - | ValueSome patternMatch -> hits.Add(struct (patternMatch.Kind, item, document)) - | 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) 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) @@ -83,15 +90,29 @@ module internal CopilotSymbolQuery = let declarationsOf (cache: FSharpNavigableItemsCache) (solution: Solution) (fullyQualifiedName: string) = cancellableTask { let! ct = CancellableTask.getCancellationToken () - let hits = ResizeArray() - for document in fsharpDocuments solution do - ct.ThrowIfCancellationRequested() - let! items = cache.GetNavigableItems document + let matchesIn (document: Document) = + cancellableTask { + ct.ThrowIfCancellationRequested() + let! items = cache.GetNavigableItems document + + return + items + |> Seq.chooseV (fun item -> + if + String.Equals(CopilotSymbolMapping.fullyQualifiedName item, fullyQualifiedName, StringComparison.Ordinal) + then + ValueSome struct (item, document) + else + ValueNone) + } - for item in items do - if String.Equals(CopilotSymbolMapping.fullyQualifiedName item, fullyQualifiedName, StringComparison.Ordinal) then - hits.Add(struct (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) + |> CancellableTask.map (Seq.collect id) let implementations = hits @@ -117,7 +138,7 @@ module internal CopilotSymbolQuery = Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree - let firstLine, lastLine = CopilotSymbolSnippets.definitionLines scopes item + let struct (firstLine, lastLine) = CopilotSymbolSnippets.definitionLines scopes item let firstLine = max 1 firstLine let lastLine = min sourceText.Lines.Count lastLine @@ -132,9 +153,9 @@ module internal CopilotSymbolQuery = cancellableTask { let! declarations = declarationsOf cache solution fullyQualifiedName - match Array.tryHead declarations with - | None -> return ValueNone - | Some(struct (first, _)) -> + match Array.tryHeadV declarations with + | ValueNone -> return ValueNone + | ValueSome(struct (first, _)) -> let snippets = ResizeArray() let locations = ResizeArray() @@ -163,7 +184,7 @@ module internal CopilotSymbolQuery = Audience = (ServiceAudience.PublicSdk ||| ServiceAudience.Local))>] type internal FSharpCopilotContextProvider [] - (cache: FSharpNavigableItemsCache, [] workspace: VisualStudioWorkspace) = + (cache: FSharpNavigableItemsCache, [] workspace: VisualStudioWorkspace | null) = static let moniker = ServiceMoniker(FSharpConstants.copilotSymbolProviderName, Version CopilotDescriptors.CurrentContextProviderVersion) @@ -235,7 +256,7 @@ type internal FSharpCopilotContextProvider :> IReadOnlyCollection } - let fullyQualifiedNameOf (inputs: IReadOnlyDictionary) = + let fullyQualifiedNameOf (inputs: IReadOnlyDictionary | null) = match inputs with | null -> ValueNone | inputs -> @@ -295,9 +316,9 @@ type internal FSharpCopilotContextProvider let solution = workspace.CurrentSolution let! declarations = CopilotSymbolQuery.declarationsOf cache solution fullyQualifiedName - match Array.tryHead declarations with - | None -> return false - | Some(struct (item, document)) -> + match Array.tryHeadV declarations with + | ValueNone -> return false + | ValueSome(struct (item, document)) -> let! sourceText = document.GetTextAsync ct match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) with diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index b155ccdc1d8..a23d7aab14d 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -1,62 +1,57 @@ // Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. -namespace Microsoft.VisualStudio.FSharp.Editor +/// Translates F# navigable items into the shapes the Copilot chat "#" mention picker understands. +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolMapping open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.Imaging open FSharp.Compiler.EditorServices -/// Translates F# navigable items into the shapes the Copilot chat "#" mention picker understands. -module internal CopilotSymbolMapping = - - /// 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 = - let mutable moniker = CopilotImageMoniker() - moniker.Guid <- KnownImageIds.ImageCatalogGuid - moniker.Id <- imageId kind - moniker - - /// 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}" +/// 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}" diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index a3c2b4b58e1..b2bb73958df 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -1,40 +1,38 @@ // Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. -namespace Microsoft.VisualStudio.FSharp.Editor +/// Widens the identifier range of a navigable item to the declaration a reader would recognise. +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolSnippets open FSharp.Compiler.EditorServices -/// Widens the identifier range of a navigable item to the declaration a reader would recognise. -module internal CopilotSymbolSnippets = - - /// 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 (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 = +/// 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 (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 scope -> scope.Range.StartLine, scope.Range.EndLine - | ValueNone -> declarationLine, item.Range.EndLine + | 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 - firstLine, min lastLine (firstLine + MaxSnippetLines - 1) + struct (firstLine, min lastLine (firstLine + MaxSnippetLines - 1)) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 995bb2d56c1..6a20a250533 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -10,10 +10,9 @@ 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 @@ -25,12 +24,13 @@ 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 diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index b3273e36a19..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 @@ -26,14 +27,16 @@ 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()) member _.GetNavigableItems(document: Document) = cancellableTask { @@ -41,11 +44,11 @@ type internal FSharpNavigableItemsCache 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 (FSharpNavigableItemsCache)) let items = NavigateTo.GetNavigableItems parseResults.ParseTree - cache[document.Id] <- currentVersion, items + cache[document.Id] <- struct (currentVersion, items) return items } @@ -162,7 +165,7 @@ type internal FSharpNavigateToSearchService [] (itemsCache let! items = getNavigableItems document let processed = - [| + seq { for item in items do let contains = kinds.Contains(navigateToItemKindToRoslynKind item.Kind) let patternMatch = tryMatch item @@ -192,9 +195,9 @@ type internal FSharpNavigateToSearchService [] (itemsCache ) ) | _ -> () - |] + } - return processed + return processed |> Seq.toImmutableArray } interface IFSharpNavigateToSearchService with @@ -204,31 +207,17 @@ type internal FSharpNavigateToSearchService [] (itemsCache 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 From 28f13e714c7f426824ac24a762afd89cc3b2572a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 1 Sep 2026 00:54:58 +0200 Subject: [PATCH 3/5] Link the Copilot mention picker release note to its PR Co-Authored-By: Claude Fable 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 371eb96461b..fc6887e7301 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,7 +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. +* 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 From 9d36d113807e09099e36bd4f8181a44bb7d36f7e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 19:24:32 +0200 Subject: [PATCH 4/5] Harden Copilot provider registration and widen one-line snippets Package load runs its tasks back to back on a single loop, so an exception from the Copilot registration task escaped into F# package load. A Copilot contract version the installed build does not serve would have taken the whole package down; catch and log instead, leaving cancellation alone. A doc comment is only reported as an outlining scope once it spans several lines, so a one-line "///" in front of a declaration was invisible to the scope search and dropped from the snippet. Walk back over the preceding "///" lines directly. Batch mention queries scanned the solution once per query, serially. Distinct search texts now scan concurrently and repeated ones share a single scan. The snippet-location test asserted a hardcoded "C:\test.fs" rather than asking the solution where its document lives. Co-Authored-By: Claude Fable 5.1 --- .../Copilot/CopilotContextProvider.fs | 22 ++++---- .../Copilot/CopilotSymbolSnippets.fs | 17 +++++- .../LanguageService/LanguageService.fs | 53 ++++++++++--------- .../CopilotContextProviderTests.fs | 14 ++++- 4 files changed, 70 insertions(+), 36 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index f63398e8c69..71b6457d83e 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -138,7 +138,9 @@ module internal CopilotSymbolQuery = Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree - let struct (firstLine, lastLine) = CopilotSymbolSnippets.definitionLines scopes item + + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.definitionLines sourceLines scopes item let firstLine = max 1 firstLine let lastLine = min sourceText.Lines.Count lastLine @@ -243,9 +245,9 @@ type internal FSharpCopilotContextProvider | text -> ValueSome text | _ -> ValueNone - let queryMentions (query: CopilotMentionQuery) = + let mentionsFor (searchText: string voption) = cancellableTask { - match workspace, searchTextOf query with + match workspace, searchText with | null, _ | _, ValueNone -> return noMentions | workspace, ValueSome searchText -> @@ -304,7 +306,7 @@ type internal FSharpCopilotContextProvider interface ICopilotMentionQueryable with member _.QueryMentionAsync(query, cancellationToken) : Task> = - queryMentions query |> CancellableTask.start cancellationToken + mentionsFor (searchTextOf query) |> CancellableTask.start cancellationToken member _.NavigateToMentionableAsync(mention, cancellationToken) : Task = match workspace, fullyQualifiedNameOf mention.Inputs with @@ -334,15 +336,15 @@ type internal FSharpCopilotContextProvider |> 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 results = ResizeArray queries.Count - - for query in queries do - let! mentions = queryMentions query - results.Add mentions + 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 results :> IReadOnlyList> + return searchTexts |> Array.map (fun text -> byText[text]) :> IReadOnlyList> } |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index b2bb73958df..59c98c3fd21 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -3,6 +3,8 @@ /// 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. @@ -10,7 +12,7 @@ open FSharp.Compiler.EditorServices let MaxSnippetLines = 200 /// Inclusive, 1-based line bounds of the declaration `item` names, including its doc comment. -let definitionLines (scopes: Structure.ScopeRange seq) (item: NavigableItem) = +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 @@ -35,4 +37,17 @@ let definitionLines (scopes: Structure.ScopeRange seq) (item: NavigableItem) = | 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].TrimStart().StartsWith("///", 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/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 6a20a250533..68bb0b038e7 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -419,30 +419,35 @@ type internal FSharpPackage() as this = false, fun _ cancellationToken -> task { - 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) - | _ -> () + 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 ) diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index cd20c8e2d4d..f7fc6966b52 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -36,6 +36,9 @@ 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 @@ -92,6 +95,14 @@ let describeShape shape = 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) + [] [] [] @@ -105,6 +116,7 @@ let describeShape shape = 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("C:\\test.fs", location.FilePath) + Assert.Equal(document.FilePath, location.FilePath) Assert.Equal(context.Snippet.Length, location.Span.Length) From 579180c69abddf446452419badb3c3e355b07e19 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 19:37:43 +0200 Subject: [PATCH 5/5] Match declaration names over spans instead of building them Resolving a picked mention walks every declaration in every document of the solution, and asked each one for its dotted path as a fresh string purely to compare it. Compare against the container and name in place instead, so the scan allocates nothing per declaration. The doc-comment probe trimmed each candidate line into a new string for the same reason. Co-Authored-By: Claude Fable 5.1 --- .../Copilot/CopilotContextProvider.fs | 4 +--- .../Copilot/CopilotSymbolMapping.fs | 17 +++++++++++++++++ .../Copilot/CopilotSymbolSnippets.fs | 2 +- .../CopilotContextProviderTests.fs | 19 +++++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 71b6457d83e..7a5da42a1a2 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -99,9 +99,7 @@ module internal CopilotSymbolQuery = return items |> Seq.chooseV (fun item -> - if - String.Equals(CopilotSymbolMapping.fullyQualifiedName item, fullyQualifiedName, StringComparison.Ordinal) - then + if CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName item then ValueSome struct (item, document) else ValueNone) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index a23d7aab14d..21e296c0476 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -3,6 +3,8 @@ /// 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 @@ -55,3 +57,18 @@ 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 index 59c98c3fd21..b31d9c192bc 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -40,7 +40,7 @@ let definitionLines (sourceLines: string array) (scopes: Structure.ScopeRange se // 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].TrimStart().StartsWith("///", StringComparison.Ordinal) + sourceLines[line - 1].AsSpan().TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) let rec docCommentStart line = if line > 1 && isDocComment (line - 1) then diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index f7fc6966b52..c7ad810ab4a 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -70,6 +70,25 @@ let twice x = x * 2 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"