From bddd3b191d21f433e9db516ed6e4801cc3e5003f Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 31 Aug 2026 23:03:16 +0200 Subject: [PATCH 01/29] 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 639843a3a0d..b12c2f456a4 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 53e1883a74c2aa0b324cf2f95c1c3dbb1c4679ee Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 1 Sep 2026 00:41:18 +0200 Subject: [PATCH 02/29] 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 0f7f66e827cf0087f4296f194999af8b56e60390 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 1 Sep 2026 00:54:58 +0200 Subject: [PATCH 03/29] 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 b12c2f456a4..5ac8e6aa5e4 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 fdfbd1d3b922d15687be47470dc37c4b857c4d54 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 19:24:32 +0200 Subject: [PATCH 04/29] 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 5eea689650a6cb47f6534e84b0a2a1aace16e36d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 19:37:43 +0200 Subject: [PATCH 05/29] 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" From 116a5e4ff168ac10a725dfcd8b0d1710d46ec38c Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:01:34 +0200 Subject: [PATCH 06/29] Slice source text instead of copying it line by line for outlining Every consumer of Structure.getOutliningRanges built its sourceLines array by calling ToString() per line, allocating a fresh string for the entire file on every outlining pass - once per keystroke for the editor's block structure, and once per resolved Copilot mention for the snippet extent. getOutliningRanges now takes ReadOnlyMemory[] and slices the already-materialized source text once (SourceText.GetLinesAsMemory()) instead. ReadOnlySpanCharExtensions in illib mirrors the existing Ordinal string helpers so span call sites read the same way string call sites do. A local recursive function closing over a ReadOnlySpan-typed sibling cannot be compiled - the CLR disallows instantiating FSharpFunc, _> as a closure field (FS0412) - so commentTypeOf moves to module scope, next to the CommentType it classifies. StructureTests.fs slices its own lines the same way at the call site, and FSharp.Compiler.Service.Tests needs a direct System.Memory PackageReference: FSharp.Compiler.Service's own reference to it is only transitive through the net472 ProjectReference's SetTargetFramework override, mirroring the FSharp.Core pin already in this project for the same reason. Co-Authored-By: Claude Fable 5.1 --- .../.FSharp.Compiler.Service/11.0.100.md | 1 + src/Compiler/Service/ServiceStructure.fs | 61 ++++++++++--------- src/Compiler/Service/ServiceStructure.fsi | 3 +- src/Compiler/Utilities/illib.fs | 49 +++++++++++++++ src/Compiler/Utilities/illib.fsi | 42 +++++++++++++ ...iler.Service.SurfaceArea.netstandard20.bsl | 2 +- .../FSharp.Compiler.Service.Tests.fsproj | 6 ++ .../StructureTests.fs | 3 +- .../src/FSharp.Editor/Common/Extensions.fs | 8 +++ .../Copilot/CopilotContextProvider.fs | 3 +- .../Copilot/CopilotSymbolSnippets.fs | 4 +- .../Structure/BlockStructureService.fs | 2 +- 12 files changed, 148 insertions(+), 36 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index 79543c63afb..1cc3b1bc0af 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -228,3 +228,4 @@ * `FSharp.Compiler.Syntax.SynComponentInfo` now holds the type name as `synType: SynType option` instead of the previous `longId: LongIdent` field, so tuple-type extensions such as `type ('T1 * 'T2) with` can be represented. A `member LongIdent` compatibility property returns the long identifier for named types and an empty list for tuple or erroneous type names. AST consumers that pattern-matched on the `longId` field must switch to the `synType` field or the `LongIdent` member. ([PR #19602](https://github.com/dotnet/fsharp/pull/19602)) * Optimizer: don't inline named functions in debug builds ([PR #19548](https://github.com/dotnet/fsharp/pull/19548) * LexFilter: drop non-strict mode ([PR #20106](https://github.com/dotnet/fsharp/pull/20106)) +* `FSharp.Compiler.EditorServices.Structure.getOutliningRanges` now takes the source lines as `ReadOnlyMemory[]` instead of `string[]`, so a caller that already holds the whole text can slice it instead of building a string per line. Callers passing a `string[]` can migrate with `Array.map (fun line -> line.AsMemory())`. diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index 99dd528eeb0..f686d9ec452 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -2,6 +2,7 @@ namespace FSharp.Compiler.EditorServices +open System open Internal.Utilities.Library open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTreeOps @@ -186,12 +187,21 @@ module Structure = } type LineNumber = int - type LineStr = string + type LineStr = ReadOnlyMemory type CommentType = | SingleLine | XmlDoc + /// Determine if a line is a single line or xml documentation comment. + /// Kept at module scope: a local recursive function capturing a `ReadOnlySpan`-typed + /// helper as a closure field would need to instantiate `FSharpFunc, _>`, + /// which the CLR disallows for byref-like type arguments (FS0412). + let commentTypeOf (line: ReadOnlySpan) = + if line.StartsWithOrdinal("///") then ValueSome XmlDoc + elif line.StartsWithOrdinal("//") then ValueSome SingleLine + else ValueNone + [] type CommentList = { @@ -206,7 +216,7 @@ module Structure = } /// Returns outlining ranges for given parsed input. - let getOutliningRanges (sourceLines: string[]) (parsedInput: ParsedInput) = + let getOutliningRanges (sourceLines: ReadOnlyMemory[]) (parsedInput: ParsedInput) = let acc = ResizeArray() /// Validation function to ensure that ranges yielded for outlining span 2 or more lines @@ -661,7 +671,7 @@ module Structure = | r :: rest, last :: _ when r.StartLine = last.EndLine + 1 || sourceLines[last.EndLine .. r.StartLine - 2] - |> Array.forall System.String.IsNullOrWhiteSpace + |> Array.forall (fun line -> line.Span.IsWhiteSpace()) -> loop rest res (r :: currentBulk) | r :: rest, _ -> loop rest (currentBulk :: res) [ r ] @@ -719,7 +729,7 @@ module Structure = let collectConditionalDirectives directives sourceLines = // Adds a fold region from prevRange.Start to the line above nextLine - let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: string array) = + let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: ReadOnlyMemory[]) = let startLineIndex = nextLine - 2 if startLineIndex >= 0 then @@ -753,7 +763,7 @@ module Structure = | ConditionalDirectiveTrivia.Else r -> ValueSome r | _ -> ValueNone - let rec group directives stack (sourceLines: string array) = + let rec group directives stack (sourceLines: ReadOnlyMemory[]) = match directives with | [] -> () | ConditionalDirectiveTrivia.If _ as ifDirective :: directives -> group directives (ifDirective :: stack) sourceLines @@ -822,19 +832,15 @@ module Structure = collectOpens decls List.iter parseDeclaration decls - /// Determine if a line is a single line or xml documentation comment - let (|Comment|_|) (line: string) = - if line.StartsWithOrdinal("///") then Some XmlDoc - elif line.StartsWithOrdinal("//") then Some SingleLine - else None - - let getCommentRanges trivia (lines: string[]) = - let rec loop (lastLineNum, currentComment, result as state) (lines: string list) lineNum = - match lines with - | [] -> state - | lineStr :: rest -> - match lineStr.TrimStart(), currentComment with - | Comment commentType, Some comment -> + let getCommentRanges trivia (lines: ReadOnlyMemory[]) = + let rec loop (lastLineNum, currentComment, result as state) lineNum = + if lineNum = lines.Length then + state + else + let lineStr = lines[lineNum] + + match commentTypeOf (lineStr.Span.TrimStart()), currentComment with + | ValueSome commentType, Some comment -> loop (if comment.Type = commentType && lineNum = lastLineNum + 1 then comment.Lines.Add(lineNum, lineStr) @@ -842,16 +848,15 @@ module Structure = else let comments = CommentList.New commentType (lineNum, lineStr) lineNum, Some comments, comment :: result) - rest (lineNum + 1) - | Comment commentType, None -> + | ValueSome commentType, None -> let comments = CommentList.New commentType (lineNum, lineStr) - loop (lineNum, Some comments, result) rest (lineNum + 1) - | _, Some comment -> loop (lineNum, None, comment :: result) rest (lineNum + 1) - | _ -> loop (lineNum, None, result) rest (lineNum + 1) + loop (lineNum, Some comments, result) (lineNum + 1) + | ValueNone, Some comment -> loop (lineNum, None, comment :: result) (lineNum + 1) + | ValueNone, None -> loop (lineNum, None, result) (lineNum + 1) let comments = - let _, lastComment, comments = loop (-1, None, []) (List.ofArray lines) 0 + let _, lastComment, comments = loop (-1, None, []) 0 match lastComment with | Some comment -> comment :: comments @@ -859,13 +864,13 @@ module Structure = |> List.rev comments - |> List.filter (fun comment -> comment.Lines.Count > 1) - |> List.map (fun comment -> + |> Seq.filter (fun comment -> comment.Lines.Count > 1) + |> Seq.map (fun comment -> let lines = comment.Lines let startLine, startStr = lines[0] let endLine, endStr = lines[lines.Count - 1] - let startCol = startStr.IndexOf '/' - let endCol = endStr.TrimEnd().Length + let startCol = startStr.Span.IndexOf '/' + let endCol = endStr.Span.TrimEnd().Length let scopeType = match comment.Type with diff --git a/src/Compiler/Service/ServiceStructure.fsi b/src/Compiler/Service/ServiceStructure.fsi index 87711629676..3695e7148ac 100644 --- a/src/Compiler/Service/ServiceStructure.fsi +++ b/src/Compiler/Service/ServiceStructure.fsi @@ -2,6 +2,7 @@ namespace FSharp.Compiler.EditorServices +open System open FSharp.Compiler.Syntax open FSharp.Compiler.Text @@ -79,4 +80,4 @@ module public Structure = } /// Returns outlining ranges for given parsed input. - val getOutliningRanges: sourceLines: string[] -> parsedInput: ParsedInput -> seq + val getOutliningRanges: sourceLines: ReadOnlyMemory[] -> parsedInput: ParsedInput -> seq diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index d6302776cb7..9aefd2f787d 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -7,6 +7,7 @@ open System.Collections.Generic open System.Collections.Concurrent open System.Diagnostics open System.IO +open System.Linq open System.Threading open System.Threading.Tasks open System.Runtime.CompilerServices @@ -112,6 +113,54 @@ module internal PervasiveAutoOpens = member inline x.IndexOfOrdinal(value: string, startIndex, count) = x.IndexOf(value, startIndex, count, StringComparison.Ordinal) + [] + type ReadOnlySpanCharExtensions = + + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.StartsWith(value, StringComparison.Ordinal) + + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = + str.StartsWith(value.AsSpan(), StringComparison.Ordinal) + + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.EndsWith(value, StringComparison.Ordinal) + + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.Ordinal) + + static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: ReadOnlySpan) = + str.EndsWith(value, StringComparison.OrdinalIgnoreCase) + + static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase) + + static member IndexOf(str: ReadOnlySpan, value: char) = + let mutable index = -1 + let mutable i = 0 + + while i < str.Length && index = -1 do + if str[i] = value then index <- i else i <- i + 1 + + index + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string) = + str.IndexOf(value.AsSpan(), StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex) = + str.Slice(startIndex).IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex) = + str.Slice(startIndex).IndexOf(value.AsSpan(), StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex, count) = + str.Slice(startIndex, count).IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex, count) = + str.Slice(startIndex, count).IndexOf(value.AsSpan(), StringComparison.Ordinal) + /// Get an initialization hole let getHole (r: _ ref) = match r.Value with diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index bc04c2ca1ac..9c1d7474ef9 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -68,6 +68,48 @@ module internal PervasiveAutoOpens = member inline IndexOfOrdinal: value: string * startIndex: int * count: int -> int + [] + type ReadOnlySpanCharExtensions = + + [] + static member inline StartsWithOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline StartsWithOrdinal: str : ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EndsWithOrdinal: str : ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinalIgnoreCase: str : ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EndsWithOrdinalIgnoreCase: str : ReadOnlySpan * value: string -> bool + + [] + static member IndexOf: str : ReadOnlySpan * value: char -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int * count: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int * count: int -> int + type Async with /// Runs the computation synchronously, always starting on the current thread. diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index 5c9c346b613..7f4e7d14ec4 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl @@ -4744,7 +4744,7 @@ FSharp.Compiler.EditorServices.Structure+ScopeRange: Void .ctor(Scope, Collapse, FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Collapse FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Scope FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+ScopeRange -FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.String[], FSharp.Compiler.Syntax.ParsedInput) +FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.ReadOnlyMemory`1[System.Char][], FSharp.Compiler.Syntax.ParsedInput) FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String errorText FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String get_errorText() FSharp.Compiler.EditorServices.ToolTipElement+Group: Microsoft.FSharp.Collections.FSharpList`1[FSharp.Compiler.EditorServices.ToolTipElementData] elements diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj index e043d8554ad..0183589a540 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj @@ -223,4 +223,10 @@ + + + + + diff --git a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index c0ae0d3fdff..d3bbe73e4b9 100644 --- a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs @@ -1,5 +1,6 @@ module FSharp.Compiler.Service.Tests.StructureTests +open System open System.IO open Xunit open FSharp.Compiler.EditorServices.Structure @@ -35,7 +36,7 @@ let (=>) (source: string) (expectedRanges: (Range * Range) list) = let ast = parseSourceCode(fileName, source) try let actual = - getOutliningRanges lines ast + getOutliningRanges (lines |> Array.map (fun line -> line.AsMemory())) ast |> Seq.filter (fun sr -> sr.Range.StartLine <> sr.Range.EndLine) |> Seq.map (fun sr -> getRange sr.Range, getRange sr.CollapseRange) |> Seq.sort diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index f9695e68ecf..89185ebe556 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -296,6 +296,14 @@ type SourceText with member this.ToFSharpSourceText() = SourceText.weakTable.GetValue(this, Runtime.CompilerServices.ConditionalWeakTable<_, _>.CreateValueCallback(SourceText.create)) + /// The lines of the text, as slices of a single string rather than one string per line. + member this.GetLinesAsMemory() = + let text = this.ToString() + + Array.init this.Lines.Count (fun i -> + let line = this.Lines[i] + text.AsMemory(line.Start, line.End - line.Start)) + type NavigationItem with member x.RoslynGlyph: FSharpRoslynGlyph = diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 7a5da42a1a2..73c1e3b166b 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -132,8 +132,7 @@ module internal CopilotSymbolQuery = let! sourceText = document.GetTextAsync ct let! parseResults = document.GetFSharpParseResultsAsync UserOpName - let sourceLines = - Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) + let sourceLines = sourceText.GetLinesAsMemory() let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index b31d9c192bc..fe53cac4f7d 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -12,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 (sourceLines: string array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = +let definitionLines (sourceLines: ReadOnlyMemory 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 @@ -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].AsSpan().TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) + sourceLines[line - 1].Span.TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) let rec docCommentStart line = if line > 1 && isDocComment (line - 1) then diff --git a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs index d0326d84311..b087cb56782 100644 --- a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs +++ b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs @@ -119,7 +119,7 @@ module internal BlockStructure = let ellipsis = "..." let createBlockSpans isBlockStructureEnabled (sourceText: SourceText) (parsedInput: ParsedInput) = - let linetext = sourceText.Lines |> Seq.map (fun x -> x.ToString()) |> Seq.toArray + let linetext = sourceText.GetLinesAsMemory() Structure.getOutliningRanges linetext parsedInput |> Seq.distinctBy (fun x -> x.Range.StartLine) From 4741185b162e4efd2e4cc397dea2541115de87da Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:36:11 +0200 Subject: [PATCH 07/29] Track comment lines by number instead of storing their text CommentList kept a copy of every comment line next to its line number, but the number alone identifies the line in the source array the function already holds, and only the first and last lines of a group are ever read back to compute the fold's columns. Store the numbers and index the source at the end, so grouping comments allocates no tuple per line. Co-Authored-By: Claude Fable 5.1 --- src/Compiler/Service/ServiceStructure.fs | 26 ++-- .../StructureTests.fs | 116 +++++++++--------- 2 files changed, 69 insertions(+), 73 deletions(-) diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index f686d9ec452..6937981ecec 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -187,7 +187,6 @@ module Structure = } type LineNumber = int - type LineStr = ReadOnlyMemory type CommentType = | SingleLine @@ -205,14 +204,14 @@ module Structure = [] type CommentList = { - Lines: ResizeArray + Lines: ResizeArray Type: CommentType } - static member New ty lineStr = + static member New ty lineNum = { Type = ty - Lines = ResizeArray [ lineStr ] + Lines = ResizeArray [ lineNum ] } /// Returns outlining ranges for given parsed input. @@ -837,20 +836,18 @@ module Structure = if lineNum = lines.Length then state else - let lineStr = lines[lineNum] - - match commentTypeOf (lineStr.Span.TrimStart()), currentComment with + match commentTypeOf (lines[lineNum].Span.TrimStart()), currentComment with | ValueSome commentType, Some comment -> loop (if comment.Type = commentType && lineNum = lastLineNum + 1 then - comment.Lines.Add(lineNum, lineStr) + comment.Lines.Add lineNum lineNum, currentComment, result else - let comments = CommentList.New commentType (lineNum, lineStr) + let comments = CommentList.New commentType lineNum lineNum, Some comments, comment :: result) (lineNum + 1) | ValueSome commentType, None -> - let comments = CommentList.New commentType (lineNum, lineStr) + let comments = CommentList.New commentType lineNum loop (lineNum, Some comments, result) (lineNum + 1) | ValueNone, Some comment -> loop (lineNum, None, comment :: result) (lineNum + 1) | ValueNone, None -> loop (lineNum, None, result) (lineNum + 1) @@ -866,11 +863,10 @@ module Structure = comments |> Seq.filter (fun comment -> comment.Lines.Count > 1) |> Seq.map (fun comment -> - let lines = comment.Lines - let startLine, startStr = lines[0] - let endLine, endStr = lines[lines.Count - 1] - let startCol = startStr.Span.IndexOf '/' - let endCol = endStr.Span.TrimEnd().Length + let startLine = comment.Lines[0] + let endLine = comment.Lines[comment.Lines.Count - 1] + let startCol = lines[startLine].Span.IndexOf '/' + let endCol = lines[endLine].Span.TrimEnd().Length let scopeType = match comment.Type with diff --git a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index d3bbe73e4b9..322a0fa3bda 100644 --- a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs @@ -36,7 +36,7 @@ let (=>) (source: string) (expectedRanges: (Range * Range) list) = let ast = parseSourceCode(fileName, source) try let actual = - getOutliningRanges (lines |> Array.map (fun line -> line.AsMemory())) ast + getOutliningRanges (lines |> Array.map _.AsMemory()) ast |> Seq.filter (fun sr -> sr.Range.StartLine <> sr.Range.EndLine) |> Seq.map (fun sr -> getRange sr.Range, getRange sr.CollapseRange) |> Seq.sort @@ -152,7 +152,7 @@ module MyModule = // 2 type Color = // 7 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 13 @@ -164,7 +164,7 @@ module MyModule = // 2 type RecordColor = // 19 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 25 @@ -190,31 +190,31 @@ module MyModule = // 2 [] let ``open statements``() = """ -open M -open N - -module M = - let x = 1 - - open M - open N - - module M = - open M - - let x = 1 - - module M = - open M - open N - let x = 1 - -open M -open N -open H - -open G -open H +open M +open N + +module M = + let x = 1 + + open M + open N + + module M = + open M + + let x = 1 + + module M = + open M + open N + let x = 1 + +open M +open N +open H + +open G +open H """ => [ (2, 0, 3, 6), (2, 0, 3, 6) (5, 0, 19, 17), (5, 8, 19, 17) @@ -227,28 +227,28 @@ open H [] let ``hash directives``() = """ -#r @"a" -#r "b" - -#r "c" - -#r "d" -#r "e" -let x = 1 - -#r "f" -#r "g" -#load "x" -#r "y" - -#load "a" - "b" - "c" - -#load "a" - "b" - "c" -#r "d" +#r @"a" +#r "b" + +#r "c" + +#r "d" +#r "e" +let x = 1 + +#r "f" +#r "g" +#load "x" +#r "y" + +#load "a" + "b" + "c" + +#load "a" + "b" + "c" +#r "d" """ => [ (2, 3, 8, 6), (2, 3, 8, 6) (11, 3, 23, 6), (11, 3, 23, 6) ] @@ -326,7 +326,7 @@ seq { // 2 [] let ``list``() = """ -let _ = +let _ = [ 1; 2 3 ] """ @@ -383,7 +383,7 @@ finally // 5 let ``if - then - else``() = """ if true then - let f x = + let f x = () () else @@ -449,7 +449,7 @@ for x = 100 downto 10 do [] let ``for each``() = """ -for x in 0 .. 100 -> +for x in 0 .. 100 -> () () """ @@ -468,7 +468,7 @@ let ``tuple``() = [] let ``do!``() = """ -do! +do! printfn "allo" printfn "allo" """ @@ -478,10 +478,10 @@ do! let ``cexpr yield yield!``() = """ cexpr{ - yield! + yield! cexpr{ - yield - + yield + 10 } } @@ -660,7 +660,7 @@ let ``Abstract members`` () = type T() = abstract Foo: int - + [] abstract Foo: int From 5e4d411383a7d3c66885be0f5361f146a5dcc49d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 8 Sep 2026 02:16:14 +0200 Subject: [PATCH 08/29] Take the main thread before registering the Copilot context provider GetProxyAsync is an exported brokered service, so calling it from a background package-load task constructs Copilot's MEF part graph on that thread. Its constructor does a blocking JoinableTask wait for the main thread; meanwhile the Git provider asks for the same proxy from the main thread while building its own services at solution open, and blocks inside MEF's PartLifecycleTracker waiting for the part the background thread owns. Neither side can proceed and Visual Studio hangs permanently. Move the registration out of the background package-load task and into LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync (run after the solution is fully loaded, the way Roslyn's AbstractPackage defers this kind of work), and switch to the main thread before asking for the proxy so the two requesters serialise instead of deadlocking. --- .../LanguageService/LanguageService.fs | 86 +++++++++++-------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 68bb0b038e7..c8faf26083b 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -415,43 +415,6 @@ type internal FSharpPackage() as this = override this.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = base.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks) - afterPackageLoadedTasks.AddTask( - false, - fun _ cancellationToken -> - task { - try - let! container = this.GetServiceAsync(typeof) - - match container with - | :? IBrokeredServiceContainer as container -> - // The Interactions service also serves the registration interface. It is absent when - // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. - let! registration = - container - .GetFullAccessServiceBroker() - .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken) - - use registration = registration - - match registration with - | null -> () - | registration -> - let moniker = - ServiceMoniker( - FSharpConstants.copilotSymbolProviderName, - Version CopilotDescriptors.CurrentContextProviderVersion - ) - - do! registration.RegisterContextProviderAsync(moniker, cancellationToken) - | _ -> () - // Package load runs its tasks back to back on one loop, so a Copilot failure - a contract - // version the installed build does not serve, say - must not take the F# package down with it. - with ex when not (ex :? OperationCanceledException) -> - DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider") - } - :> Task - ) - #if DEBUG afterPackageLoadedTasks.AddTask( false, @@ -464,6 +427,55 @@ type internal FSharpPackage() as this = ) #endif + /// Copilot's registration service is an exported brokered service whose MEF part constructor blocks waiting + /// for the main thread. Asking for the proxy from a background thread therefore deadlocks against anyone + /// asking for it from the main thread - the Git provider does, while creating its services at solution open - + /// so take the main thread dependency deliberately, the way Roslyn does for a proxy that has one. + member private this.RegisterCopilotContextProviderAsync(cancellationToken: CancellationToken) : Task = + task { + try + do! this.JoinableTaskFactory.SwitchToMainThreadAsync(alwaysYield = true, cancellationToken = cancellationToken) + + 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) + | _ -> () + // A Copilot failure - a contract version the installed build does not serve, say - must not take the + // rest of the post-load work down with it. + with ex when not (ex :? OperationCanceledException) -> + DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider") + } + + override this.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) : Task = + // 'base' cannot be captured by the state machine, so start the base work before entering it. + let baseComponents = + base.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) + + task { + do! baseComponents + do! this.RegisterCopilotContextProviderAsync(cancellationToken) + } + override _.RoslynLanguageName = FSharpConstants.FSharpLanguageName (*override this.CreateWorkspace() = this.ComponentModel.GetService() *) override this.CreateLanguageService() = FSharpLanguageService(this) From 8d730118e27d92fe3cb62a7bb6e66593242c9981 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 8 Sep 2026 02:18:45 +0200 Subject: [PATCH 09/29] Trace Copilot context-provider registration through the output pane Diagnostic aid: on a large solution the "#" mention picker stays empty and nothing in the Debug pane says why. Log each step of RegisterCopilotContextProviderAsync so a hang or an early return (no brokered service container, a null proxy) is visible without a debugger attached. --- .../FSharp.Editor/LanguageService/LanguageService.fs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index c8faf26083b..96c4041db06 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -434,14 +434,18 @@ type internal FSharpPackage() as this = member private this.RegisterCopilotContextProviderAsync(cancellationToken: CancellationToken) : Task = task { try + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registering context provider (switching to main thread)…" do! this.JoinableTaskFactory.SwitchToMainThreadAsync(alwaysYield = true, cancellationToken = cancellationToken) + DebugHelpers.FSharpOutputPane.logInfo "Copilot: getting brokered service container…" let! container = this.GetServiceAsync(typeof) match container with | :? IBrokeredServiceContainer as container -> // The Interactions service also serves the registration interface. It is absent when // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. + DebugHelpers.FSharpOutputPane.logInfo "Copilot: getting registration service proxy…" + let! registration = container .GetFullAccessServiceBroker() @@ -450,8 +454,10 @@ type internal FSharpPackage() as this = use registration = registration match registration with - | null -> () + | null -> DebugHelpers.FSharpOutputPane.logInfo "Copilot: service proxy is null (Copilot not installed)" | registration -> + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registering F# context provider…" + let moniker = ServiceMoniker( FSharpConstants.copilotSymbolProviderName, @@ -459,7 +465,8 @@ type internal FSharpPackage() as this = ) do! registration.RegisterContextProviderAsync(moniker, cancellationToken) - | _ -> () + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registration complete" + | _ -> DebugHelpers.FSharpOutputPane.logInfo "Copilot: container is not IBrokeredServiceContainer" // A Copilot failure - a contract version the installed build does not serve, say - must not take the // rest of the post-load work down with it. with ex when not (ex :? OperationCanceledException) -> From d1ed1c4bae356e6dffd481034d87f67471be04cb Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 8 Sep 2026 03:05:49 +0200 Subject: [PATCH 10/29] Answer the Copilot mention picker from what is already parsed The picker showed no F# declarations on large solutions. Every query walked every document of every F# project, parsing the ones nobody had opened, and `whenAllThrottled` queued a task per document on one semaphore, so a thousand documents meant a thousand tasks waiting to run while Copilot cancelled the query and took nothing. A query now visits the documents in three groups, stopping as soon as it holds as many declarations as it reports: the documents the user has open, the ones already in the parse cache, and only then the ones that would have to be parsed, which get a time budget of their own. The new cache lookup reads no text, so a closed document costs nothing. `forEachThrottled` pulls documents through a fixed set of workers instead of starting a task per document, and a batch of search texts visits each document once for all of them. Co-Authored-By: Claude Opus 5 --- .../FSharp.Editor/Common/CancellableTasks.fs | 25 ++ .../Copilot/CopilotContextProvider.fs | 219 +++++++++++++----- .../Navigation/NavigateToSearchService.fs | 7 + .../CopilotContextProviderTests.fs | 119 +++++++++- 4 files changed, 302 insertions(+), 68 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs index 7520395a084..d699bdba0bf 100644 --- a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs +++ b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs @@ -1130,6 +1130,31 @@ module CancellableTasks = return! allTask } + /// Runs the work over the items with at most maxDegreeOfParallelism of them in flight. A worker + /// takes the next item when it frees up, so cancellation cancels the workers rather than a + /// pending task per item. + let forEachThrottled maxDegreeOfParallelism (work: 'T -> CancellableTask) (items: 'T seq) = + cancellableTask { + let! ct = getCancellationToken () + let items = Seq.toArray items + let mutable next = -1 + + let worker () = + backgroundTask { + let mutable index = Interlocked.Increment &next + + while index < items.Length do + ct.ThrowIfCancellationRequested() + do! work items[index] ct + index <- Interlocked.Increment &next + } + + let workers = + Array.init (min (max 1 maxDegreeOfParallelism) items.Length) (fun _ -> worker ()) + + do! (Task.WhenAll workers :> Task) + } + let inline whenAllTasks (tasks: CancellableTask seq) = cancellableTask { let! ct = getCancellationToken () diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 73c1e3b166b..fdddf05793e 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -6,6 +6,8 @@ open System open System.Collections.Generic open System.ComponentModel.Composition open System.IO +open System.Diagnostics +open System.Threading open System.Threading.Tasks open Microsoft.CodeAnalysis @@ -34,11 +36,36 @@ module internal CopilotSymbolQuery = [] let private UserOpName = "CopilotSymbolContext" + /// How long a query keeps parsing files nobody has opened yet. Copilot cancels on its own schedule + /// and takes no partial results, so an answer from what is already parsed beats a complete answer. + [] + let private ColdSearchBudgetMs = 1500 + + let private parallelism = max 1 (Environment.ProcessorCount - 1) + let private fsharpDocuments (solution: Solution) = solution.Projects |> Seq.where (fun project -> project.Language = FSharpConstants.FSharpLanguageName) |> Seq.collect _.Documents + /// The documents in the order a query visits them: the ones the user has open, the ones already + /// parsed into the cache, and the ones that would have to be parsed to answer. + let private tiers (cache: FSharpNavigableItemsCache) (openDocumentIds: DocumentId seq) (solution: Solution) = + let openIds = HashSet openDocumentIds + let opened = ResizeArray() + let cached = ResizeArray() + let cold = ResizeArray() + + for document in fsharpDocuments solution do + if openIds.Contains document.Id then + opened.Add document + else + match cache.TryGetCachedNavigableItems document.Id with + | ValueSome items -> cached.Add(struct (document, items)) + | ValueNone -> cold.Add document + + struct (opened, cached, cold) + let describe (item: NavigableItem) (document: Document) = let container = match item.Container.FullName with @@ -50,67 +77,117 @@ module internal CopilotSymbolQuery = 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) = + /// Declarations whose fully qualified name matches each search text, best match first, one entry + /// per name. Every document is visited once for all of the texts. + let search (cache: FSharpNavigableItemsCache) (openDocumentIds: DocumentId seq) (solution: Solution) (searchTexts: string[]) = cancellableTask { let! ct = CancellableTask.getCancellationToken () - let tryMatch = cache.CreateMatcherFor searchText + let matchers = searchTexts |> Array.map cache.CreateMatcherFor + let hits = Array.init searchTexts.Length (fun _ -> ResizeArray()) + let found = Array.init searchTexts.Length (fun _ -> HashSet StringComparer.Ordinal) + + let collect (document: Document) (items: NavigableItem array) = + lock hits (fun () -> + for index in 0 .. matchers.Length - 1 do + let tryMatch = matchers[index] + + for item in items do + match tryMatch item with + | ValueSome patternMatch -> + hits[index].Add(struct (patternMatch.Kind, item, document)) + + found[index].Add(CopilotSymbolMapping.fullyQualifiedName item) |> ignore + | ValueNone -> ()) - let matchesIn (document: Document) = + let enough () = + lock hits (fun () -> found |> Array.forall (fun names -> names.Count >= MaxMentions)) + + let parseAndCollect (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))) + collect document items } - 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) + let struct (opened, cached, cold) = tiers cache openDocumentIds solution + + do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect + + if not (enough ()) then + for struct (document, items) in cached do + ct.ThrowIfCancellationRequested() + collect document items + + if not (enough ()) then + let budget = Stopwatch.StartNew() + + // The budget stops handing out documents rather than cancelling a parse under way: + // the first query of a session has to survive its first parse to answer at all. + let scan (document: Document) = + cancellableTask { + if budget.ElapsedMilliseconds < ColdSearchBudgetMs && not (enough ()) then + do! parseAndCollect document + } + + do! cold |> CancellableTask.forEachThrottled parallelism scan return hits - |> Seq.collect id - |> Seq.sortBy (fun (struct (kind, item: NavigableItem, document: Document)) -> - document.IsFSharpSignatureFile, kind, item.Name.Length) - |> Seq.distinctBy (fun (struct (_, item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) - |> Seq.truncate MaxMentions - |> Seq.map (fun (struct (_, item, document)) -> struct (item, document)) - |> Seq.toArray + |> Array.map ( + 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) = + /// implementation declares the name, so the search stops once an implementation has answered. + let declarationsOf + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (solution: Solution) + (fullyQualifiedName: string) + = cancellableTask { let! ct = CancellableTask.getCancellationToken () + let hits = ResizeArray() + + let collect (document: Document) (items: NavigableItem array) = + lock hits (fun () -> + for item in items do + if CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName item then + hits.Add(struct (item, document))) - let matchesIn (document: Document) = + let declaredInImplementation () = + lock hits (fun () -> + hits + |> Seq.exists (fun (struct (_, document: Document)) -> not document.IsFSharpSignatureFile)) + + let parseAndCollect (document: Document) = cancellableTask { - ct.ThrowIfCancellationRequested() let! items = cache.GetNavigableItems document - - return - items - |> Seq.chooseV (fun item -> - if CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName item then - ValueSome struct (item, document) - else - ValueNone) + collect document items } - 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 struct (opened, cached, cold) = tiers cache openDocumentIds solution + + do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect + + if not (declaredInImplementation ()) then + for struct (document, items) in cached do + ct.ThrowIfCancellationRequested() + collect document items + + if not (declaredInImplementation ()) then + let scan (document: Document) = + cancellableTask { + if not (declaredInImplementation ()) then + do! parseAndCollect document + } + + do! cold |> CancellableTask.forEachThrottled parallelism scan let implementations = hits @@ -148,9 +225,14 @@ module internal CopilotSymbolQuery = return struct (sourceText.GetSubText(span).ToString(), span) } - let symbolContext (cache: FSharpNavigableItemsCache) (solution: Solution) (fullyQualifiedName: string) = + let symbolContext + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (solution: Solution) + (fullyQualifiedName: string) + = cancellableTask { - let! declarations = declarationsOf cache solution fullyQualifiedName + let! declarations = declarationsOf cache openDocumentIds solution fullyQualifiedName match Array.tryHeadV declarations with | ValueNone -> return ValueNone @@ -242,17 +324,31 @@ type internal FSharpCopilotContextProvider | text -> ValueSome text | _ -> ValueNone - let mentionsFor (searchText: string voption) = + /// One pass over the solution for the whole batch: Copilot's picker asks for several texts at once + /// and each of them would otherwise walk the same documents. + let mentionsFor (searchTexts: string voption[]) = cancellableTask { - match workspace, searchText with - | null, _ - | _, ValueNone -> return noMentions - | workspace, ValueSome searchText -> - let! hits = CopilotSymbolQuery.search cache workspace.CurrentSolution searchText + let distinct = searchTexts |> Seq.chooseV id |> Seq.distinct |> Seq.toArray + + match workspace with + | null -> return searchTexts |> Array.map (fun _ -> noMentions) + | _ when Array.isEmpty distinct -> return searchTexts |> Array.map (fun _ -> noMentions) + | workspace -> + let! hits = CopilotSymbolQuery.search cache (workspace.GetOpenDocumentIds()) 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)) -> mentionFor item document) + :> IReadOnlyCollection return - hits |> Array.map (fun (struct (item, document)) -> mentionFor item document) - :> IReadOnlyCollection + searchTexts + |> Array.map (function + | ValueSome text -> byText[text] + | ValueNone -> noMentions) } let fullyQualifiedNameOf (inputs: IReadOnlyDictionary | null) = @@ -292,7 +388,8 @@ type internal FSharpCopilotContextProvider String.Equals(memberName, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) -> cancellableTask { - let! symbol = CopilotSymbolQuery.symbolContext cache workspace.CurrentSolution fullyQualifiedName + let! symbol = + CopilotSymbolQuery.symbolContext cache (workspace.GetOpenDocumentIds()) workspace.CurrentSolution fullyQualifiedName match symbol with | ValueNone -> return null @@ -303,7 +400,9 @@ type internal FSharpCopilotContextProvider interface ICopilotMentionQueryable with member _.QueryMentionAsync(query, cancellationToken) : Task> = - mentionsFor (searchTextOf query) |> CancellableTask.start cancellationToken + mentionsFor [| searchTextOf query |] + |> CancellableTask.map Array.head + |> CancellableTask.start cancellationToken member _.NavigateToMentionableAsync(mention, cancellationToken) : Task = match workspace, fullyQualifiedNameOf mention.Inputs with @@ -313,7 +412,8 @@ type internal FSharpCopilotContextProvider cancellableTask { let! ct = CancellableTask.getCancellationToken () let solution = workspace.CurrentSolution - let! declarations = CopilotSymbolQuery.declarationsOf cache solution fullyQualifiedName + + let! declarations = CopilotSymbolQuery.declarationsOf cache (workspace.GetOpenDocumentIds()) solution fullyQualifiedName match Array.tryHeadV declarations with | ValueNone -> return false @@ -333,15 +433,8 @@ 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 searchTexts = queries |> Seq.map searchTextOf |> Seq.toArray - let distinct = Array.distinct searchTexts - let! mentions = distinct |> Array.map mentionsFor |> CancellableTask.whenAll - let byText = Array.zip distinct mentions |> dict - - return searchTexts |> Array.map (fun text -> byText[text]) :> IReadOnlyList> - } + mentionsFor (queries |> Seq.map searchTextOf |> Seq.toArray) + |> CancellableTask.map (fun mentions -> mentions :> IReadOnlyList>) |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index 75a349040b2..8eac9921f81 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -52,6 +52,13 @@ type internal FSharpNavigableItemsCache return items } + /// The items of the document's last parse, whatever version they came from. Reads no text, so a + /// closed document costs nothing; a caller that needs the items of the current text asks for them. + member _.TryGetCachedNavigableItems(documentId: DocumentId) = + match cache.TryGetValue documentId with + | true, struct (_, items) -> ValueSome items + | _ -> ValueNone + member _.CreateMatcherFor(searchPattern: string) = let patternMatcher = patternMatcherFactory.CreatePatternMatcher( diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index c7ad810ab4a..9f98a83ceac 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -2,10 +2,12 @@ namespace FSharp.Editor.Tests +open System open System.Threading open Xunit +open Microsoft.CodeAnalysis open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.FSharp.Editor @@ -49,13 +51,21 @@ let twice x = x * 2 let private run computation = computation |> CancellableTask.start CancellationToken.None |> _.Result - let private search pattern = - CopilotSymbolQuery.search cache solution pattern - |> run + let private namesOf hits = + hits |> Array.map (fun (struct (item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + let private searchIn cache openDocumentIds solution pattern = + CopilotSymbolQuery.search cache openDocumentIds solution [| pattern |] + |> run + |> Array.head + |> namesOf + + let private search pattern = + searchIn cache Seq.empty solution pattern + let private symbolContext name = - CopilotSymbolQuery.symbolContext cache solution name |> run + CopilotSymbolQuery.symbolContext cache Seq.empty solution name |> run let private contextOf name = match symbolContext name with @@ -79,8 +89,9 @@ let twice x = x * 2 [] let ``a name matches only the declaration it spells out`` (candidate: string, expected: bool) = let item = - CopilotSymbolQuery.search cache solution "Counter" + CopilotSymbolQuery.search cache Seq.empty solution [| "Counter" |] |> run + |> Array.head |> Array.pick (fun (struct (item, _)) -> if CopilotSymbolMapping.fullyQualifiedName item = "Widgets.Counter" then Some item @@ -98,6 +109,104 @@ let twice x = x * 2 let ``an unknown name has no context`` () = Assert.True((symbolContext "Widgets.NoSuchThing").IsNone) + /// One matcher and one parse cache per test, so what a test leaves parsed cannot answer the next one. + let private freshCache () = + MefHelpers.createExportProvider().GetExportedValue() + + let private solutionOf files = + let projectId = ProjectId.CreateNewId() + + let documents = + files + |> List.map (fun (path, source) -> RoslynTestHelpers.CreateDocumentInfo projectId path source) + + let solution = + RoslynTestHelpers.CreateSolution [ RoslynTestHelpers.CreateProjectInfo projectId "C:\\many.fsproj" documents ] + + { RoslynTestHelpers.DefaultProjectOptions with + SourceFiles = files |> List.map fst |> Array.ofList + } + |> RoslynTestHelpers.SetProjectOptions projectId solution + + solution + + let private documentsOf (solution: Solution) = + solution.Projects |> Seq.exactlyOne |> _.Documents |> Seq.toArray + + let private documentNamed (name: string) solution = + documentsOf solution + |> Array.find _.FilePath.EndsWith(name, StringComparison.Ordinal) + + /// A file holding more declarations matching `name` than one query reports. + let private manyDeclarations name count = + let members = + [ for i in 1..count -> $" member _.{name}{i} = {i}" ] |> String.concat "\n" + + $"module {name}Module\n\ntype {name}Holder() =\n{members}\n" + + let private coldFile = "C:\\cold.fs", "module Cold\n\nlet widgetCounter = 1\n" + + [] + let ``an open document answers without parsing the rest of the solution`` () = + let cache = freshCache () + let solution = solutionOf [ "C:\\open.fs", manyDeclarations "Widget" 25; coldFile ] + let opened = documentNamed "open.fs" solution + + let names = searchIn cache [ opened.Id ] solution "Widget" + + Assert.Equal(20, names.Length) + Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal)) + Assert.True((cache.TryGetCachedNavigableItems (documentNamed "cold.fs" solution).Id).IsNone) + + [] + let ``documents already parsed answer without parsing the rest`` () = + let cache = freshCache () + let solution = solutionOf [ "C:\\warm.fs", manyDeclarations "Widget" 25; coldFile ] + cache.GetNavigableItems(documentNamed "warm.fs" solution) |> run |> ignore + + let names = searchIn cache Seq.empty solution "Widget" + + Assert.Equal(20, names.Length) + Assert.True((cache.TryGetCachedNavigableItems (documentNamed "cold.fs" solution).Id).IsNone) + + [] + let ``the search stops once it has enough declarations`` () = + let cache = freshCache () + + let solution = + solutionOf + [ + for i in 1..150 -> $"C:\\cold{i}.fs", $"module Cold{i}\n\ntype Counter{i}() =\n member _.Value = {i}\n" + ] + + let names = searchIn cache Seq.empty solution "Counter" + + Assert.Equal(20, names.Length) + + Assert.NotEmpty( + documentsOf solution + |> Array.filter (fun document -> (cache.TryGetCachedNavigableItems document.Id).IsNone) + ) + + [] + let ``a batch of texts answers like the same texts one by one`` () = + let cache = freshCache () + + let solution = + solutionOf + [ + "C:\\a.fs", "module A\n\nlet alpha = 1\n" + "C:\\b.fs", "module B\n\nlet beta = 2\n" + ] + + let batched = + CopilotSymbolQuery.search cache Seq.empty solution [| "alpha"; "beta" |] + |> run + |> Array.map namesOf + + Assert.Equal(searchIn cache Seq.empty solution "alpha", batched[0]) + Assert.Equal(searchIn cache Seq.empty solution "beta", batched[1]) + [] let ``a type context carries the whole declaration and its doc comment`` () = let context = contextOf "Widgets.Counter" From 30a96ada293da288e8902233dc8046c352275602 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 03:14:49 +0200 Subject: [PATCH 11/29] Answer the mention picker with the open files first A declaration in a file the user has open now carries a High priority into the picker, which merges answers from every provider and cannot infer that from their order. The obvious source for what the user is looking at, the shell's current document frame, is not reachable here: importing SVsServiceProvider by contract name pins the required type identity to System.IServiceProvider, which nothing exports, so the whole brokered service failed to compose and the picker held no F# symbols at all. Co-Authored-By: Claude Opus 5 --- .../Copilot/CopilotContextProvider.fs | 50 ++++++++++++++----- .../CopilotContextProviderTests.fs | 30 ++++++++++- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index fdddf05793e..8ef12c8aa76 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -22,6 +22,13 @@ open Microsoft.VisualStudio.Shell.ServiceBroker open FSharp.Compiler.EditorServices open CancellableTasks +/// Where a declaration sits relative to what the user is working on. The picker merges answers from +/// every provider, so a match in a file the user has open has to say so rather than rely on its position. +[] +type internal DocumentFocus = + | Open + | Elsewhere + /// Solution-wide lookup of F# declarations behind the Copilot chat "#" mention picker. /// Kept apart from the brokered service so it can be exercised without a Visual Studio workspace. module internal CopilotSymbolQuery = @@ -48,10 +55,15 @@ module internal CopilotSymbolQuery = |> Seq.where (fun project -> project.Language = FSharpConstants.FSharpLanguageName) |> Seq.collect _.Documents + let focusOf (openIds: HashSet) (document: Document) = + if openIds.Contains document.Id then + DocumentFocus.Open + else + DocumentFocus.Elsewhere + /// The documents in the order a query visits them: the ones the user has open, the ones already /// parsed into the cache, and the ones that would have to be parsed to answer. - let private tiers (cache: FSharpNavigableItemsCache) (openDocumentIds: DocumentId seq) (solution: Solution) = - let openIds = HashSet openDocumentIds + let private tiers (cache: FSharpNavigableItemsCache) (openIds: HashSet) (solution: Solution) = let opened = ResizeArray() let cached = ResizeArray() let cold = ResizeArray() @@ -82,6 +94,7 @@ module internal CopilotSymbolQuery = let search (cache: FSharpNavigableItemsCache) (openDocumentIds: DocumentId seq) (solution: Solution) (searchTexts: string[]) = cancellableTask { let! ct = CancellableTask.getCancellationToken () + let openIds = HashSet openDocumentIds let matchers = searchTexts |> Array.map cache.CreateMatcherFor let hits = Array.init searchTexts.Length (fun _ -> ResizeArray()) let found = Array.init searchTexts.Length (fun _ -> HashSet StringComparer.Ordinal) @@ -108,7 +121,7 @@ module internal CopilotSymbolQuery = collect document items } - let struct (opened, cached, cold) = tiers cache openDocumentIds solution + let struct (opened, cached, cold) = tiers cache openIds solution do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect @@ -134,10 +147,10 @@ module internal CopilotSymbolQuery = hits |> Array.map ( Seq.sortBy (fun (struct (kind, item: NavigableItem, document: Document)) -> - document.IsFSharpSignatureFile, kind, item.Name.Length) + focusOf openIds 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.map (fun (struct (_, item, document)) -> struct (item, document, focusOf openIds document)) >> Seq.toArray ) } @@ -171,7 +184,7 @@ module internal CopilotSymbolQuery = collect document items } - let struct (opened, cached, cold) = tiers cache openDocumentIds solution + let struct (opened, cached, cold) = tiers cache (HashSet openDocumentIds) solution do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect @@ -292,11 +305,21 @@ type internal FSharpCopilotContextProvider 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 priorityOf focus = + match focus with + | DocumentFocus.Open -> CopilotQueriedMentionPriority.High + | DocumentFocus.Elsewhere -> CopilotQueriedMentionPriority.None + + let mentionFor (item: NavigableItem) (document: Document) focus = + let inputs = + Dictionary( + dict + [ + CopilotSymbolMapping.FullyQualifiedNameInput, + CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item) + ], + StringComparer.Ordinal + ) let description = CopilotSymbolQuery.describe item document @@ -308,7 +331,8 @@ type internal FSharpCopilotContextProvider Description = description, Tooltip = description, Icon = Nullable(CopilotSymbolMapping.icon item.Kind), - IsNavigable = true + IsNavigable = true, + Priority = priorityOf focus ) :> CopilotQueriedMention @@ -341,7 +365,7 @@ type internal FSharpCopilotContextProvider for index in 0 .. distinct.Length - 1 do byText[distinct[index]] <- hits[index] - |> Array.map (fun (struct (item, document)) -> mentionFor item document) + |> Array.map (fun (struct (item, document, focus)) -> mentionFor item document focus) :> IReadOnlyCollection return diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index 9f98a83ceac..4c31e579700 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -53,7 +53,7 @@ let twice x = x * 2 let private namesOf hits = hits - |> Array.map (fun (struct (item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + |> Array.map (fun (struct (item, _, _)) -> CopilotSymbolMapping.fullyQualifiedName item) let private searchIn cache openDocumentIds solution pattern = CopilotSymbolQuery.search cache openDocumentIds solution [| pattern |] @@ -92,7 +92,7 @@ let twice x = x * 2 CopilotSymbolQuery.search cache Seq.empty solution [| "Counter" |] |> run |> Array.head - |> Array.pick (fun (struct (item, _)) -> + |> Array.pick (fun (struct (item, _, _)) -> if CopilotSymbolMapping.fullyQualifiedName item = "Widgets.Counter" then Some item else @@ -188,6 +188,32 @@ let twice x = x * 2 |> Array.filter (fun document -> (cache.TryGetCachedNavigableItems document.Id).IsNone) ) + /// The declaration in the open file loses on every other part of the ordering - the name it is + /// matched against is longer - so it can only come first by being the file the user has open. + [] + [] + [] + let ``an open file answers before the rest`` (holderIsOpen: bool) (expected: string) = + let cache = freshCache () + + let solution = + solutionOf + [ + "C:\\elsewhere.fs", "module Elsewhere\n\ntype Widget() =\n member _.Value = 1\n" + "C:\\holder.fs", "module Holder\n\ntype WidgetHolder() =\n member _.Value = 2\n" + ] + + let openDocumentIds = + if holderIsOpen then + [ (documentNamed "holder.fs" solution).Id ] + else + [] + + let names = searchIn cache openDocumentIds solution "Widget" + + Assert.Equal(expected, Array.head names) + Assert.Equal(2, names.Length) + [] let ``a batch of texts answers like the same texts one by one`` () = let cache = freshCache () From 4bffe1ad19777510b55e51da5038e5765d78e8f3 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 04:17:42 +0200 Subject: [PATCH 12/29] Answer the picker before it knows what is being mentioned Copilot asks for mentions while the user is still typing, before it has resolved what kind of mention that is, and the query then carries CopilotMentionType.Unknown. The provider only answered Context, so every early query - the ones that fill the list as the user types - went back empty, and no F# declaration reached the picker at all. Copilot's own symbol provider answers Unknown and Context alike. The priorities now match what that provider reports for C#: the file whose editor has focus is High, a file that is merely open is Low, anything else None. A tracker updated from GotAggregateFocus supplies the focused path, which the shell's own current-document frame cannot: reaching it needs SVsServiceProvider, which this composition does not export. Co-Authored-By: Claude Opus 5 --- .../Copilot/ActiveDocumentTracker.fs | 43 +++++++++++++++ .../Copilot/CopilotContextProvider.fs | 55 +++++++++++++------ .../src/FSharp.Editor/FSharp.Editor.fsproj | 1 + .../CopilotContextProviderTests.fs | 31 +++++++++-- 4 files changed, 110 insertions(+), 20 deletions(-) create mode 100644 vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs diff --git a/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs b/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs new file mode 100644 index 00000000000..0ea4667a3cd --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System.ComponentModel.Composition + +open Microsoft.VisualStudio.Text +open Microsoft.VisualStudio.Text.Editor +open Microsoft.VisualStudio.Utilities + +/// The file whose editor last took focus. Written only from the UI thread, read without a lock from +/// wherever a brokered service happens to run: a read that races a tab switch names the tab before it, +/// which costs a mention its place in a picker and nothing else. +[] +[] +type internal FSharpActiveDocumentTracker() = + + let mutable focusedFilePath = ValueNone + + member _.FocusedFilePath = focusedFilePath + + member _.SetFocusedFilePath path = focusedFilePath <- ValueSome path + +/// Every content type, not just F#: a C# file taking focus has to displace the F# one, or a declaration +/// would answer as focused while its file is off screen. +[)>] +[] +[] +type internal FSharpActiveDocumentListener + [] + (tracker: FSharpActiveDocumentTracker, textDocumentFactory: ITextDocumentFactoryService) = + + interface IWpfTextViewCreationListener with + member _.TextViewCreated(textView: IWpfTextView) = + let recordFocus () = + match textDocumentFactory.TryGetTextDocument textView.TextBuffer with + | true, document -> tracker.SetFocusedFilePath document.FilePath + | _ -> () + + textView.GotAggregateFocus.Add(fun _ -> recordFocus ()) + + if textView.HasAggregateFocus then + recordFocus () diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 8ef12c8aa76..1dadb902468 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -22,10 +22,12 @@ open Microsoft.VisualStudio.Shell.ServiceBroker open FSharp.Compiler.EditorServices open CancellableTasks -/// Where a declaration sits relative to what the user is working on. The picker merges answers from -/// every provider, so a match in a file the user has open has to say so rather than rely on its position. +/// Where a declaration sits relative to what the user is working on. The picker merges answers from every +/// provider and ranks them by the priority each one reports, so a match has to say where it sits rather +/// than rely on its position. The tiers mirror what Copilot's own symbol provider reports for C#. [] type internal DocumentFocus = + | Focused | Open | Elsewhere @@ -33,6 +35,8 @@ type internal DocumentFocus = /// Kept apart from the brokered service so it can be exercised without a Visual Studio workspace. module internal CopilotSymbolQuery = + /// Also the point at which the search stops parsing files nobody has opened, so it bounds the cold + /// scan as much as the answer. Copilot's own provider reads an index and can afford a far larger cap. [] let private MaxMentions = 20 @@ -55,11 +59,11 @@ module internal CopilotSymbolQuery = |> Seq.where (fun project -> project.Language = FSharpConstants.FSharpLanguageName) |> Seq.collect _.Documents - let focusOf (openIds: HashSet) (document: Document) = - if openIds.Contains document.Id then - DocumentFocus.Open - else - DocumentFocus.Elsewhere + let focusOf (focusedFilePath: string voption) (openIds: HashSet) (document: Document) = + match focusedFilePath with + | ValueSome path when String.Equals(path, document.FilePath, StringComparison.OrdinalIgnoreCase) -> DocumentFocus.Focused + | _ when openIds.Contains document.Id -> DocumentFocus.Open + | _ -> DocumentFocus.Elsewhere /// The documents in the order a query visits them: the ones the user has open, the ones already /// parsed into the cache, and the ones that would have to be parsed to answer. @@ -91,7 +95,13 @@ module internal CopilotSymbolQuery = /// Declarations whose fully qualified name matches each search text, best match first, one entry /// per name. Every document is visited once for all of the texts. - let search (cache: FSharpNavigableItemsCache) (openDocumentIds: DocumentId seq) (solution: Solution) (searchTexts: string[]) = + let search + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (focusedFilePath: string voption) + (solution: Solution) + (searchTexts: string[]) + = cancellableTask { let! ct = CancellableTask.getCancellationToken () let openIds = HashSet openDocumentIds @@ -147,10 +157,10 @@ module internal CopilotSymbolQuery = hits |> Array.map ( Seq.sortBy (fun (struct (kind, item: NavigableItem, document: Document)) -> - focusOf openIds document, document.IsFSharpSignatureFile, kind, item.Name.Length) + focusOf focusedFilePath openIds 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, focusOf openIds document)) + >> Seq.map (fun (struct (_, item, document)) -> struct (item, document, focusOf focusedFilePath openIds document)) >> Seq.toArray ) } @@ -278,7 +288,11 @@ module internal CopilotSymbolQuery = Audience = (ServiceAudience.PublicSdk ||| ServiceAudience.Local))>] type internal FSharpCopilotContextProvider [] - (cache: FSharpNavigableItemsCache, [] workspace: VisualStudioWorkspace | null) = + ( + cache: FSharpNavigableItemsCache, + activeDocument: FSharpActiveDocumentTracker, + [] workspace: VisualStudioWorkspace | null + ) = static let moniker = ServiceMoniker(FSharpConstants.copilotSymbolProviderName, Version CopilotDescriptors.CurrentContextProviderVersion) @@ -307,7 +321,8 @@ type internal FSharpCopilotContextProvider let priorityOf focus = match focus with - | DocumentFocus.Open -> CopilotQueriedMentionPriority.High + | DocumentFocus.Focused -> CopilotQueriedMentionPriority.High + | DocumentFocus.Open -> CopilotQueriedMentionPriority.Low | DocumentFocus.Elsewhere -> CopilotQueriedMentionPriority.None let mentionFor (item: NavigableItem) (document: Document) focus = @@ -337,11 +352,13 @@ type internal FSharpCopilotContextProvider :> 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". + /// name once the mention has been committed, as in "#fsharpSymbol:Namespace.Type". The picker asks + /// before it has resolved what kind of mention is being typed, which Copilot's own provider answers + /// as readily as a resolved one. let searchTextOf (query: CopilotMentionQuery) = match query.Type, query.Inputs with - | CopilotMentionType.Context, null -> ValueNone - | CopilotMentionType.Context, inputs when inputs.Count > 0 -> + | (CopilotMentionType.Context | CopilotMentionType.Unknown), null -> ValueNone + | (CopilotMentionType.Context | CopilotMentionType.Unknown), 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 @@ -358,7 +375,13 @@ type internal FSharpCopilotContextProvider | null -> return searchTexts |> Array.map (fun _ -> noMentions) | _ when Array.isEmpty distinct -> return searchTexts |> Array.map (fun _ -> noMentions) | workspace -> - let! hits = CopilotSymbolQuery.search cache (workspace.GetOpenDocumentIds()) workspace.CurrentSolution distinct + let! hits = + CopilotSymbolQuery.search + cache + (workspace.GetOpenDocumentIds()) + activeDocument.FocusedFilePath + workspace.CurrentSolution + distinct let byText = Dictionary(StringComparer.Ordinal) diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 3176e1b964c..71634cd8bd2 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -94,6 +94,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index 4c31e579700..79c18e5ffbf 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -55,12 +55,15 @@ let twice x = x * 2 hits |> Array.map (fun (struct (item, _, _)) -> CopilotSymbolMapping.fullyQualifiedName item) - let private searchIn cache openDocumentIds solution pattern = - CopilotSymbolQuery.search cache openDocumentIds solution [| pattern |] + let private searchFocused cache openDocumentIds focusedFilePath solution pattern = + CopilotSymbolQuery.search cache openDocumentIds focusedFilePath solution [| pattern |] |> run |> Array.head |> namesOf + let private searchIn cache openDocumentIds solution pattern = + searchFocused cache openDocumentIds ValueNone solution pattern + let private search pattern = searchIn cache Seq.empty solution pattern @@ -89,7 +92,7 @@ let twice x = x * 2 [] let ``a name matches only the declaration it spells out`` (candidate: string, expected: bool) = let item = - CopilotSymbolQuery.search cache Seq.empty solution [| "Counter" |] + CopilotSymbolQuery.search cache Seq.empty ValueNone solution [| "Counter" |] |> run |> Array.head |> Array.pick (fun (struct (item, _, _)) -> @@ -214,6 +217,26 @@ let twice x = x * 2 Assert.Equal(expected, Array.head names) Assert.Equal(2, names.Length) + /// The focused file outranks the merely open one, which is how Copilot's own provider separates + /// the tab being edited from the rest of the tabs. + [] + let ``the focused file answers before the other open ones`` () = + let cache = freshCache () + + let solution = + solutionOf + [ + "C:\\elsewhere.fs", "module Elsewhere\n\ntype Widget() =\n member _.Value = 1\n" + "C:\\holder.fs", "module Holder\n\ntype WidgetHolder() =\n member _.Value = 2\n" + ] + + let openDocumentIds = documentsOf solution |> Array.map _.Id + + let names = + searchFocused cache openDocumentIds (ValueSome "C:\\holder.fs") solution "Widget" + + Assert.Equal("Holder.WidgetHolder", Array.head names) + [] let ``a batch of texts answers like the same texts one by one`` () = let cache = freshCache () @@ -226,7 +249,7 @@ let twice x = x * 2 ] let batched = - CopilotSymbolQuery.search cache Seq.empty solution [| "alpha"; "beta" |] + CopilotSymbolQuery.search cache Seq.empty ValueNone solution [| "alpha"; "beta" |] |> run |> Array.map namesOf From bd80eac40105a27a631bc7c3ed46b2e696bf8798 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 17:33:20 +0200 Subject: [PATCH 13/29] Rank and label F# declarations in the picker the way Copilot does C# Three gaps against Copilot's own symbol provider, which answers the picker for C# (SymbolContextProvider in Microsoft.VisualStudio.Copilot.Core): A bare "#" arrives as a query whose only input is empty. The provider read that as no query at all and answered nothing, so the list the user first sees never held an F# declaration. It now answers with every declaration of the open files, and a text shorter than three characters is looked up in the open files alone - what Copilot does, and what keeps the first keystrokes from parsing the solution. Copilot ranks a declaration Selection when its whole extent holds the caret or overlaps the selection in the focused file. The focus tracker now records the lines the caret or selection covers, and the search outlines the focused file once to find the declarations around them. The caret in a member's body selects the member, its type and the modules around it. The extent is the full declaration, not the snippet: the snippet stops at 200 lines, which would miss a caret deep inside a large module. The picker shows a mention's description beside it, and Copilot puts the file name there; the provider put the container and the project. The description is now the file name, and the tooltip follows Copilot's "{0} in {1}\n{2}" - kind, file, and a member named by its container - from a localizable resource. Co-Authored-By: Claude Opus 5 --- .../Copilot/ActiveDocumentTracker.fs | 57 ++++-- .../Copilot/CopilotContextProvider.fs | 163 ++++++++++++------ .../Copilot/CopilotSymbolMapping.fs | 9 + .../Copilot/CopilotSymbolSnippets.fs | 9 +- .../src/FSharp.Editor/FSharp.Editor.resx | 5 + .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 7 + .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 7 + .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 7 + .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 7 + .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 7 + .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 7 + .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 7 + .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 7 + .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 7 + .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 7 + .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 7 + .../xlf/FSharp.Editor.zh-Hans.xlf | 7 + .../xlf/FSharp.Editor.zh-Hant.xlf | 7 + .../CopilotContextProviderTests.fs | 89 ++++++++-- 19 files changed, 345 insertions(+), 78 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs b/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs index 0ea4667a3cd..8ef15407940 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs @@ -8,18 +8,29 @@ open Microsoft.VisualStudio.Text open Microsoft.VisualStudio.Text.Editor open Microsoft.VisualStudio.Utilities -/// The file whose editor last took focus. Written only from the UI thread, read without a lock from -/// wherever a brokered service happens to run: a read that races a tab switch names the tab before it, -/// which costs a mention its place in a picker and nothing else. +/// The file of the editor that last took focus, and the lines its caret or selection covers - 1-based +/// and inclusive, as the parse tree counts them. +type internal EditorFocus = + { + FilePath: string + FirstLine: int + LastLine: int + } + +/// Written from the UI thread as focus, caret and selection move, read from wherever a brokered service +/// happens to run; the lock keeps a read from pairing one file with another file's lines. A read that +/// races a tab switch names the tab before it, which costs a mention its place in a picker and nothing else. [] [] type internal FSharpActiveDocumentTracker() = - let mutable focusedFilePath = ValueNone + let gate = obj () + let mutable focus = ValueNone - member _.FocusedFilePath = focusedFilePath + member _.Focus = lock gate (fun () -> focus) - member _.SetFocusedFilePath path = focusedFilePath <- ValueSome path + member _.SetFocus value = + lock gate (fun () -> focus <- ValueSome value) /// Every content type, not just F#: a C# file taking focus has to displace the F# one, or a declaration /// would answer as focused while its file is off screen. @@ -30,14 +41,40 @@ type internal FSharpActiveDocumentListener [] (tracker: FSharpActiveDocumentTracker, textDocumentFactory: ITextDocumentFactoryService) = + static let lineOf (point: SnapshotPoint) = + point.GetContainingLine().LineNumber + 1 + + /// A selection ends before its End point: one of whole lines ends at the start of the line after them. + static let linesOf (textView: ITextView) = + let selection = textView.Selection + + if selection.IsEmpty then + let caret = lineOf textView.Caret.Position.BufferPosition + struct (caret, caret) + else + let firstLine = lineOf selection.Start.Position + struct (firstLine, max firstLine (lineOf (selection.End.Position - 1))) + interface IWpfTextViewCreationListener with member _.TextViewCreated(textView: IWpfTextView) = let recordFocus () = match textDocumentFactory.TryGetTextDocument textView.TextBuffer with - | true, document -> tracker.SetFocusedFilePath document.FilePath + | true, document -> + let struct (firstLine, lastLine) = linesOf textView + + tracker.SetFocus + { + FilePath = document.FilePath + FirstLine = firstLine + LastLine = lastLine + } | _ -> () - textView.GotAggregateFocus.Add(fun _ -> recordFocus ()) + let recordWhileFocused () = + if textView.HasAggregateFocus then + recordFocus () - if textView.HasAggregateFocus then - recordFocus () + textView.GotAggregateFocus.Add(fun _ -> recordFocus ()) + textView.Caret.PositionChanged.Add(fun _ -> recordWhileFocused ()) + textView.Selection.SelectionChanged.Add(fun _ -> recordWhileFocused ()) + recordWhileFocused () diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 1dadb902468..b8ac3fa2c6d 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -18,6 +18,7 @@ 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 @@ -27,6 +28,8 @@ open CancellableTasks /// than rely on its position. The tiers mirror what Copilot's own symbol provider reports for C#. [] type internal DocumentFocus = + /// Declared around the caret or selection of the focused file. + | Selected | Focused | Open | Elsewhere @@ -52,19 +55,78 @@ module internal CopilotSymbolQuery = [] let private ColdSearchBudgetMs = 1500 + /// A shorter text matches too much of the solution to be worth parsing it for, so it is looked up in the + /// files the user has open - which is what Copilot's own provider does for C#. + [] + let private MinSolutionWideSearchLength = 3 + let private parallelism = max 1 (Environment.ProcessorCount - 1) + /// A bare "#" asks with no text at all, and is answered with every declaration of the open files. + let private matcherFor (cache: FSharpNavigableItemsCache) (searchText: string) = + match searchText with + | "" -> fun (_: NavigableItem) -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) + | searchText -> cache.CreateMatcherFor searchText + let private fsharpDocuments (solution: Solution) = solution.Projects |> Seq.where (fun project -> project.Language = FSharpConstants.FSharpLanguageName) |> Seq.collect _.Documents - let focusOf (focusedFilePath: string voption) (openIds: HashSet) (document: Document) = - match focusedFilePath with - | ValueSome path when String.Equals(path, document.FilePath, StringComparison.OrdinalIgnoreCase) -> DocumentFocus.Focused + let private isFocused (focus: EditorFocus) (document: Document) = + String.Equals(focus.FilePath, document.FilePath, StringComparison.OrdinalIgnoreCase) + + let private focusOf + (focus: EditorFocus voption) + (openIds: HashSet) + (isSelected: NavigableItem -> bool) + (document: Document) + (item: NavigableItem) + = + match focus with + | ValueSome focus when isFocused focus document -> + if isSelected item then + DocumentFocus.Selected + else + DocumentFocus.Focused | _ when openIds.Contains document.Id -> DocumentFocus.Open | _ -> DocumentFocus.Elsewhere + /// The source of `document` and the outlining of its declarations. + let private outlineOf (document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync ct + let! parseResults = document.GetFSharpParseResultsAsync UserOpName + let sourceLines = sourceText.GetLinesAsMemory() + + let scopes = + Structure.getOutliningRanges sourceLines parseResults.ParseTree |> Seq.toArray + + return struct (sourceText, sourceLines, scopes) + } + + /// Whether a declaration of the focused file spans a line the caret or selection is on - the whole + /// declaration, so the caret in a member's body selects the member, its type and the modules around them. + let private selectionIn (focus: EditorFocus voption) (opened: Document seq) = + cancellableTask { + let focused = + focus + |> ValueOption.bind (fun focus -> opened |> Seq.tryFindV (isFocused focus)) + + match focus, focused with + | ValueSome focus, ValueSome document -> + let! struct (_, sourceLines, scopes) = outlineOf document + + return + fun (item: NavigableItem) -> + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.declarationLines sourceLines scopes item + + firstLine <= focus.LastLine && focus.FirstLine <= lastLine + | _ -> return fun (_: NavigableItem) -> false + } + /// The documents in the order a query visits them: the ones the user has open, the ones already /// parsed into the cache, and the ones that would have to be parsed to answer. let private tiers (cache: FSharpNavigableItemsCache) (openIds: HashSet) (solution: Solution) = @@ -82,63 +144,58 @@ module internal CopilotSymbolQuery = struct (opened, cached, cold) - 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 each search text, best match first, one entry /// per name. Every document is visited once for all of the texts. let search (cache: FSharpNavigableItemsCache) (openDocumentIds: DocumentId seq) - (focusedFilePath: string voption) + (focus: EditorFocus voption) (solution: Solution) (searchTexts: string[]) = cancellableTask { let! ct = CancellableTask.getCancellationToken () let openIds = HashSet openDocumentIds - let matchers = searchTexts |> Array.map cache.CreateMatcherFor + let matchers = searchTexts |> Array.map (matcherFor cache) + + let openFilesOnly = + searchTexts |> Array.map (fun text -> text.Length < MinSolutionWideSearchLength) + let hits = Array.init searchTexts.Length (fun _ -> ResizeArray()) let found = Array.init searchTexts.Length (fun _ -> HashSet StringComparer.Ordinal) - let collect (document: Document) (items: NavigableItem array) = + let collect isOpen (document: Document) (items: NavigableItem array) = lock hits (fun () -> for index in 0 .. matchers.Length - 1 do - let tryMatch = matchers[index] + if isOpen || not openFilesOnly[index] then + let tryMatch = matchers[index] - for item in items do - match tryMatch item with - | ValueSome patternMatch -> - hits[index].Add(struct (patternMatch.Kind, item, document)) + for item in items do + match tryMatch item with + | ValueSome patternMatch -> + hits[index].Add(struct (patternMatch.Kind, item, document)) - found[index].Add(CopilotSymbolMapping.fullyQualifiedName item) |> ignore - | ValueNone -> ()) + found[index].Add(CopilotSymbolMapping.fullyQualifiedName item) |> ignore + | ValueNone -> ()) let enough () = - lock hits (fun () -> found |> Array.forall (fun names -> names.Count >= MaxMentions)) + lock hits (fun () -> + Seq.forall2 (fun (names: HashSet) onlyOpen -> onlyOpen || names.Count >= MaxMentions) found openFilesOnly) - let parseAndCollect (document: Document) = + let parseAndCollect isOpen (document: Document) = cancellableTask { let! items = cache.GetNavigableItems document - collect document items + collect isOpen document items } let struct (opened, cached, cold) = tiers cache openIds solution - do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect + do! opened |> CancellableTask.forEachThrottled parallelism (parseAndCollect true) if not (enough ()) then for struct (document, items) in cached do ct.ThrowIfCancellationRequested() - collect document items + collect false document items if not (enough ()) then let budget = Stopwatch.StartNew() @@ -148,19 +205,23 @@ module internal CopilotSymbolQuery = let scan (document: Document) = cancellableTask { if budget.ElapsedMilliseconds < ColdSearchBudgetMs && not (enough ()) then - do! parseAndCollect document + do! parseAndCollect false document } do! cold |> CancellableTask.forEachThrottled parallelism scan + let! isSelected = selectionIn focus opened + return hits |> Array.map ( - Seq.sortBy (fun (struct (kind, item: NavigableItem, document: Document)) -> - focusOf focusedFilePath openIds document, document.IsFSharpSignatureFile, kind, item.Name.Length) - >> Seq.distinctBy (fun (struct (_, item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + Seq.map (fun (struct (kind, item, document)) -> + struct (focusOf focus openIds isSelected document item, kind, item, document)) + >> Seq.sortBy (fun (struct (focus, kind, item: NavigableItem, document: Document)) -> + focus, 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, focusOf focusedFilePath openIds document)) + >> Seq.map (fun (struct (focus, _, item, document)) -> struct (item, document, focus)) >> Seq.toArray ) } @@ -228,13 +289,7 @@ module internal CopilotSymbolQuery = /// 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 = sourceText.GetLinesAsMemory() - - let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree + let! struct (sourceText, sourceLines, scopes) = outlineOf document let struct (firstLine, lastLine) = CopilotSymbolSnippets.definitionLines sourceLines scopes item @@ -321,6 +376,7 @@ type internal FSharpCopilotContextProvider let priorityOf focus = match focus with + | DocumentFocus.Selected -> CopilotQueriedMentionPriority.Selection | DocumentFocus.Focused -> CopilotQueriedMentionPriority.High | DocumentFocus.Open -> CopilotQueriedMentionPriority.Low | DocumentFocus.Elsewhere -> CopilotQueriedMentionPriority.None @@ -336,15 +392,23 @@ type internal FSharpCopilotContextProvider StringComparer.Ordinal ) - let description = CopilotSymbolQuery.describe item document + 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 = description, - Tooltip = description, + Description = fileName, + Tooltip = tooltip, Icon = Nullable(CopilotSymbolMapping.icon item.Kind), IsNavigable = true, Priority = priorityOf focus @@ -360,9 +424,9 @@ type internal FSharpCopilotContextProvider | (CopilotMentionType.Context | CopilotMentionType.Unknown), null -> ValueNone | (CopilotMentionType.Context | CopilotMentionType.Unknown), inputs when inputs.Count > 0 -> match inputs[inputs.Count - 1] with - | text when String.IsNullOrWhiteSpace text -> ValueNone + | null -> ValueSome "" | text when String.Equals(text, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) -> ValueNone - | text -> ValueSome text + | text -> ValueSome(text.Trim()) | _ -> ValueNone /// One pass over the solution for the whole batch: Copilot's picker asks for several texts at once @@ -376,12 +440,7 @@ type internal FSharpCopilotContextProvider | _ when Array.isEmpty distinct -> return searchTexts |> Array.map (fun _ -> noMentions) | workspace -> let! hits = - CopilotSymbolQuery.search - cache - (workspace.GetOpenDocumentIds()) - activeDocument.FocusedFilePath - workspace.CurrentSolution - distinct + CopilotSymbolQuery.search cache (workspace.GetOpenDocumentIds()) activeDocument.Focus workspace.CurrentSolution distinct let byText = Dictionary(StringComparer.Ordinal) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index 21e296c0476..f306617cd5a 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -58,6 +58,15 @@ let fullyQualifiedName (item: NavigableItem) = | "" -> item.Name | container -> $"{container}.{item.Name}" +/// How Copilot's own tooltip names a declaration: a member by the container it is declared in, a type +/// or module by itself. +let tooltipName (item: NavigableItem) = + match symbolContextType item.Kind, item.Container.Type, item.Container.Name with + | CopilotSymbolContextType.Class, _, _ + | _, NavigableContainerType.File, _ + | _, _, "" -> item.Name + | _, _, container -> $"{container}.{item.Name}" + /// 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) = diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index fe53cac4f7d..eba3ce7742d 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -11,8 +11,8 @@ open FSharp.Compiler.EditorServices [] let MaxSnippetLines = 200 -/// Inclusive, 1-based line bounds of the declaration `item` names, including its doc comment. -let definitionLines (sourceLines: ReadOnlyMemory array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = +/// Inclusive, 1-based line bounds of the whole declaration `item` names, including its doc comment. +let declarationLines (sourceLines: ReadOnlyMemory 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 @@ -48,6 +48,9 @@ let definitionLines (sourceLines: ReadOnlyMemory array) (scopes: Structure else line - let firstLine = docCommentStart firstLine + struct (docCommentStart firstLine, lastLine) +/// The lines of the declaration `item` names that a chat prompt carries. +let definitionLines (sourceLines: ReadOnlyMemory array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = + let struct (firstLine, lastLine) = declarationLines sourceLines scopes item struct (firstLine, min lastLine (firstLine + MaxSnippetLines - 1)) diff --git a/vsintegration/src/FSharp.Editor/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/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 index 79c18e5ffbf..d6a878be27f 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -55,8 +55,8 @@ let twice x = x * 2 hits |> Array.map (fun (struct (item, _, _)) -> CopilotSymbolMapping.fullyQualifiedName item) - let private searchFocused cache openDocumentIds focusedFilePath solution pattern = - CopilotSymbolQuery.search cache openDocumentIds focusedFilePath solution [| pattern |] + let private searchFocused cache openDocumentIds focus solution pattern = + CopilotSymbolQuery.search cache openDocumentIds focus solution [| pattern |] |> run |> Array.head |> namesOf @@ -67,6 +67,16 @@ let twice x = x * 2 let private search pattern = searchIn cache Seq.empty solution pattern + let private itemNamed (fullyQualifiedName: string) = + CopilotSymbolQuery.search cache Seq.empty ValueNone solution [| fullyQualifiedName |] + |> run + |> Array.head + |> Array.pick (fun (struct (item, _, _)) -> + if CopilotSymbolMapping.fullyQualifiedName item = fullyQualifiedName then + Some item + else + None) + let private symbolContext name = CopilotSymbolQuery.symbolContext cache Seq.empty solution name |> run @@ -91,17 +101,15 @@ let twice x = x * 2 [] [] let ``a name matches only the declaration it spells out`` (candidate: string, expected: bool) = - let item = - CopilotSymbolQuery.search cache Seq.empty ValueNone solution [| "Counter" |] - |> run - |> Array.head - |> Array.pick (fun (struct (item, _, _)) -> - if CopilotSymbolMapping.fullyQualifiedName item = "Widgets.Counter" then - Some item - else - None) + Assert.Equal(expected, CopilotSymbolMapping.hasFullyQualifiedName candidate (itemNamed "Widgets.Counter")) - Assert.Equal(expected, CopilotSymbolMapping.hasFullyQualifiedName candidate item) + [] + [] + [] + [] + [] + 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 ``search reports each declaration once`` () = @@ -149,6 +157,14 @@ let twice x = x * 2 let private coldFile = "C:\\cold.fs", "module Cold\n\nlet widgetCounter = 1\n" + let private caretOn filePath line = + ValueSome + { + FilePath = filePath + FirstLine = line + LastLine = line + } + [] let ``an open document answers without parsing the rest of the solution`` () = let cache = freshCache () @@ -217,6 +233,33 @@ let twice x = x * 2 Assert.Equal(expected, Array.head names) Assert.Equal(2, names.Length) + /// A bare "#" is the first thing the picker asks, and Copilot's own provider fills it from the open files. + [] + let ``no text at all answers with the declarations of the open files`` () = + let cache = freshCache () + let solution = solutionOf [ "C:\\open.fs", manyDeclarations "Widget" 3; coldFile ] + + let names = + searchFocused cache [ (documentNamed "open.fs" solution).Id ] (caretOn "C:\\open.fs" 1) solution "" + + Assert.Contains("WidgetModule.WidgetHolder", names) + Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal)) + Assert.True((cache.TryGetCachedNavigableItems (documentNamed "cold.fs" solution).Id).IsNone) + + [] + [] + [] + let ``a short text is looked up in the open files alone`` (pattern: string) = + let cache = freshCache () + + let solution = + solutionOf [ "C:\\open.fs", "module Open\n\nlet other = 1\n"; coldFile ] + + let names = + searchIn cache [ (documentNamed "open.fs" solution).Id ] solution pattern + + Assert.Equal(pattern.Length >= 3, Array.contains "Cold.widgetCounter" names) + /// The focused file outranks the merely open one, which is how Copilot's own provider separates /// the tab being edited from the rest of the tabs. [] @@ -233,10 +276,30 @@ let twice x = x * 2 let openDocumentIds = documentsOf solution |> Array.map _.Id let names = - searchFocused cache openDocumentIds (ValueSome "C:\\holder.fs") solution "Widget" + searchFocused cache openDocumentIds (caretOn "C:\\holder.fs" 1) solution "Widget" Assert.Equal("Holder.WidgetHolder", Array.head names) + /// The type around the caret loses on name length to the other one, so it can only come first by + /// holding the caret - on a line of its member's body, not of its own name. + [] + [] + [] + let ``the declaration around the caret answers before the rest of the focused file`` (caretLine: int) (expected: string) = + let cache = freshCache () + + let source = + "module Selection\n\ntype Short() =\n member _.Value = 1\n\ntype AroundTheCaret() =\n member _.Compute() =\n 2\n" + + let solution = solutionOf [ "C:\\selection.fs", source ] + let focused = documentNamed "selection.fs" solution + + let names = + searchFocused cache [ focused.Id ] (caretOn "C:\\selection.fs" caretLine) solution "" + |> Array.filter (fun name -> name = "Selection.Short" || name = "Selection.AroundTheCaret") + + Assert.Equal(expected, Array.head names) + [] let ``a batch of texts answers like the same texts one by one`` () = let cache = freshCache () From be1c4216dedc7952d23e265bd657fb3318beda03 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 20:20:42 +0200 Subject: [PATCH 14/29] Take the copied outlining work back out of this pull request Reverts the copies of #20443 that came in with an earlier rebase: "Slice source text instead of copying it line by line for outlining" and "Track comment lines by number instead of storing their text". They changed the public FSharp.Compiler.Service surface (Structure.getOutliningRanges took ReadOnlyMemory[]) and added a System.Memory reference that fails restore with NU1510 - neither belongs here, and #20443 carries that work on its own. The Copilot snippets go back to the string[] lines Structure.getOutliningRanges takes on main. Everything outside the Copilot files is now as on main. Co-Authored-By: Claude Opus 5 --- .../.FSharp.Compiler.Service/11.0.100.md | 1 - src/Compiler/Service/ServiceStructure.fs | 75 ++++++----- src/Compiler/Service/ServiceStructure.fsi | 3 +- src/Compiler/Utilities/illib.fs | 49 -------- src/Compiler/Utilities/illib.fsi | 42 ------- ...iler.Service.SurfaceArea.netstandard20.bsl | 2 +- .../FSharp.Compiler.Service.Tests.fsproj | 6 - .../StructureTests.fs | 117 +++++++++--------- .../src/FSharp.Editor/Common/Extensions.fs | 8 -- .../Copilot/CopilotContextProvider.fs | 3 +- .../Copilot/CopilotSymbolSnippets.fs | 6 +- .../Structure/BlockStructureService.fs | 2 +- 12 files changed, 103 insertions(+), 211 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index 1cc3b1bc0af..79543c63afb 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -228,4 +228,3 @@ * `FSharp.Compiler.Syntax.SynComponentInfo` now holds the type name as `synType: SynType option` instead of the previous `longId: LongIdent` field, so tuple-type extensions such as `type ('T1 * 'T2) with` can be represented. A `member LongIdent` compatibility property returns the long identifier for named types and an empty list for tuple or erroneous type names. AST consumers that pattern-matched on the `longId` field must switch to the `synType` field or the `LongIdent` member. ([PR #19602](https://github.com/dotnet/fsharp/pull/19602)) * Optimizer: don't inline named functions in debug builds ([PR #19548](https://github.com/dotnet/fsharp/pull/19548) * LexFilter: drop non-strict mode ([PR #20106](https://github.com/dotnet/fsharp/pull/20106)) -* `FSharp.Compiler.EditorServices.Structure.getOutliningRanges` now takes the source lines as `ReadOnlyMemory[]` instead of `string[]`, so a caller that already holds the whole text can slice it instead of building a string per line. Callers passing a `string[]` can migrate with `Array.map (fun line -> line.AsMemory())`. diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index 6937981ecec..99dd528eeb0 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -2,7 +2,6 @@ namespace FSharp.Compiler.EditorServices -open System open Internal.Utilities.Library open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTreeOps @@ -187,35 +186,27 @@ module Structure = } type LineNumber = int + type LineStr = string type CommentType = | SingleLine | XmlDoc - /// Determine if a line is a single line or xml documentation comment. - /// Kept at module scope: a local recursive function capturing a `ReadOnlySpan`-typed - /// helper as a closure field would need to instantiate `FSharpFunc, _>`, - /// which the CLR disallows for byref-like type arguments (FS0412). - let commentTypeOf (line: ReadOnlySpan) = - if line.StartsWithOrdinal("///") then ValueSome XmlDoc - elif line.StartsWithOrdinal("//") then ValueSome SingleLine - else ValueNone - [] type CommentList = { - Lines: ResizeArray + Lines: ResizeArray Type: CommentType } - static member New ty lineNum = + static member New ty lineStr = { Type = ty - Lines = ResizeArray [ lineNum ] + Lines = ResizeArray [ lineStr ] } /// Returns outlining ranges for given parsed input. - let getOutliningRanges (sourceLines: ReadOnlyMemory[]) (parsedInput: ParsedInput) = + let getOutliningRanges (sourceLines: string[]) (parsedInput: ParsedInput) = let acc = ResizeArray() /// Validation function to ensure that ranges yielded for outlining span 2 or more lines @@ -670,7 +661,7 @@ module Structure = | r :: rest, last :: _ when r.StartLine = last.EndLine + 1 || sourceLines[last.EndLine .. r.StartLine - 2] - |> Array.forall (fun line -> line.Span.IsWhiteSpace()) + |> Array.forall System.String.IsNullOrWhiteSpace -> loop rest res (r :: currentBulk) | r :: rest, _ -> loop rest (currentBulk :: res) [ r ] @@ -728,7 +719,7 @@ module Structure = let collectConditionalDirectives directives sourceLines = // Adds a fold region from prevRange.Start to the line above nextLine - let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: ReadOnlyMemory[]) = + let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: string array) = let startLineIndex = nextLine - 2 if startLineIndex >= 0 then @@ -762,7 +753,7 @@ module Structure = | ConditionalDirectiveTrivia.Else r -> ValueSome r | _ -> ValueNone - let rec group directives stack (sourceLines: ReadOnlyMemory[]) = + let rec group directives stack (sourceLines: string array) = match directives with | [] -> () | ConditionalDirectiveTrivia.If _ as ifDirective :: directives -> group directives (ifDirective :: stack) sourceLines @@ -831,29 +822,36 @@ module Structure = collectOpens decls List.iter parseDeclaration decls - let getCommentRanges trivia (lines: ReadOnlyMemory[]) = - let rec loop (lastLineNum, currentComment, result as state) lineNum = - if lineNum = lines.Length then - state - else - match commentTypeOf (lines[lineNum].Span.TrimStart()), currentComment with - | ValueSome commentType, Some comment -> + /// Determine if a line is a single line or xml documentation comment + let (|Comment|_|) (line: string) = + if line.StartsWithOrdinal("///") then Some XmlDoc + elif line.StartsWithOrdinal("//") then Some SingleLine + else None + + let getCommentRanges trivia (lines: string[]) = + let rec loop (lastLineNum, currentComment, result as state) (lines: string list) lineNum = + match lines with + | [] -> state + | lineStr :: rest -> + match lineStr.TrimStart(), currentComment with + | Comment commentType, Some comment -> loop (if comment.Type = commentType && lineNum = lastLineNum + 1 then - comment.Lines.Add lineNum + comment.Lines.Add(lineNum, lineStr) lineNum, currentComment, result else - let comments = CommentList.New commentType lineNum + let comments = CommentList.New commentType (lineNum, lineStr) lineNum, Some comments, comment :: result) + rest (lineNum + 1) - | ValueSome commentType, None -> - let comments = CommentList.New commentType lineNum - loop (lineNum, Some comments, result) (lineNum + 1) - | ValueNone, Some comment -> loop (lineNum, None, comment :: result) (lineNum + 1) - | ValueNone, None -> loop (lineNum, None, result) (lineNum + 1) + | Comment commentType, None -> + let comments = CommentList.New commentType (lineNum, lineStr) + loop (lineNum, Some comments, result) rest (lineNum + 1) + | _, Some comment -> loop (lineNum, None, comment :: result) rest (lineNum + 1) + | _ -> loop (lineNum, None, result) rest (lineNum + 1) let comments = - let _, lastComment, comments = loop (-1, None, []) 0 + let _, lastComment, comments = loop (-1, None, []) (List.ofArray lines) 0 match lastComment with | Some comment -> comment :: comments @@ -861,12 +859,13 @@ module Structure = |> List.rev comments - |> Seq.filter (fun comment -> comment.Lines.Count > 1) - |> Seq.map (fun comment -> - let startLine = comment.Lines[0] - let endLine = comment.Lines[comment.Lines.Count - 1] - let startCol = lines[startLine].Span.IndexOf '/' - let endCol = lines[endLine].Span.TrimEnd().Length + |> List.filter (fun comment -> comment.Lines.Count > 1) + |> List.map (fun comment -> + let lines = comment.Lines + let startLine, startStr = lines[0] + let endLine, endStr = lines[lines.Count - 1] + let startCol = startStr.IndexOf '/' + let endCol = endStr.TrimEnd().Length let scopeType = match comment.Type with diff --git a/src/Compiler/Service/ServiceStructure.fsi b/src/Compiler/Service/ServiceStructure.fsi index 3695e7148ac..87711629676 100644 --- a/src/Compiler/Service/ServiceStructure.fsi +++ b/src/Compiler/Service/ServiceStructure.fsi @@ -2,7 +2,6 @@ namespace FSharp.Compiler.EditorServices -open System open FSharp.Compiler.Syntax open FSharp.Compiler.Text @@ -80,4 +79,4 @@ module public Structure = } /// Returns outlining ranges for given parsed input. - val getOutliningRanges: sourceLines: ReadOnlyMemory[] -> parsedInput: ParsedInput -> seq + val getOutliningRanges: sourceLines: string[] -> parsedInput: ParsedInput -> seq diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index 9aefd2f787d..d6302776cb7 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -7,7 +7,6 @@ open System.Collections.Generic open System.Collections.Concurrent open System.Diagnostics open System.IO -open System.Linq open System.Threading open System.Threading.Tasks open System.Runtime.CompilerServices @@ -113,54 +112,6 @@ module internal PervasiveAutoOpens = member inline x.IndexOfOrdinal(value: string, startIndex, count) = x.IndexOf(value, startIndex, count, StringComparison.Ordinal) - [] - type ReadOnlySpanCharExtensions = - - static member inline StartsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = - str.StartsWith(value, StringComparison.Ordinal) - - static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = - str.StartsWith(value.AsSpan(), StringComparison.Ordinal) - - static member inline EndsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = - str.EndsWith(value, StringComparison.Ordinal) - - static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = - str.EndsWith(value.AsSpan(), StringComparison.Ordinal) - - static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: ReadOnlySpan) = - str.EndsWith(value, StringComparison.OrdinalIgnoreCase) - - static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: string) = - str.EndsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase) - - static member IndexOf(str: ReadOnlySpan, value: char) = - let mutable index = -1 - let mutable i = 0 - - while i < str.Length && index = -1 do - if str[i] = value then index <- i else i <- i + 1 - - index - - static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = - str.IndexOf(value, StringComparison.Ordinal) - - static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string) = - str.IndexOf(value.AsSpan(), StringComparison.Ordinal) - - static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex) = - str.Slice(startIndex).IndexOf(value, StringComparison.Ordinal) - - static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex) = - str.Slice(startIndex).IndexOf(value.AsSpan(), StringComparison.Ordinal) - - static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex, count) = - str.Slice(startIndex, count).IndexOf(value, StringComparison.Ordinal) - - static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex, count) = - str.Slice(startIndex, count).IndexOf(value.AsSpan(), StringComparison.Ordinal) - /// Get an initialization hole let getHole (r: _ ref) = match r.Value with diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index 9c1d7474ef9..bc04c2ca1ac 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -68,48 +68,6 @@ module internal PervasiveAutoOpens = member inline IndexOfOrdinal: value: string * startIndex: int * count: int -> int - [] - type ReadOnlySpanCharExtensions = - - [] - static member inline StartsWithOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> bool - - [] - static member inline StartsWithOrdinal: str : ReadOnlySpan * value: string -> bool - - [] - static member inline EndsWithOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> bool - - [] - static member inline EndsWithOrdinal: str : ReadOnlySpan * value: string -> bool - - [] - static member inline EndsWithOrdinalIgnoreCase: str : ReadOnlySpan * value: ReadOnlySpan -> bool - - [] - static member inline EndsWithOrdinalIgnoreCase: str : ReadOnlySpan * value: string -> bool - - [] - static member IndexOf: str : ReadOnlySpan * value: char -> int - - [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> int - - [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string -> int - - [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int -> int - - [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int -> int - - [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int * count: int -> int - - [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int * count: int -> int - type Async with /// Runs the computation synchronously, always starting on the current thread. diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index 7f4e7d14ec4..5c9c346b613 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl @@ -4744,7 +4744,7 @@ FSharp.Compiler.EditorServices.Structure+ScopeRange: Void .ctor(Scope, Collapse, FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Collapse FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Scope FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+ScopeRange -FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.ReadOnlyMemory`1[System.Char][], FSharp.Compiler.Syntax.ParsedInput) +FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.String[], FSharp.Compiler.Syntax.ParsedInput) FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String errorText FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String get_errorText() FSharp.Compiler.EditorServices.ToolTipElement+Group: Microsoft.FSharp.Collections.FSharpList`1[FSharp.Compiler.EditorServices.ToolTipElementData] elements diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj index 0183589a540..e043d8554ad 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj @@ -223,10 +223,4 @@ - - - - - diff --git a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index 322a0fa3bda..c0ae0d3fdff 100644 --- a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs @@ -1,6 +1,5 @@ module FSharp.Compiler.Service.Tests.StructureTests -open System open System.IO open Xunit open FSharp.Compiler.EditorServices.Structure @@ -36,7 +35,7 @@ let (=>) (source: string) (expectedRanges: (Range * Range) list) = let ast = parseSourceCode(fileName, source) try let actual = - getOutliningRanges (lines |> Array.map _.AsMemory()) ast + getOutliningRanges lines ast |> Seq.filter (fun sr -> sr.Range.StartLine <> sr.Range.EndLine) |> Seq.map (fun sr -> getRange sr.Range, getRange sr.CollapseRange) |> Seq.sort @@ -152,7 +151,7 @@ module MyModule = // 2 type Color = // 7 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 13 @@ -164,7 +163,7 @@ module MyModule = // 2 type RecordColor = // 19 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 25 @@ -190,31 +189,31 @@ module MyModule = // 2 [] let ``open statements``() = """ -open M -open N - -module M = - let x = 1 - - open M - open N - - module M = - open M - - let x = 1 - - module M = - open M - open N - let x = 1 - -open M -open N -open H - -open G -open H +open M +open N + +module M = + let x = 1 + + open M + open N + + module M = + open M + + let x = 1 + + module M = + open M + open N + let x = 1 + +open M +open N +open H + +open G +open H """ => [ (2, 0, 3, 6), (2, 0, 3, 6) (5, 0, 19, 17), (5, 8, 19, 17) @@ -227,28 +226,28 @@ open H [] let ``hash directives``() = """ -#r @"a" -#r "b" - -#r "c" - -#r "d" -#r "e" -let x = 1 - -#r "f" -#r "g" -#load "x" -#r "y" - -#load "a" - "b" - "c" - -#load "a" - "b" - "c" -#r "d" +#r @"a" +#r "b" + +#r "c" + +#r "d" +#r "e" +let x = 1 + +#r "f" +#r "g" +#load "x" +#r "y" + +#load "a" + "b" + "c" + +#load "a" + "b" + "c" +#r "d" """ => [ (2, 3, 8, 6), (2, 3, 8, 6) (11, 3, 23, 6), (11, 3, 23, 6) ] @@ -326,7 +325,7 @@ seq { // 2 [] let ``list``() = """ -let _ = +let _ = [ 1; 2 3 ] """ @@ -383,7 +382,7 @@ finally // 5 let ``if - then - else``() = """ if true then - let f x = + let f x = () () else @@ -449,7 +448,7 @@ for x = 100 downto 10 do [] let ``for each``() = """ -for x in 0 .. 100 -> +for x in 0 .. 100 -> () () """ @@ -468,7 +467,7 @@ let ``tuple``() = [] let ``do!``() = """ -do! +do! printfn "allo" printfn "allo" """ @@ -478,10 +477,10 @@ do! let ``cexpr yield yield!``() = """ cexpr{ - yield! + yield! cexpr{ - yield - + yield + 10 } } @@ -660,7 +659,7 @@ let ``Abstract members`` () = type T() = abstract Foo: int - + [] abstract Foo: int diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index 89185ebe556..f9695e68ecf 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -296,14 +296,6 @@ type SourceText with member this.ToFSharpSourceText() = SourceText.weakTable.GetValue(this, Runtime.CompilerServices.ConditionalWeakTable<_, _>.CreateValueCallback(SourceText.create)) - /// The lines of the text, as slices of a single string rather than one string per line. - member this.GetLinesAsMemory() = - let text = this.ToString() - - Array.init this.Lines.Count (fun i -> - let line = this.Lines[i] - text.AsMemory(line.Start, line.End - line.Start)) - type NavigationItem with member x.RoslynGlyph: FSharpRoslynGlyph = diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index b8ac3fa2c6d..cd866ae01d0 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -98,7 +98,8 @@ module internal CopilotSymbolQuery = let! ct = CancellableTask.getCancellationToken () let! sourceText = document.GetTextAsync ct let! parseResults = document.GetFSharpParseResultsAsync UserOpName - let sourceLines = sourceText.GetLinesAsMemory() + let sourceLines = + Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree |> Seq.toArray diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index eba3ce7742d..758f366448a 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -12,7 +12,7 @@ open FSharp.Compiler.EditorServices let MaxSnippetLines = 200 /// Inclusive, 1-based line bounds of the whole declaration `item` names, including its doc comment. -let declarationLines (sourceLines: ReadOnlyMemory array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = +let declarationLines (sourceLines: string array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = let declarationLine = item.Range.StartLine // A construct's outlining range reaches back over the doc comment in front of it, so it is the @@ -40,7 +40,7 @@ let declarationLines (sourceLines: ReadOnlyMemory array) (scopes: Structur // 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].Span.TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) + sourceLines[line - 1].AsSpan().TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) let rec docCommentStart line = if line > 1 && isDocComment (line - 1) then @@ -51,6 +51,6 @@ let declarationLines (sourceLines: ReadOnlyMemory array) (scopes: Structur struct (docCommentStart firstLine, lastLine) /// The lines of the declaration `item` names that a chat prompt carries. -let definitionLines (sourceLines: ReadOnlyMemory array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = +let definitionLines (sourceLines: string array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = let struct (firstLine, lastLine) = declarationLines sourceLines scopes item struct (firstLine, min lastLine (firstLine + MaxSnippetLines - 1)) diff --git a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs index b087cb56782..d0326d84311 100644 --- a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs +++ b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs @@ -119,7 +119,7 @@ module internal BlockStructure = let ellipsis = "..." let createBlockSpans isBlockStructureEnabled (sourceText: SourceText) (parsedInput: ParsedInput) = - let linetext = sourceText.GetLinesAsMemory() + let linetext = sourceText.Lines |> Seq.map (fun x -> x.ToString()) |> Seq.toArray Structure.getOutliningRanges linetext parsedInput |> Seq.distinctBy (fun x -> x.Range.StartLine) From cd491d3a5ef4de549481fd4b5a371d60dc104395 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 20:20:42 +0200 Subject: [PATCH 15/29] Keep double backticks in a mention's fully qualified name The mention input named a declaration by its container path and name joined with dots, so a value ``a.b`` in module M and the value b of M's nested module a were both "M.a.b": the picker showed one of them and resolving the mention gathered both. A name NavigateTo reports as needing backticks now keeps them, and so does the last segment of the container around it - the one segment of the path FCS hands over apart from the rest. hasFullyQualifiedName reads the same spelling without building the string. Still ambiguous: a dot in the name of a container further out, and in a file's top-level module, which NavigateTo names by its whole dotted path. Both reach the editor already joined. Co-Authored-By: Claude Opus 5 --- .../Copilot/CopilotSymbolMapping.fs | 74 +++++++++++++++---- .../CopilotContextProviderTests.fs | 28 +++++++ 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index f306617cd5a..474f9c30e40 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -9,6 +9,7 @@ open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.Imaging open FSharp.Compiler.EditorServices +open FSharp.Compiler.Syntax /// Name of the context member. It becomes the mention prefix the user sees and re-types, /// as in "#fsharpSymbol:Namespace.Type.Member". @@ -51,12 +52,32 @@ let private imageId kind = 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. +/// A name F# spells in double backticks keeps them: bare, a value ``a.b`` in module M and the value b of +/// M's nested module a would both be "M.a.b". A file's top-level module is left bare - NavigateTo names it +/// by its whole dotted path, joined, so its segments are not known apart. +let private isQuoted (item: NavigableItem) = + item.NeedsBackticks && not (item.Kind.IsModule && item.Container.Type.IsFile) + +/// FCS joins a container's path into one string, so of its segments only the last can be spelled. +let private isContainerQuoted (container: NavigableContainer) = + container.Name.Length > 0 + && not container.Type.IsFile + && PrettyNaming.DoesIdentifierNeedBackticks container.Name + +let private containerPath (container: NavigableContainer) = + if isContainerQuoted container then + let path = container.FullName + $"{path.Substring(0, path.Length - container.Name.Length)}``{container.Name}``" + else + container.FullName + +/// Dotted path that 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}" + let name = if isQuoted item then $"``{item.Name}``" else item.Name + + match containerPath item.Container with + | "" -> name + | container -> $"{container}.{name}" /// How Copilot's own tooltip names a declaration: a member by the container it is declared in, a type /// or module by itself. @@ -67,17 +88,44 @@ let tooltipName (item: NavigableItem) = | _, _, "" -> item.Name | _, _, container -> $"{container}.{item.Name}" +/// How much of `candidate` is left in front of `segment` - spelled in double backticks when `quoted` - +/// or -1 when the candidate does not end with it. +let private lengthBefore (candidate: ReadOnlySpan) (segment: string) quoted = + let ticks = if quoted then 2 else 0 + let length = segment.Length + 2 * ticks + + if candidate.Length < length then + -1 + else + let tail = candidate.Slice(candidate.Length - length) + + if + tail.Slice(ticks, segment.Length).Equals(segment.AsSpan(), StringComparison.Ordinal) + && (not quoted + || tail.StartsWith("``".AsSpan(), StringComparison.Ordinal) + && tail.EndsWith("``".AsSpan(), StringComparison.Ordinal)) + then + candidate.Length - length + else + -1 + /// Answers what comparing against `fullyQualifiedName` would, without building the dotted path - /// a solution-wide scan asks this of every declaration it walks past. let hasFullyQualifiedName (candidate: string) (item: NavigableItem) = let candidate = candidate.AsSpan() - let container = item.Container.FullName - let name = item.Name.AsSpan() + let container = item.Container + let path = container.FullName + let beforeName = lengthBefore candidate item.Name (isQuoted item) - if container.Length = 0 then - candidate.Equals(name, StringComparison.Ordinal) + if beforeName < 0 then + false + elif path.Length = 0 then + beforeName = 0 + elif beforeName = 0 || candidate[beforeName - 1] <> '.' then + false 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) + let spelledPath = candidate.Slice(0, beforeName - 1) + let enclosing = path.Length - container.Name.Length + + lengthBefore spelledPath container.Name (isContainerQuoted container) = enclosing + && spelledPath.Slice(0, enclosing).Equals(path.AsSpan(0, enclosing), StringComparison.Ordinal) diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index d6a878be27f..021a28c62ea 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -360,3 +360,31 @@ let twice x = x * 2 Assert.Equal(document.FilePath, location.FilePath) Assert.Equal(context.Snippet.Length, location.Span.Length) + + /// A value and a module whose names hold a dot, beside the nested paths that would spell the same + /// without the double backticks. + let private dottedNames = + "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 ``names that differ only in double backticks answer as two mentions`` () = + let names = + searchIn (freshCache ()) Seq.empty (solutionOf [ "C:\\dotted.fs", dottedNames ]) "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) = + let solution = solutionOf [ "C:\\dotted.fs", dottedNames ] + + match + CopilotSymbolQuery.symbolContext (freshCache ()) Seq.empty solution fullyQualifiedName + |> run + with + | ValueSome context -> Assert.Equal(declaration, context.Snippet.Trim()) + | ValueNone -> failwith $"expected a symbol context for {fullyQualifiedName}" From d9c3fdd394629ae13cb0a94e3d6ea6779231438b Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 15:49:57 +0200 Subject: [PATCH 16/29] Throttle through FSharp.Core's Task.parallelDoLimit CancellableTask is CancellationToken -> Task, the shape Task.parallelDoLimit takes, so the hand-rolled worker loop is one call. Co-Authored-By: Claude Opus 5 --- .../FSharp.Editor/Common/CancellableTasks.fs | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs index d699bdba0bf..9d1447cbb3e 100644 --- a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs +++ b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs @@ -1130,29 +1130,11 @@ module CancellableTasks = return! allTask } - /// Runs the work over the items with at most maxDegreeOfParallelism of them in flight. A worker - /// takes the next item when it frees up, so cancellation cancels the workers rather than a - /// pending task per item. + /// Runs the work over the items with at most maxDegreeOfParallelism of them in flight. let forEachThrottled maxDegreeOfParallelism (work: 'T -> CancellableTask) (items: 'T seq) = cancellableTask { let! ct = getCancellationToken () - let items = Seq.toArray items - let mutable next = -1 - - let worker () = - backgroundTask { - let mutable index = Interlocked.Increment &next - - while index < items.Length do - ct.ThrowIfCancellationRequested() - do! work items[index] ct - index <- Interlocked.Increment &next - } - - let workers = - Array.init (min (max 1 maxDegreeOfParallelism) items.Length) (fun _ -> worker ()) - - do! (Task.WhenAll workers :> Task) + return! Task.parallelDoLimit maxDegreeOfParallelism ct (Seq.map work items) } let inline whenAllTasks (tasks: CancellableTask seq) = From c3f3bb0b3d12d6de1898f1a0672f850f83ae1188 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 15:49:58 +0200 Subject: [PATCH 17/29] Format the Copilot context provider CheckCodeFormatting on this branch fails without it. Co-Authored-By: Claude Opus 5 --- .../src/FSharp.Editor/Copilot/CopilotContextProvider.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index cd866ae01d0..2c58ea2dedc 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -98,6 +98,7 @@ module internal CopilotSymbolQuery = 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()) From d38264d61dee20d47066c8ba1c953fda0fdb8424 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 16:52:28 +0200 Subject: [PATCH 18/29] Pipe items into Seq.map instead of applying it prefix-style Co-Authored-By: Claude Sonnet 5 --- vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs index 9d1447cbb3e..a8a9271f9b1 100644 --- a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs +++ b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs @@ -1134,7 +1134,7 @@ module CancellableTasks = let forEachThrottled maxDegreeOfParallelism (work: 'T -> CancellableTask) (items: 'T seq) = cancellableTask { let! ct = getCancellationToken () - return! Task.parallelDoLimit maxDegreeOfParallelism ct (Seq.map work items) + return! Task.parallelDoLimit maxDegreeOfParallelism ct (items |> Seq.map work) } let inline whenAllTasks (tasks: CancellableTask seq) = From 43b84a1a52c57de737c562c96ca6cccbe2266519 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 17:25:27 +0200 Subject: [PATCH 19/29] Walk the solution once for both the picker and a picked mention The mention search and the lookup of a picked name each carried their own copy of the open-cached-cold walk; scanTiers is the one walk, told when it has enough. Matching now runs outside the lock, a document's hits joining the shared lists in one step, and the focused file is outlined only when one of its declarations matched. Two fixes the reshuffle exposed. The search text is the input after the member name, not the last one: "#fsharpSymbol:Ns.Type:15" searched for "15". And focus counts only while the file is still open - the tracker is not told when its tab closes, so a closed file kept answering as focused. EditorFocus is a struct, so a caret move allocates nothing; the listener resolves the text document once and lets its subscriptions go with the view. Co-Authored-By: Claude Fable 5.1 --- .../Copilot/ActiveDocumentTracker.fs | 33 +- .../Copilot/CopilotContextProvider.fs | 398 ++++++++++-------- .../Copilot/CopilotSymbolMapping.fs | 44 +- .../CopilotContextProviderTests.fs | 274 ++++++------ 4 files changed, 423 insertions(+), 326 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs b/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs index 8ef15407940..fc77976f1f2 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs @@ -10,6 +10,7 @@ open Microsoft.VisualStudio.Utilities /// The file of the editor that last took focus, and the lines its caret or selection covers - 1-based /// and inclusive, as the parse tree counts them. +[] type internal EditorFocus = { FilePath: string @@ -42,7 +43,7 @@ type internal FSharpActiveDocumentListener (tracker: FSharpActiveDocumentTracker, textDocumentFactory: ITextDocumentFactoryService) = static let lineOf (point: SnapshotPoint) = - point.GetContainingLine().LineNumber + 1 + point.Snapshot.GetLineNumberFromPosition point.Position + 1 /// A selection ends before its End point: one of whole lines ends at the start of the line after them. static let linesOf (textView: ITextView) = @@ -57,9 +58,10 @@ type internal FSharpActiveDocumentListener interface IWpfTextViewCreationListener with member _.TextViewCreated(textView: IWpfTextView) = - let recordFocus () = - match textDocumentFactory.TryGetTextDocument textView.TextBuffer with - | true, document -> + match textDocumentFactory.TryGetTextDocument textView.TextBuffer with + | false, _ -> () + | true, document -> + let recordFocus () = let struct (firstLine, lastLine) = linesOf textView tracker.SetFocus @@ -68,13 +70,20 @@ type internal FSharpActiveDocumentListener FirstLine = firstLine LastLine = lastLine } - | _ -> () - let recordWhileFocused () = - if textView.HasAggregateFocus then - recordFocus () + let recordWhileFocused () = + if textView.HasAggregateFocus then + recordFocus () - textView.GotAggregateFocus.Add(fun _ -> recordFocus ()) - textView.Caret.PositionChanged.Add(fun _ -> recordWhileFocused ()) - textView.Selection.SelectionChanged.Add(fun _ -> recordWhileFocused ()) - recordWhileFocused () + let subscriptions = + [ + textView.GotAggregateFocus.Subscribe(fun _ -> recordFocus ()) + textView.Caret.PositionChanged.Subscribe(fun _ -> recordWhileFocused ()) + textView.Selection.SelectionChanged.Subscribe(fun _ -> recordWhileFocused ()) + ] + + textView.Closed.Add(fun _ -> + for subscription in subscriptions do + subscription.Dispose()) + + recordWhileFocused () diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 2c58ea2dedc..16d5561a2d3 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -23,9 +23,9 @@ open Microsoft.VisualStudio.Text.PatternMatching open FSharp.Compiler.EditorServices open CancellableTasks -/// Where a declaration sits relative to what the user is working on. The picker merges answers from every -/// provider and ranks them by the priority each one reports, so a match has to say where it sits rather -/// than rely on its position. The tiers mirror what Copilot's own symbol provider reports for C#. +/// Where a declaration sits relative to what the user is working on, best first. The picker merges +/// answers from every provider and ranks them by the priority each one reports, so a match has to say +/// where it sits rather than rely on its position. [] type internal DocumentFocus = /// Declared around the caret or selection of the focused file. @@ -39,7 +39,7 @@ type internal DocumentFocus = module internal CopilotSymbolQuery = /// Also the point at which the search stops parsing files nobody has opened, so it bounds the cold - /// scan as much as the answer. Copilot's own provider reads an index and can afford a far larger cap. + /// scan as much as the answer. [] let private MaxMentions = 20 @@ -53,10 +53,10 @@ module internal CopilotSymbolQuery = /// How long a query keeps parsing files nobody has opened yet. Copilot cancels on its own schedule /// and takes no partial results, so an answer from what is already parsed beats a complete answer. [] - let private ColdSearchBudgetMs = 1500 + let private ColdSearchBudgetMs = 1500L /// A shorter text matches too much of the solution to be worth parsing it for, so it is looked up in the - /// files the user has open - which is what Copilot's own provider does for C#. + /// files the user has open. [] let private MinSolutionWideSearchLength = 3 @@ -69,13 +69,12 @@ module internal CopilotSymbolQuery = | searchText -> cache.CreateMatcherFor searchText let private fsharpDocuments (solution: Solution) = - solution.Projects - |> Seq.where (fun project -> project.Language = FSharpConstants.FSharpLanguageName) - |> Seq.collect _.Documents + solution.Projects |> Seq.where _.IsFSharp |> Seq.collect _.Documents let private isFocused (focus: EditorFocus) (document: Document) = String.Equals(focus.FilePath, document.FilePath, StringComparison.OrdinalIgnoreCase) + /// Focus counts only while the file is still open: the tracker is not told when its tab closes. let private focusOf (focus: EditorFocus voption) (openIds: HashSet) @@ -83,16 +82,33 @@ module internal CopilotSymbolQuery = (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 - | ValueSome focus when isFocused focus document -> - if isSelected item then - DocumentFocus.Selected - else - DocumentFocus.Focused - | _ when openIds.Contains document.Id -> DocumentFocus.Open - | _ -> DocumentFocus.Elsewhere + | DocumentFocus.Selected -> 0 + | DocumentFocus.Focused -> 1 + | DocumentFocus.Open -> 2 + | DocumentFocus.Elsewhere -> 3 + + /// The source of a document and the outlining of its declarations. + [] + type private Outline = + { + Text: SourceText + Lines: string array + Scopes: Structure.ScopeRange array + } - /// The source of `document` and the outlining of its declarations. let private outlineOf (document: Document) = cancellableTask { let! ct = CancellableTask.getCancellationToken () @@ -102,31 +118,28 @@ module internal CopilotSymbolQuery = let sourceLines = Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) - let scopes = - Structure.getOutliningRanges sourceLines parseResults.ParseTree |> Seq.toArray - - return struct (sourceText, sourceLines, scopes) + return + { + Text = sourceText + Lines = sourceLines + Scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree |> Seq.toArray + } } - /// Whether a declaration of the focused file spans a line the caret or selection is on - the whole + let private notSelected (_: NavigableItem) = false + + /// Whether a declaration of the focused document spans a line the caret or selection is on - the whole /// declaration, so the caret in a member's body selects the member, its type and the modules around them. - let private selectionIn (focus: EditorFocus voption) (opened: Document seq) = + let private selectionIn (focus: EditorFocus) (document: Document) = cancellableTask { - let focused = - focus - |> ValueOption.bind (fun focus -> opened |> Seq.tryFindV (isFocused focus)) - - match focus, focused with - | ValueSome focus, ValueSome document -> - let! struct (_, sourceLines, scopes) = outlineOf document + let! outline = outlineOf document - return - fun (item: NavigableItem) -> - let struct (firstLine, lastLine) = - CopilotSymbolSnippets.declarationLines sourceLines scopes item + return + fun (item: NavigableItem) -> + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.declarationLines outline.Lines outline.Scopes item - firstLine <= focus.LastLine && focus.FirstLine <= lastLine - | _ -> return fun (_: NavigableItem) -> false + firstLine <= focus.LastLine && focus.FirstLine <= lastLine } /// The documents in the order a query visits them: the ones the user has open, the ones already @@ -146,86 +159,161 @@ module internal CopilotSymbolQuery = struct (opened, cached, cold) - /// Declarations whose fully qualified name matches each search text, best match first, one entry - /// per name. Every document is visited once for all of the texts. - let search + /// Visits the documents tier by tier until `enough` answers, parsing the cold ones for at most + /// `budgetMs`. The budget stops handing out documents rather than cancelling a parse under way: + /// the first query of a session has to survive its first parse to answer at all. + let private scanTiers (cache: FSharpNavigableItemsCache) - (openDocumentIds: DocumentId seq) - (focus: EditorFocus voption) + (openIds: HashSet) (solution: Solution) - (searchTexts: string[]) + (budgetMs: int64) + (enough: unit -> bool) + (collect: Document -> NavigableItem array -> unit) = cancellableTask { let! ct = CancellableTask.getCancellationToken () - let openIds = HashSet openDocumentIds - let matchers = searchTexts |> Array.map (matcherFor cache) - - let openFilesOnly = - searchTexts |> Array.map (fun text -> text.Length < MinSolutionWideSearchLength) - - let hits = Array.init searchTexts.Length (fun _ -> ResizeArray()) - let found = Array.init searchTexts.Length (fun _ -> HashSet StringComparer.Ordinal) - - let collect isOpen (document: Document) (items: NavigableItem array) = - lock hits (fun () -> - for index in 0 .. matchers.Length - 1 do - if isOpen || not openFilesOnly[index] then - let tryMatch = matchers[index] - - for item in items do - match tryMatch item with - | ValueSome patternMatch -> - hits[index].Add(struct (patternMatch.Kind, item, document)) - - found[index].Add(CopilotSymbolMapping.fullyQualifiedName item) |> ignore - | ValueNone -> ()) - - let enough () = - lock hits (fun () -> - Seq.forall2 (fun (names: HashSet) onlyOpen -> onlyOpen || names.Count >= MaxMentions) found openFilesOnly) + let struct (opened, cached, cold) = tiers cache openIds solution - let parseAndCollect isOpen (document: Document) = + let parseAndCollect (document: Document) = cancellableTask { let! items = cache.GetNavigableItems document - collect isOpen document items + collect document items } - let struct (opened, cached, cold) = tiers cache openIds solution - - do! opened |> CancellableTask.forEachThrottled parallelism (parseAndCollect true) + do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect if not (enough ()) then for struct (document, items) in cached do ct.ThrowIfCancellationRequested() - collect false document items + collect document items if not (enough ()) then let budget = Stopwatch.StartNew() - // The budget stops handing out documents rather than cancelling a parse under way: - // the first query of a session has to survive its first parse to answer at all. let scan (document: Document) = cancellableTask { - if budget.ElapsedMilliseconds < ColdSearchBudgetMs && not (enough ()) then - do! parseAndCollect false document + if budget.ElapsedMilliseconds < budgetMs && not (enough ()) then + do! parseAndCollect document } do! cold |> CancellableTask.forEachThrottled parallelism scan + } + + [] + type private Hit = + { + Kind: PatternMatchKind + Item: NavigableItem + Document: Document + Name: string + } + + let private compareRanked (struct (rank1: int, hit1: Hit)) (struct (rank2: int, hit2: Hit)) = + match compare rank1 rank2 with + | 0 -> + match compare (int hit1.Kind) (int hit2.Kind) with + | 0 -> compare hit1.Item.Name.Length hit2.Item.Name.Length + | order -> order + | order -> order + + /// Declarations whose fully qualified name matches each search text, best match first, one entry + /// per name. Every document is visited once for all of the texts. + let search + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (focus: EditorFocus voption) + (solution: Solution) + (searchTexts: string[]) + = + cancellableTask { + let openIds = HashSet openDocumentIds + + let queries = + searchTexts + |> Array.map (fun text -> + struct {| + TryMatch = matcherFor cache text + SolutionWide = text.Length >= MinSolutionWideSearchLength + Hits = ResizeArray() + Names = HashSet StringComparer.Ordinal + |}) + + let solutionWide = queries |> Array.filter _.SolutionWide + let gate = obj () + let mutable focusedDocument = ValueNone + + // Matching runs outside the lock; a document's hits join the shared lists in one step. + let collect (document: Document) (items: NavigableItem array) = + let queries = + if openIds.Contains document.Id then + queries + else + solutionWide + + let matched = + queries + |> Array.map (fun query -> + let hits = ResizeArray() + + for item in items do + match query.TryMatch item with + | ValueSome patternMatch -> + hits.Add + { + Kind = patternMatch.Kind + Item = item + Document = document + Name = CopilotSymbolMapping.fullyQualifiedName item + } + | ValueNone -> () + + struct (query, hits)) + + lock gate (fun () -> + for struct (query, hits) in matched do + query.Hits.AddRange hits + + if query.SolutionWide then + for hit in hits do + if query.Names.Count < MaxMentions then + query.Names.Add hit.Name |> ignore + + match focus with + | ValueSome focus when + isFocused focus document + && matched |> Array.exists (fun (struct (_, hits)) -> hits.Count > 0) + -> + focusedDocument <- ValueSome document + | _ -> ()) + + let enough () = + lock gate (fun () -> solutionWide |> Array.forall (fun query -> query.Names.Count >= MaxMentions)) + + do! scanTiers cache openIds solution ColdSearchBudgetMs enough collect - let! isSelected = selectionIn focus opened + let! isSelected = + match focus, focusedDocument with + | ValueSome focus, ValueSome document -> selectionIn focus document + | _ -> CancellableTask.singleton notSelected return - hits - |> Array.map ( - Seq.map (fun (struct (kind, item, document)) -> - struct (focusOf focus openIds isSelected document item, kind, item, document)) - >> Seq.sortBy (fun (struct (focus, kind, item: NavigableItem, document: Document)) -> - focus, document.IsFSharpSignatureFile, kind, item.Name.Length) - >> Seq.distinctBy (fun (struct (_, _, item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) - >> Seq.truncate MaxMentions - >> Seq.map (fun (struct (focus, _, item, document)) -> struct (item, document, focus)) - >> Seq.toArray - ) + queries + |> Array.map (fun query -> + let seen = HashSet StringComparer.Ordinal + let mentions = ResizeArray MaxMentions + + let ranked = + query.Hits + |> Seq.map (fun hit -> + let focus = focusOf focus openIds isSelected hit.Document hit.Item + struct (rankOf focus * 2 + (if hit.Document.IsFSharpSignatureFile then 1 else 0), hit), focus) + |> Seq.sortWith (fun (ranked1, _) (ranked2, _) -> compareRanked ranked1 ranked2) + + for struct (_, hit), focus in ranked do + if mentions.Count < MaxMentions && seen.Add hit.Name then + mentions.Add(struct (hit.Item, hit.Document, focus)) + + mentions.ToArray()) } /// Declarations carrying exactly this fully qualified name. Signature files answer only when no @@ -237,73 +325,52 @@ module internal CopilotSymbolQuery = (fullyQualifiedName: string) = cancellableTask { - let! ct = CancellableTask.getCancellationToken () let hits = ResizeArray() + let mutable declaredInImplementation = false let collect (document: Document) (items: NavigableItem array) = - lock hits (fun () -> - for item in items do - if CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName item then - hits.Add(struct (item, document))) - - let declaredInImplementation () = - lock hits (fun () -> - hits - |> Seq.exists (fun (struct (_, document: Document)) -> not document.IsFSharpSignatureFile)) - - let parseAndCollect (document: Document) = - cancellableTask { - let! items = cache.GetNavigableItems document - collect document items - } - - let struct (opened, cached, cold) = tiers cache (HashSet openDocumentIds) solution + let declared = + items + |> Array.filter (CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName) - do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect + if declared.Length > 0 then + lock hits (fun () -> + for item in declared do + hits.Add(struct (item, document)) - if not (declaredInImplementation ()) then - for struct (document, items) in cached do - ct.ThrowIfCancellationRequested() - collect document items + if not document.IsFSharpSignatureFile then + declaredInImplementation <- true) - if not (declaredInImplementation ()) then - let scan (document: Document) = - cancellableTask { - if not (declaredInImplementation ()) then - do! parseAndCollect document - } + let enough () = + lock hits (fun () -> declaredInImplementation) - do! cold |> CancellableTask.forEachThrottled parallelism scan + do! scanTiers cache (HashSet openDocumentIds) solution Int64.MaxValue enough collect let implementations = hits |> Seq.filter (fun (struct (_, document: Document)) -> not document.IsFSharpSignatureFile) + |> Seq.truncate MaxDeclarations + |> Seq.toArray - let preferred = - if Seq.isEmpty implementations then - hits :> _ seq - else - implementations - - return preferred |> Seq.truncate MaxDeclarations |> Seq.toArray + return + match implementations with + | [||] -> hits |> Seq.truncate MaxDeclarations |> Seq.toArray + | implementations -> implementations } /// The source of the whole declaration `item` names, together with the span it occupies. - let snippetOf (item: NavigableItem) (document: Document) = - cancellableTask { - let! struct (sourceText, sourceLines, scopes) = outlineOf document + let private snippetOf (outline: Outline) (item: NavigableItem) = + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.definitionLines outline.Lines outline.Scopes item - let struct (firstLine, lastLine) = - CopilotSymbolSnippets.definitionLines sourceLines scopes item + let text = outline.Text + let firstLine = max 1 firstLine + let lastLine = min text.Lines.Count lastLine - let firstLine = max 1 firstLine - let lastLine = min sourceText.Lines.Count lastLine + let span = + TextSpan.FromBounds(text.Lines[firstLine - 1].Start, text.Lines[lastLine - 1].End) - let span = - TextSpan.FromBounds(sourceText.Lines[firstLine - 1].Start, sourceText.Lines[lastLine - 1].End) - - return struct (sourceText.GetSubText(span).ToString(), span) - } + struct (text.ToString span, span) let symbolContext (cache: FSharpNavigableItemsCache) @@ -319,9 +386,17 @@ module internal CopilotSymbolQuery = | ValueSome(struct (first, _)) -> let snippets = ResizeArray() let locations = ResizeArray() + let mutable outlined: struct (DocumentId * Outline) voption = ValueNone + + // Overloads and partial definitions of one name mostly share a file: outline it once. + for struct (item, document: Document) in declarations do + let! outline = + match outlined with + | ValueSome(struct (id, outline)) when id = document.Id -> CancellableTask.singleton outline + | _ -> outlineOf document - for struct (item, document) in declarations do - let! struct (text, span) = snippetOf item 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))) @@ -384,15 +459,10 @@ type internal FSharpCopilotContextProvider | DocumentFocus.Elsewhere -> CopilotQueriedMentionPriority.None let mentionFor (item: NavigableItem) (document: Document) focus = - let inputs = - Dictionary( - dict - [ - CopilotSymbolMapping.FullyQualifiedNameInput, - CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item) - ], - StringComparer.Ordinal - ) + let inputs = Dictionary(1, StringComparer.Ordinal) + + inputs[CopilotSymbolMapping.FullyQualifiedNameInput] <- + CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item) let fileName = Path.GetFileName document.FilePath @@ -417,30 +487,14 @@ type internal FSharpCopilotContextProvider ) :> 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". The picker asks - /// before it has resolved what kind of mention is being typed, which Copilot's own provider answers - /// as readily as a resolved one. - let searchTextOf (query: CopilotMentionQuery) = - match query.Type, query.Inputs with - | (CopilotMentionType.Context | CopilotMentionType.Unknown), null -> ValueNone - | (CopilotMentionType.Context | CopilotMentionType.Unknown), inputs when inputs.Count > 0 -> - match inputs[inputs.Count - 1] with - | null -> ValueSome "" - | text when String.Equals(text, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) -> ValueNone - | text -> ValueSome(text.Trim()) - | _ -> ValueNone - /// One pass over the solution for the whole batch: Copilot's picker asks for several texts at once /// and each of them would otherwise walk the same documents. let mentionsFor (searchTexts: string voption[]) = cancellableTask { - let distinct = searchTexts |> Seq.chooseV id |> Seq.distinct |> Seq.toArray - - match workspace with - | null -> return searchTexts |> Array.map (fun _ -> noMentions) - | _ when Array.isEmpty distinct -> return searchTexts |> Array.map (fun _ -> noMentions) - | workspace -> + match workspace, searchTexts |> Seq.chooseV id |> Seq.distinct |> Seq.toArray with + | null, _ + | _, [||] -> return Array.create searchTexts.Length noMentions + | workspace, distinct -> let! hits = CopilotSymbolQuery.search cache (workspace.GetOpenDocumentIds()) activeDocument.Focus workspace.CurrentSolution distinct @@ -508,7 +562,7 @@ type internal FSharpCopilotContextProvider interface ICopilotMentionQueryable with member _.QueryMentionAsync(query, cancellationToken) : Task> = - mentionsFor [| searchTextOf query |] + mentionsFor [| CopilotSymbolMapping.searchTextOf query |] |> CancellableTask.map Array.head |> CancellableTask.start cancellationToken @@ -543,6 +597,6 @@ type internal FSharpCopilotContextProvider // Copilot's own picker providers answer through the batch interface, one result collection per query. interface ICopilotMentionBatchQueryable with member _.QueryMentionBatchAsync(queries, cancellationToken) : Task>> = - mentionsFor (queries |> Seq.map searchTextOf |> Seq.toArray) + 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 index 474f9c30e40..5547d6fb85a 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -4,6 +4,7 @@ module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolMapping open System +open System.Collections.Generic open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.Imaging @@ -65,19 +66,24 @@ let private isContainerQuoted (container: NavigableContainer) = && PrettyNaming.DoesIdentifierNeedBackticks container.Name let private containerPath (container: NavigableContainer) = + let path = container.FullName + if isContainerQuoted container then - let path = container.FullName - $"{path.Substring(0, path.Length - container.Name.Length)}``{container.Name}``" + String.Concat(path.Substring(0, path.Length - container.Name.Length), "``", container.Name, "``") else - container.FullName + path /// Dotted path that identifies a picked mention when it is resolved back to source. let fullyQualifiedName (item: NavigableItem) = - let name = if isQuoted item then $"``{item.Name}``" else item.Name + let name = + if isQuoted item then + String.Concat("``", item.Name, "``") + else + item.Name match containerPath item.Container with | "" -> name - | container -> $"{container}.{name}" + | container -> String.Concat(container, ".", name) /// How Copilot's own tooltip names a declaration: a member by the container it is declared in, a type /// or module by itself. @@ -91,8 +97,8 @@ let tooltipName (item: NavigableItem) = /// How much of `candidate` is left in front of `segment` - spelled in double backticks when `quoted` - /// or -1 when the candidate does not end with it. let private lengthBefore (candidate: ReadOnlySpan) (segment: string) quoted = - let ticks = if quoted then 2 else 0 - let length = segment.Length + 2 * ticks + let quotes = if quoted then 2 else 0 + let length = segment.Length + quotes * 2 if candidate.Length < length then -1 @@ -100,7 +106,7 @@ let private lengthBefore (candidate: ReadOnlySpan) (segment: string) quote let tail = candidate.Slice(candidate.Length - length) if - tail.Slice(ticks, segment.Length).Equals(segment.AsSpan(), StringComparison.Ordinal) + tail.Slice(quotes, segment.Length).Equals(segment.AsSpan(), StringComparison.Ordinal) && (not quoted || tail.StartsWith("``".AsSpan(), StringComparison.Ordinal) && tail.EndsWith("``".AsSpan(), StringComparison.Ordinal)) @@ -129,3 +135,25 @@ let hasFullyQualifiedName (candidate: string) (item: NavigableItem) = lengthBefore spelledPath container.Name (isContainerQuoted container) = enclosing && spelledPath.Slice(0, enclosing).Equals(path.AsSpan(0, enclosing), StringComparison.Ordinal) + +/// The text a picker query searches for. The inputs are positional: the member name first once the +/// mention has been committed, as in "#fsharpSymbol:Namespace.Type", then the search text, then +/// qualifiers the search ignores. The picker asks before it has resolved what kind of mention is typed. +let searchTextOf (query: CopilotMentionQuery) = + let searchTextAt index (inputs: IReadOnlyList) = + if index < inputs.Count then + ValueSome( + match inputs[index] with + | null -> "" + | text -> text.Trim() + ) + else + ValueNone + + match query.Type, query.Inputs with + | (CopilotMentionType.Context | CopilotMentionType.Unknown), inputs when inputs.Count > 0 -> + if String.Equals(inputs[0], SymbolMember, StringComparison.Ordinal) then + searchTextAt 1 inputs + else + searchTextAt 0 inputs + | _ -> ValueNone diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index 021a28c62ea..e1fad5b19df 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -3,7 +3,6 @@ namespace FSharp.Editor.Tests open System -open System.Threading open Xunit @@ -45,21 +44,50 @@ let twice x = x * 2 let solution = RoslynTestHelpers.CreateSolution fileContents - let private cache = + /// One matcher and one parse cache per test, so what a test leaves parsed cannot answer the next one. + let private freshCache () = MefHelpers.createExportProvider().GetExportedValue() + let private cache = freshCache () + let private run computation = - computation |> CancellableTask.start CancellationToken.None |> _.Result + CancellableTask.runSynchronouslyWithoutCancellation computation + + let private solutionOf files = + let projectId = ProjectId.CreateNewId() + + let documents = + files + |> List.map (fun (path, source) -> RoslynTestHelpers.CreateDocumentInfo projectId path source) + + let solution = + RoslynTestHelpers.CreateSolution [ RoslynTestHelpers.CreateProjectInfo projectId "C:\\many.fsproj" documents ] + + { RoslynTestHelpers.DefaultProjectOptions with + SourceFiles = files |> List.map fst |> Array.ofList + } + |> RoslynTestHelpers.SetProjectOptions projectId solution + + solution + + let private documentsOf (solution: Solution) = + solution.Projects |> Seq.exactlyOne |> _.Documents |> Seq.toArray + + let private documentNamed (name: string) solution = + documentsOf solution + |> Array.find _.FilePath.EndsWith(name, StringComparison.Ordinal) + + let private hitsIn cache openDocumentIds focus solution pattern = + CopilotSymbolQuery.search cache openDocumentIds focus solution [| pattern |] + |> run + |> Array.head let private namesOf hits = hits |> Array.map (fun (struct (item, _, _)) -> CopilotSymbolMapping.fullyQualifiedName item) let private searchFocused cache openDocumentIds focus solution pattern = - CopilotSymbolQuery.search cache openDocumentIds focus solution [| pattern |] - |> run - |> Array.head - |> namesOf + hitsIn cache openDocumentIds focus solution pattern |> namesOf let private searchIn cache openDocumentIds solution pattern = searchFocused cache openDocumentIds ValueNone solution pattern @@ -68,22 +96,54 @@ let twice x = x * 2 searchIn cache Seq.empty solution pattern let private itemNamed (fullyQualifiedName: string) = - CopilotSymbolQuery.search cache Seq.empty ValueNone solution [| fullyQualifiedName |] - |> run - |> Array.head - |> Array.pick (fun (struct (item, _, _)) -> + hitsIn cache Seq.empty ValueNone solution fullyQualifiedName + |> Array.tryPickV (fun (struct (item, _, _)) -> if CopilotSymbolMapping.fullyQualifiedName item = fullyQualifiedName then - Some item + ValueSome item else - None) + ValueNone) + |> ValueOption.defaultWith (fun () -> failwith $"no declaration named {fullyQualifiedName}") - let private symbolContext name = - CopilotSymbolQuery.symbolContext cache Seq.empty solution name |> run + let private contextIn cache solution (name: string) = + CopilotSymbolQuery.symbolContext cache Seq.empty solution name + |> run + |> ValueOption.defaultWith (fun () -> failwith $"expected a symbol context for {name}") - let private contextOf name = - match symbolContext name with - | ValueSome context -> context - | ValueNone -> failwith $"expected a symbol context for {name}" + let private contextOf name = contextIn cache solution name + + /// A file holding more declarations matching `name` than one query reports. + let private manyDeclarations name count = + let members = + [ for i in 1..count -> $" member _.{name}{i} = {i}" ] |> String.concat "\n" + + $"module {name}Module\n\ntype {name}Holder() =\n{members}\n" + + let private coldFile = "C:\\cold.fs", "module Cold\n\nlet widgetCounter = 1\n" + + /// The holder's declaration loses on every other part of the ordering - the name it is matched + /// against is longer - so it can only come first through where its file sits. + let private twoWidgets = + [ + "C:\\elsewhere.fs", "module Elsewhere\n\ntype Widget() =\n member _.Value = 1\n" + "C:\\holder.fs", "module Holder\n\ntype WidgetHolder() =\n member _.Value = 2\n" + ] + + /// A value and a module whose names hold a dot, beside the nested paths that would spell the same + /// without the double backticks. + let private dottedSolution = + solutionOf + [ + "C:\\dotted.fs", + "module M\n\nlet ``a.b`` = 1\n\nmodule a =\n let b = 2\n\nmodule ``x.y`` =\n let z = 3\n\nmodule x =\n module y =\n let z = 4\n" + ] + + let private caretOn filePath line = + ValueSome + { + FilePath = filePath + FirstLine = line + LastLine = line + } [] [] @@ -111,6 +171,25 @@ let twice x = x * 2 let ``a tooltip names a member by its container and a type by itself`` (fullyQualifiedName: string, expected: string) = Assert.Equal(expected, CopilotSymbolMapping.tooltipName (itemNamed fullyQualifiedName)) + /// The inputs of a query are split on ':', so "#fsharpSymbol:Ns.Type:15" arrives as three of them. + [] + [] + [] + [] + [] + [] + [] + let ``the search text is the input after the member name`` (inputs: string, expected: string) = + let query = CopilotMentionQuery(CopilotMentionType.Unknown, inputs.Split ':') + + Assert.Equal(ValueOption.ofObj expected, CopilotSymbolMapping.searchTextOf query) + + [] + let ``only context mentions are searched for`` () = + let query = CopilotMentionQuery(CopilotMentionType.Command, [| "Widget" |]) + + Assert.True((CopilotSymbolMapping.searchTextOf query).IsNone) + [] let ``search reports each declaration once`` () = let names = search "Counter" @@ -118,74 +197,31 @@ let twice x = x * 2 [] let ``an unknown name has no context`` () = - Assert.True((symbolContext "Widgets.NoSuchThing").IsNone) - - /// One matcher and one parse cache per test, so what a test leaves parsed cannot answer the next one. - let private freshCache () = - MefHelpers.createExportProvider().GetExportedValue() - - let private solutionOf files = - let projectId = ProjectId.CreateNewId() - - let documents = - files - |> List.map (fun (path, source) -> RoslynTestHelpers.CreateDocumentInfo projectId path source) - - let solution = - RoslynTestHelpers.CreateSolution [ RoslynTestHelpers.CreateProjectInfo projectId "C:\\many.fsproj" documents ] - - { RoslynTestHelpers.DefaultProjectOptions with - SourceFiles = files |> List.map fst |> Array.ofList - } - |> RoslynTestHelpers.SetProjectOptions projectId solution - - solution - - let private documentsOf (solution: Solution) = - solution.Projects |> Seq.exactlyOne |> _.Documents |> Seq.toArray - - let private documentNamed (name: string) solution = - documentsOf solution - |> Array.find _.FilePath.EndsWith(name, StringComparison.Ordinal) - - /// A file holding more declarations matching `name` than one query reports. - let private manyDeclarations name count = - let members = - [ for i in 1..count -> $" member _.{name}{i} = {i}" ] |> String.concat "\n" - - $"module {name}Module\n\ntype {name}Holder() =\n{members}\n" - - let private coldFile = "C:\\cold.fs", "module Cold\n\nlet widgetCounter = 1\n" - - let private caretOn filePath line = - ValueSome - { - FilePath = filePath - FirstLine = line - LastLine = line - } + Assert.True( + (CopilotSymbolQuery.symbolContext cache Seq.empty solution "Widgets.NoSuchThing" + |> run) + .IsNone + ) - [] - let ``an open document answers without parsing the rest of the solution`` () = + [] + [] + [] + let ``a known file answers without parsing the rest of the solution`` (isOpen: bool) = let cache = freshCache () - let solution = solutionOf [ "C:\\open.fs", manyDeclarations "Widget" 25; coldFile ] - let opened = documentNamed "open.fs" solution + let solution = solutionOf [ "C:\\known.fs", manyDeclarations "Widget" 25; coldFile ] + let known = documentNamed "known.fs" solution - let names = searchIn cache [ opened.Id ] solution "Widget" - - Assert.Equal(20, names.Length) - Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal)) - Assert.True((cache.TryGetCachedNavigableItems (documentNamed "cold.fs" solution).Id).IsNone) - - [] - let ``documents already parsed answer without parsing the rest`` () = - let cache = freshCache () - let solution = solutionOf [ "C:\\warm.fs", manyDeclarations "Widget" 25; coldFile ] - cache.GetNavigableItems(documentNamed "warm.fs" solution) |> run |> ignore + let openDocumentIds = + if isOpen then + [ known.Id ] + else + cache.GetNavigableItems known |> run |> ignore + [] - let names = searchIn cache Seq.empty solution "Widget" + let names = searchIn cache openDocumentIds solution "Widget" Assert.Equal(20, names.Length) + Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal)) Assert.True((cache.TryGetCachedNavigableItems (documentNamed "cold.fs" solution).Id).IsNone) [] @@ -207,28 +243,31 @@ let twice x = x * 2 |> Array.filter (fun document -> (cache.TryGetCachedNavigableItems document.Id).IsNone) ) - /// The declaration in the open file loses on every other part of the ordering - the name it is - /// matched against is longer - so it can only come first by being the file the user has open. + /// Focus outranks being open, which outranks the rest; a focused file that is no longer open counts + /// for nothing, since nothing tells the tracker that its tab has closed. [] - [] - [] - let ``an open file answers before the rest`` (holderIsOpen: bool) (expected: string) = + [] + [] + [] + [] + let ``the file the user works in answers first`` (holderIsOpen: bool, holderIsFocused: bool, expected: string) = let cache = freshCache () - - let solution = - solutionOf - [ - "C:\\elsewhere.fs", "module Elsewhere\n\ntype Widget() =\n member _.Value = 1\n" - "C:\\holder.fs", "module Holder\n\ntype WidgetHolder() =\n member _.Value = 2\n" - ] + let solution = solutionOf twoWidgets + let holder = documentNamed "holder.fs" solution let openDocumentIds = - if holderIsOpen then - [ (documentNamed "holder.fs" solution).Id ] + [ + if holderIsOpen then + holder.Id + ] + + let focus = + if holderIsFocused then + caretOn "C:\\holder.fs" 1 else - [] + ValueNone - let names = searchIn cache openDocumentIds solution "Widget" + let names = searchFocused cache openDocumentIds focus solution "Widget" Assert.Equal(expected, Array.head names) Assert.Equal(2, names.Length) @@ -260,32 +299,12 @@ let twice x = x * 2 Assert.Equal(pattern.Length >= 3, Array.contains "Cold.widgetCounter" names) - /// The focused file outranks the merely open one, which is how Copilot's own provider separates - /// the tab being edited from the rest of the tabs. - [] - let ``the focused file answers before the other open ones`` () = - let cache = freshCache () - - let solution = - solutionOf - [ - "C:\\elsewhere.fs", "module Elsewhere\n\ntype Widget() =\n member _.Value = 1\n" - "C:\\holder.fs", "module Holder\n\ntype WidgetHolder() =\n member _.Value = 2\n" - ] - - let openDocumentIds = documentsOf solution |> Array.map _.Id - - let names = - searchFocused cache openDocumentIds (caretOn "C:\\holder.fs" 1) solution "Widget" - - Assert.Equal("Holder.WidgetHolder", Array.head names) - /// The type around the caret loses on name length to the other one, so it can only come first by /// holding the caret - on a line of its member's body, not of its own name. [] [] [] - let ``the declaration around the caret answers before the rest of the focused file`` (caretLine: int) (expected: string) = + let ``the declaration around the caret answers before the rest of the focused file`` (caretLine: int, expected: string) = let cache = freshCache () let source = @@ -356,20 +375,14 @@ let twice x = x * 2 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 + let document = RoslynTestHelpers.GetSingleDocument solution Assert.Equal(document.FilePath, location.FilePath) Assert.Equal(context.Snippet.Length, location.Span.Length) - /// A value and a module whose names hold a dot, beside the nested paths that would spell the same - /// without the double backticks. - let private dottedNames = - "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 ``names that differ only in double backticks answer as two mentions`` () = - let names = - searchIn (freshCache ()) Seq.empty (solutionOf [ "C:\\dotted.fs", dottedNames ]) "a.b" + let names = searchIn cache Seq.empty dottedSolution "a.b" Assert.Contains("M.``a.b``", names) Assert.Contains("M.a.b", names) @@ -380,11 +393,4 @@ let twice x = x * 2 [] [] let ``a name holding a dot resolves to its own declaration`` (fullyQualifiedName: string, declaration: string) = - let solution = solutionOf [ "C:\\dotted.fs", dottedNames ] - - match - CopilotSymbolQuery.symbolContext (freshCache ()) Seq.empty solution fullyQualifiedName - |> run - with - | ValueSome context -> Assert.Equal(declaration, context.Snippet.Trim()) - | ValueNone -> failwith $"expected a symbol context for {fullyQualifiedName}" + Assert.Equal(declaration, (contextIn cache dottedSolution fullyQualifiedName).Snippet.Trim()) From 3cac37986e9e9c24d2d7fd5b734480de74b4249d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 17:55:14 +0200 Subject: [PATCH 20/29] Collect subscriptions into an array, not a list A fixed three subscriptions, once per text view: one allocation instead of three cons cells. Co-Authored-By: Claude Sonnet 5 --- .../src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs b/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs index fc77976f1f2..9eca113199b 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs @@ -76,11 +76,11 @@ type internal FSharpActiveDocumentListener recordFocus () let subscriptions = - [ + [| textView.GotAggregateFocus.Subscribe(fun _ -> recordFocus ()) textView.Caret.PositionChanged.Subscribe(fun _ -> recordWhileFocused ()) textView.Selection.SelectionChanged.Subscribe(fun _ -> recordWhileFocused ()) - ] + |] textView.Closed.Add(fun _ -> for subscription in subscriptions do From 2d3603c70949539c5baa26b1ea72f1639bbc81f7 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 17 Sep 2026 00:40:56 +0200 Subject: [PATCH 21/29] Take Copilot picker focus from Copilot's own document-context service Replace FSharpActiveDocumentTracker, a hand-rolled MEF text-view listener, with ICopilotDocumentContextProvider via the shared service broker - the same brokered service Copilot's own C# provider uses to answer where the user is. Verified live in the RoslynDev hive: the proxy composes, is sticky across chat focus, and its caret/selection lines resolve correctly for caret, range and whole-file cases. Co-Authored-By: Claude Sonnet 5 --- .../Copilot/ActiveDocumentTracker.fs | 89 ------------------- .../Copilot/CopilotContextProvider.fs | 32 ++++++- .../Copilot/CopilotSymbolMapping.fs | 61 +++++++++++++ .../src/FSharp.Editor/Copilot/EditorFocus.fs | 13 +++ .../src/FSharp.Editor/FSharp.Editor.fsproj | 2 +- .../CopilotContextProviderTests.fs | 64 +++++++++++++ 6 files changed, 168 insertions(+), 93 deletions(-) delete mode 100644 vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs create mode 100644 vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs diff --git a/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs b/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs deleted file mode 100644 index 9eca113199b..00000000000 --- a/vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. - -namespace Microsoft.VisualStudio.FSharp.Editor - -open System.ComponentModel.Composition - -open Microsoft.VisualStudio.Text -open Microsoft.VisualStudio.Text.Editor -open Microsoft.VisualStudio.Utilities - -/// The file of the editor that last took focus, and the lines its caret or selection covers - 1-based -/// and inclusive, as the parse tree counts them. -[] -type internal EditorFocus = - { - FilePath: string - FirstLine: int - LastLine: int - } - -/// Written from the UI thread as focus, caret and selection move, read from wherever a brokered service -/// happens to run; the lock keeps a read from pairing one file with another file's lines. A read that -/// races a tab switch names the tab before it, which costs a mention its place in a picker and nothing else. -[] -[] -type internal FSharpActiveDocumentTracker() = - - let gate = obj () - let mutable focus = ValueNone - - member _.Focus = lock gate (fun () -> focus) - - member _.SetFocus value = - lock gate (fun () -> focus <- ValueSome value) - -/// Every content type, not just F#: a C# file taking focus has to displace the F# one, or a declaration -/// would answer as focused while its file is off screen. -[)>] -[] -[] -type internal FSharpActiveDocumentListener - [] - (tracker: FSharpActiveDocumentTracker, textDocumentFactory: ITextDocumentFactoryService) = - - static let lineOf (point: SnapshotPoint) = - point.Snapshot.GetLineNumberFromPosition point.Position + 1 - - /// A selection ends before its End point: one of whole lines ends at the start of the line after them. - static let linesOf (textView: ITextView) = - let selection = textView.Selection - - if selection.IsEmpty then - let caret = lineOf textView.Caret.Position.BufferPosition - struct (caret, caret) - else - let firstLine = lineOf selection.Start.Position - struct (firstLine, max firstLine (lineOf (selection.End.Position - 1))) - - interface IWpfTextViewCreationListener with - member _.TextViewCreated(textView: IWpfTextView) = - match textDocumentFactory.TryGetTextDocument textView.TextBuffer with - | false, _ -> () - | true, document -> - let recordFocus () = - let struct (firstLine, lastLine) = linesOf textView - - tracker.SetFocus - { - FilePath = document.FilePath - FirstLine = firstLine - LastLine = lastLine - } - - let recordWhileFocused () = - if textView.HasAggregateFocus then - recordFocus () - - let subscriptions = - [| - textView.GotAggregateFocus.Subscribe(fun _ -> recordFocus ()) - textView.Caret.PositionChanged.Subscribe(fun _ -> recordWhileFocused ()) - textView.Selection.SelectionChanged.Subscribe(fun _ -> recordWhileFocused ()) - |] - - textView.Closed.Add(fun _ -> - for subscription in subscriptions do - subscription.Dispose()) - - recordWhileFocused () diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 16d5561a2d3..dc7a21cf553 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -422,10 +422,35 @@ type internal FSharpCopilotContextProvider [] ( cache: FSharpNavigableItemsCache, - activeDocument: FSharpActiveDocumentTracker, + [)>] serviceBroker: IServiceBroker, [] workspace: VisualStudioWorkspace | null ) = + /// The client Copilot's own document-context provider uses to reach the broker; never disposed, since + /// disposing it would dispose the broker it wraps, and it is meant to outlive every query. + let documentContexts = + new ServiceBrokerClient(serviceBroker, ThreadHelper.JoinableTaskFactory) + + /// Where the user is, from the service that answers the same question for Copilot's own C# provider. + /// The proxy is null when Copilot is not installed; the picker then ranks nothing as focused. + let focusOf () = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + use! rental = documentContexts.GetProxyAsync(CopilotDescriptors.Context.Document, ct).AsTask() + + match rental.Proxy with + | null -> return ValueNone + | proxy -> + let! context = proxy.GetActiveDocumentAsync(CopilotCorrelationId.New(), ct) + + match context with + | null -> return ValueNone + | context -> + match context.TryGetValue() with + | true, document -> return CopilotSymbolMapping.editorFocusOf document + | _ -> return ValueNone + } + static let moniker = ServiceMoniker(FSharpConstants.copilotSymbolProviderName, Version CopilotDescriptors.CurrentContextProviderVersion) @@ -495,8 +520,9 @@ type internal FSharpCopilotContextProvider | null, _ | _, [||] -> return Array.create searchTexts.Length noMentions | workspace, distinct -> - let! hits = - CopilotSymbolQuery.search cache (workspace.GetOpenDocumentIds()) activeDocument.Focus workspace.CurrentSolution distinct + let! focus = focusOf () + + let! hits = CopilotSymbolQuery.search cache (workspace.GetOpenDocumentIds()) focus workspace.CurrentSolution distinct let byText = Dictionary(StringComparer.Ordinal) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index 5547d6fb85a..e9f1ccb2d3e 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -157,3 +157,64 @@ let searchTextOf (query: CopilotMentionQuery) = else searchTextAt 0 inputs | _ -> ValueNone + +/// The number of lines before `offset` in `content`, 1-based - `DocumentSelection.Caret` is always the +/// offset of the start of a line, so no column arithmetic is needed. +let private lineOf (content: string) offset = + let mutable line = 1 + + for i in 0 .. offset - 1 do + if content[i] = '\n' then + line <- line + 1 + + line + +let private totalLinesIn (content: string) = lineOf content content.Length + +/// Where Copilot's document context says the user is. Copilot widens an empty selection to the +/// enclosing block, and to the whole file when a language has no block expander, as F# has not: the +/// caret line then comes from the selection's caret, which Copilot leaves out when the caret is on the +/// range's first or last line - so a caret on the file's first or last line reports no line. +let editorFocusOf (document: DocumentContext) = + match document.FilePath with + | null -> ValueNone + | filePath -> + match document.Selections |> Seq.tryHeadV with + | ValueSome selection when selection.Caret.HasValue -> + let line = lineOf document.Content selection.Caret.Value + + ValueSome + { + FilePath = filePath + FirstLine = line + LastLine = line + } + | ValueSome selection when selection.LineRange.HasValue -> + let range = selection.LineRange.Value + + let totalLines = + document.TotalLinesInFile + |> ValueOption.ofNullable + |> ValueOption.defaultWith (fun () -> totalLinesIn document.Content) + + if range.StartLine <= 1 && range.EndLine >= totalLines then + ValueSome + { + FilePath = filePath + FirstLine = 0 + LastLine = 0 + } + else + ValueSome + { + FilePath = filePath + FirstLine = range.StartLine + LastLine = range.EndLine + } + | _ -> + ValueSome + { + FilePath = filePath + FirstLine = 0 + LastLine = 0 + } diff --git a/vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs b/vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs new file mode 100644 index 00000000000..6f75b2bc4af --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +/// The file the user last edited and the lines the caret or selection covers, 1-based and inclusive; +/// 0 and 0 when the file is known but no line is. +[] +type internal EditorFocus = + { + FilePath: string + FirstLine: int + LastLine: int + } diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 71634cd8bd2..f5d6a8e0b04 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -94,7 +94,7 @@ - + diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index e1fad5b19df..73e42f663be 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -394,3 +394,67 @@ let twice x = x * 2 [] let ``a name holding a dot resolves to its own declaration`` (fullyQualifiedName: string, declaration: string) = Assert.Equal(declaration, (contextIn cache dottedSolution fullyQualifiedName).Snippet.Trim()) + + /// Six lines, counting newlines: "module M", "", "let a = 1", "", "let b = 2", and the blank line a + /// trailing newline leaves after it. + let private focusContent = "module M\n\nlet a = 1\n\nlet b = 2\n" + + let private focusFilePath = "C:\\focus.fs" + + let private documentFocus caretOffset startLine endLine = + let caret = if caretOffset < 0 then Nullable() else Nullable caretOffset + + let lineRange = + if startLine < 0 then + Nullable() + else + Nullable(Microsoft.VisualStudio.RpcContracts.Utilities.Range(startLine, -1, endLine, -1)) + + let selection = + DocumentSelection(0, focusContent.Length, Caret = caret, LineRange = lineRange) + + DocumentContext(focusContent, FilePath = focusFilePath, TotalLinesInFile = 6, Selections = [| selection |]) + |> CopilotSymbolMapping.editorFocusOf + + /// Copilot reports the caret as the offset of its line's start (10, the start of "let a = 1"), a + /// line range as 1-based line numbers, and the whole file as a range covering every line - which no + /// declaration can be "around", so it answers "focused, no line known", the same as no selection at all. + [] + [] + [] + [] + let ``the caret or selection maps onto a focused line range`` + (caretOffset: int) + (startLine: int) + (endLine: int) + (expectedFirst: int) + (expectedLast: int) + = + let expected = + ValueSome + { + FilePath = focusFilePath + FirstLine = expectedFirst + LastLine = expectedLast + } + + Assert.Equal(expected, documentFocus caretOffset startLine endLine) + + [] + let ``no selection at all is focused with no line known`` () = + let document = DocumentContext(focusContent, FilePath = focusFilePath) + + let expected = + ValueSome + { + FilePath = focusFilePath + FirstLine = 0 + LastLine = 0 + } + + Assert.Equal(expected, CopilotSymbolMapping.editorFocusOf document) + + [] + let ``a document with no file path is not focused`` () = + let document = DocumentContext(focusContent) + Assert.True((CopilotSymbolMapping.editorFocusOf document).IsNone) From f17ea64cd1de24f65f39f0b4a71540428ddfcb08 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 17 Sep 2026 01:15:08 +0200 Subject: [PATCH 22/29] Navigate to the overload a Copilot mention was picked for, not the first found Several overloads or partial definitions share one fully qualified name, so resolving a committed mention by that name alone always landed on whichever one a solution-wide scan reached first - not the one under the caret when the mention was picked. Record the picked declaration's line as a second (optional) mention input and use it to disambiguate on navigation, falling back to the first match when the line is absent or no longer found (e.g. a mention picked before this change shipped). Co-Authored-By: Claude Sonnet 5 --- .../Copilot/CopilotContextProvider.fs | 36 +++++++++++++++- .../Copilot/CopilotSymbolMapping.fs | 5 +++ .../CopilotContextProviderTests.fs | 43 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index dc7a21cf553..8f50536a255 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -358,6 +358,17 @@ module internal CopilotSymbolQuery = | implementations -> implementations } + /// The declaration among `declarations` that a mention was picked for, when its line is still known + /// and still matches one of them - several overloads and partial definitions answer to the same + /// fully qualified name, so the first one found is no substitute once a line has been recorded. + let declarationAt (line: int voption) (declarations: struct (NavigableItem * Document) array) = + match line with + | ValueSome line -> + declarations + |> Array.tryFindV (fun (struct (item, _)) -> item.Range.StartLine = line) + |> ValueOption.orElseWith (fun () -> Array.tryHeadV declarations) + | ValueNone -> Array.tryHeadV declarations + /// The source of the whole declaration `item` names, together with the span it occupies. let private snippetOf (outline: Outline) (item: NavigableItem) = let struct (firstLine, lastLine) = @@ -466,6 +477,12 @@ type internal FSharpCopilotContextProvider CopilotDefaultTypes.StringName, IsRequired = true ) + CopilotInputDescriptor( + CopilotSymbolMapping.DeclarationLineInput, + "Line the picked overload or partial definition is declared on.", + CopilotDefaultTypes.IntegerName, + IsRequired = false + ) |] ) @@ -484,11 +501,13 @@ type internal FSharpCopilotContextProvider | DocumentFocus.Elsewhere -> CopilotQueriedMentionPriority.None let mentionFor (item: NavigableItem) (document: Document) focus = - let inputs = Dictionary(1, StringComparer.Ordinal) + let inputs = Dictionary(2, StringComparer.Ordinal) inputs[CopilotSymbolMapping.FullyQualifiedNameInput] <- CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item) + inputs[CopilotSymbolMapping.DeclarationLineInput] <- CopilotValue(CopilotDefaultTypes.IntegerName, item.Range.StartLine) + let fileName = Path.GetFileName document.FilePath let tooltip = @@ -550,6 +569,19 @@ type internal FSharpCopilotContextProvider | _ -> ValueNone | _ -> ValueNone + /// Which overload or partial definition of a fully qualified name a mention was picked for - + /// several of them share the same name, so navigating one has to tell them apart by more than that. + let declarationLineOf (inputs: IReadOnlyDictionary | null) = + match inputs with + | null -> ValueNone + | inputs -> + match inputs.TryGetValue CopilotSymbolMapping.DeclarationLineInput with + | true, value -> + match value.TryGetValue() with + | true, line -> ValueSome line + | _ -> ValueNone + | _ -> ValueNone + interface IExportedBrokeredService with member _.Descriptor = CopilotDescriptors.CreateContextProviderDescriptor moniker @@ -603,7 +635,7 @@ type internal FSharpCopilotContextProvider let! declarations = CopilotSymbolQuery.declarationsOf cache (workspace.GetOpenDocumentIds()) solution fullyQualifiedName - match Array.tryHeadV declarations with + match CopilotSymbolQuery.declarationAt (declarationLineOf mention.Inputs) declarations with | ValueNone -> return false | ValueSome(struct (item, document)) -> let! sourceText = document.GetTextAsync ct diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index e9f1ccb2d3e..b1fc3084d57 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -20,6 +20,11 @@ let SymbolMember = "fsharpSymbol" [] let FullyQualifiedNameInput = "fullyQualifiedName" +/// Disambiguates which overload or partial definition a mention was picked for - several of them share +/// one fully qualified name, and only the one at this line is the one the user meant. +[] +let DeclarationLineInput = "declarationLine" + /// The parse tree cannot tell an interface, struct or record apart from a plain class, so every /// type-like declaration is reported as a class. let symbolContextType kind = diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index 73e42f663be..e61077012fd 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -319,6 +319,49 @@ let twice x = x * 2 Assert.Equal(expected, Array.head names) + /// Two overloads answer to the same fully qualified name, so navigating one after the picker has + /// closed - when there is no caret to consult any more - has to go by the line recorded when it was + /// picked, not by whichever overload a solution-wide scan happens to reach first. + [] + let ``navigating a mention goes to the overload it was picked for`` () = + let cache = freshCache () + + let source = + "module Overloads\n\ntype Counter() =\n member _.Bump() =\n 1\n\n member _.Bump(step: int) =\n step\n" + + let solution = solutionOf [ "C:\\overloads.fs", source ] + + let declarations = + CopilotSymbolQuery.declarationsOf cache Seq.empty solution "Overloads.Counter.Bump" + |> run + + let lineOf index = + let struct (item: FSharp.Compiler.EditorServices.NavigableItem, _) = + declarations[index] + + item.Range.StartLine + + Assert.Equal(2, declarations.Length) + + let firstLine, secondLine = lineOf 0, lineOf 1 + + let picked line = + CopilotSymbolQuery.declarationAt (ValueSome line) declarations + |> ValueOption.map (fun (struct (item, _)) -> item.Range.StartLine) + + Assert.Equal(ValueSome firstLine, picked firstLine) + Assert.Equal(ValueSome secondLine, picked secondLine) + + // An unknown line, or none at all, falls back to the first - the only choice before this line + // was tracked, and still the answer for a mention picked before this change shipped. + Assert.Equal(ValueSome firstLine, picked -1) + + Assert.Equal( + ValueSome firstLine, + CopilotSymbolQuery.declarationAt ValueNone declarations + |> ValueOption.map (fun (struct (item, _)) -> item.Range.StartLine) + ) + [] let ``a batch of texts answers like the same texts one by one`` () = let cache = freshCache () From 42b504167a0a2b1226bf81adb57567420df2fd55 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 17 Sep 2026 03:28:31 +0200 Subject: [PATCH 23/29] Preallocate the open-documents bucket in tiers by its known upper bound openIds.Count is a free, exact upper bound for how many open documents a query visits, so ResizeArray can skip its first few growth steps. cached/cold have no comparably cheap bound - either estimate would be wrong for half of all queries (a cold solution vs. one already parsed) and shared between two different-sized buckets, so they stay as they were. Co-Authored-By: Claude Sonnet 5 --- .../src/FSharp.Editor/Copilot/CopilotContextProvider.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 8f50536a255..1f10ab47876 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -145,7 +145,7 @@ module internal CopilotSymbolQuery = /// The documents in the order a query visits them: the ones the user has open, the ones already /// parsed into the cache, and the ones that would have to be parsed to answer. let private tiers (cache: FSharpNavigableItemsCache) (openIds: HashSet) (solution: Solution) = - let opened = ResizeArray() + let opened = ResizeArray(openIds.Count) let cached = ResizeArray() let cold = ResizeArray() From 15f9eccc746f7eea1d970eb48858e7991477fa67 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 20 Sep 2026 20:14:38 +0200 Subject: [PATCH 24/29] Compare spans through ordinal helpers instead of spelling StringComparison out The Copilot mapping compared spans by passing StringComparison.Ordinal to every call. #20443 adds the same helpers to illib, but they live in `module internal PervasiveAutoOpens`, and optimization info for anything non-public is dropped at the assembly boundary, so an --optimize+ build of FSharp.Editor fails on them with FS1116/FS1118. Keep a copy beside the ReadOnlySpanExtensions polyfills already in this file, named the same as illib's so the call sites need not change if the two converge. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/FSharp.Editor/Common/Extensions.fs | 26 +++++++++++++++++-- .../Copilot/CopilotSymbolMapping.fs | 8 +++--- .../Copilot/CopilotSymbolSnippets.fs | 2 +- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index f9695e68ecf..69991063180 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -7,6 +7,7 @@ open System open System.IO open System.Collections.Immutable open System.Collections.Generic +open System.Runtime.CompilerServices open System.Runtime.InteropServices open System.Threading open System.Threading.Tasks @@ -649,9 +650,30 @@ type Async with | null -> Async.RunSynchronouslyImmediate(computation, ?cancellationToken = cancellationToken) | _ -> Async.RunSynchronously(computation, ?cancellationToken = cancellationToken) -#if !NET7_0_OR_GREATER -open System.Runtime.CompilerServices +/// Ordinal comparisons of a span, which the BCL spells only through a StringComparison argument. +/// A copy of Internal.Utilities.Library's rather than a use of them: those are inline members of an +/// internal module, and optimization info for anything non-public is dropped at the assembly boundary, +/// so an --optimize+ build here fails on them with FS1116/FS1118. +[] +type ReadOnlySpanCharExtensions = + [] + static member inline EqualsOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.Equals(value, StringComparison.Ordinal) + + [] + static member inline EqualsOrdinal(str: ReadOnlySpan, value: string) = + str.Equals(value.AsSpan(), StringComparison.Ordinal) + + [] + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = + str.StartsWith(value.AsSpan(), StringComparison.Ordinal) + + [] + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.Ordinal) + +#if !NET7_0_OR_GREATER [] type ReadOnlySpanExtensions = [] diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index b1fc3084d57..d0168154541 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -111,10 +111,8 @@ let private lengthBefore (candidate: ReadOnlySpan) (segment: string) quote let tail = candidate.Slice(candidate.Length - length) if - tail.Slice(quotes, segment.Length).Equals(segment.AsSpan(), StringComparison.Ordinal) - && (not quoted - || tail.StartsWith("``".AsSpan(), StringComparison.Ordinal) - && tail.EndsWith("``".AsSpan(), StringComparison.Ordinal)) + tail.Slice(quotes, segment.Length).EqualsOrdinal segment + && (not quoted || tail.StartsWithOrdinal "``" && tail.EndsWithOrdinal "``") then candidate.Length - length else @@ -139,7 +137,7 @@ let hasFullyQualifiedName (candidate: string) (item: NavigableItem) = let enclosing = path.Length - container.Name.Length lengthBefore spelledPath container.Name (isContainerQuoted container) = enclosing - && spelledPath.Slice(0, enclosing).Equals(path.AsSpan(0, enclosing), StringComparison.Ordinal) + && spelledPath.Slice(0, enclosing).EqualsOrdinal(path.AsSpan(0, enclosing)) /// The text a picker query searches for. The inputs are positional: the member name first once the /// mention has been committed, as in "#fsharpSymbol:Namespace.Type", then the search text, then diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index 758f366448a..1937769f820 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -40,7 +40,7 @@ let declarationLines (sourceLines: string array) (scopes: Structure.ScopeRange s // Outlining reports a doc comment only once it spans several lines, so a one-line "///" in front of // a declaration is invisible to the scopes above. let isDocComment line = - sourceLines[line - 1].AsSpan().TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) + sourceLines[line - 1].AsSpan().TrimStart().StartsWithOrdinal "///" let rec docCommentStart line = if line > 1 && isDocComment (line - 1) then From b0caf629826f57ba36db8231718b1ffef646367b Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 20 Sep 2026 22:14:48 +0200 Subject: [PATCH 25/29] Take the span ordinal helpers from illib instead of copying them FSharp.Editor cannot call illib's inline helpers through the assembly reference: optimization info for anything non-public is dropped at the assembly boundary, so an --optimize+ build fails with FS1116/FS1118. Compile illib into FSharp.Editor instead, the way the project already pulls in LegacyMSBuildReferenceResolver.fs, so the helpers are in the same assembly as their callers and the code exists once. The span comparisons the Copilot mapping needs go to illib beside the String ones rather than into a private copy under vsintegration. Co-Authored-By: Claude Opus 5 (1M context) --- src/Compiler/Utilities/illib.fs | 19 ++++++++++++++ src/Compiler/Utilities/illib.fsi | 15 +++++++++++ .../src/FSharp.Editor/Common/Extensions.fs | 26 ++----------------- .../Copilot/CopilotSymbolMapping.fs | 2 ++ .../Copilot/CopilotSymbolSnippets.fs | 2 ++ .../src/FSharp.Editor/FSharp.Editor.fsproj | 2 ++ 6 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index d6302776cb7..2fd0459b2e6 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -112,6 +112,25 @@ module internal PervasiveAutoOpens = member inline x.IndexOfOrdinal(value: string, startIndex, count) = x.IndexOf(value, startIndex, count, StringComparison.Ordinal) + [] + type ReadOnlySpanCharExtensions = + + [] + static member inline EqualsOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.Equals(value, StringComparison.Ordinal) + + [] + static member inline EqualsOrdinal(str: ReadOnlySpan, value: string) = + str.Equals(value.AsSpan(), StringComparison.Ordinal) + + [] + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = + str.StartsWith(value.AsSpan(), StringComparison.Ordinal) + + [] + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.Ordinal) + /// Get an initialization hole let getHole (r: _ ref) = match r.Value with diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index bc04c2ca1ac..183cc74cf27 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -68,6 +68,21 @@ module internal PervasiveAutoOpens = member inline IndexOfOrdinal: value: string * startIndex: int * count: int -> int + [] + type ReadOnlySpanCharExtensions = + + [] + static member inline EqualsOrdinal: str: ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EqualsOrdinal: str: ReadOnlySpan * value: string -> bool + + [] + static member inline StartsWithOrdinal: str: ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinal: str: ReadOnlySpan * value: string -> bool + type Async with /// Runs the computation synchronously, always starting on the current thread. diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index 69991063180..f9695e68ecf 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -7,7 +7,6 @@ open System open System.IO open System.Collections.Immutable open System.Collections.Generic -open System.Runtime.CompilerServices open System.Runtime.InteropServices open System.Threading open System.Threading.Tasks @@ -650,30 +649,9 @@ type Async with | null -> Async.RunSynchronouslyImmediate(computation, ?cancellationToken = cancellationToken) | _ -> Async.RunSynchronously(computation, ?cancellationToken = cancellationToken) -/// Ordinal comparisons of a span, which the BCL spells only through a StringComparison argument. -/// A copy of Internal.Utilities.Library's rather than a use of them: those are inline members of an -/// internal module, and optimization info for anything non-public is dropped at the assembly boundary, -/// so an --optimize+ build here fails on them with FS1116/FS1118. -[] -type ReadOnlySpanCharExtensions = - - [] - static member inline EqualsOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = - str.Equals(value, StringComparison.Ordinal) - - [] - static member inline EqualsOrdinal(str: ReadOnlySpan, value: string) = - str.Equals(value.AsSpan(), StringComparison.Ordinal) - - [] - static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = - str.StartsWith(value.AsSpan(), StringComparison.Ordinal) - - [] - static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = - str.EndsWith(value.AsSpan(), StringComparison.Ordinal) - #if !NET7_0_OR_GREATER +open System.Runtime.CompilerServices + [] type ReadOnlySpanExtensions = [] diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index d0168154541..c0748410e73 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -9,6 +9,8 @@ open System.Collections.Generic open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.Imaging +open Internal.Utilities.Library + open FSharp.Compiler.EditorServices open FSharp.Compiler.Syntax diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index 1937769f820..aa953b26c2d 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -5,6 +5,8 @@ module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolSnippets open System +open Internal.Utilities.Library + open FSharp.Compiler.EditorServices /// A module scope can span a whole file, which is more than a chat prompt can usefully carry. diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index f5d6a8e0b04..1d9783e1061 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -31,6 +31,8 @@ + + From a6c811d98c393ea38a3b9b29f18e1245c8a0b601 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 20 Sep 2026 22:39:56 +0200 Subject: [PATCH 26/29] Resolve only the open documents up front, walk the rest on demand tiers walked every F# document in the solution and asked the cache about each one, however little of that a query went on to use. The open documents now come from their ids, and the cached and cold tiers are sequences the scan walks only when it reaches them - a query the open files already answer never looks at the solution at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../Copilot/CopilotContextProvider.fs | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 1f10ab47876..39ee621448a 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -143,19 +143,40 @@ module internal CopilotSymbolQuery = } /// The documents in the order a query visits them: the ones the user has open, the ones already - /// parsed into the cache, and the ones that would have to be parsed to answer. + /// parsed into the cache, and the ones that would have to be parsed to answer. Only the open ones + /// are resolved up front, by id rather than by walking the solution; the other two tiers are walked + /// when they are reached, so a query the open files already answer never looks at the rest. let private tiers (cache: FSharpNavigableItemsCache) (openIds: HashSet) (solution: Solution) = - let opened = ResizeArray(openIds.Count) - let cached = ResizeArray() - let cold = ResizeArray() - - for document in fsharpDocuments solution do - if openIds.Contains document.Id then - opened.Add document - else - match cache.TryGetCachedNavigableItems document.Id with - | ValueSome items -> cached.Add(struct (document, items)) - | ValueNone -> cold.Add document + let opened = + openIds + |> Seq.chooseV (fun id -> + match solution.GetDocument id with + | null -> ValueNone + | document when document.Project.IsFSharp -> ValueSome document + | _ -> ValueNone) + |> ResizeArray + + let unopened = + seq { + for document in fsharpDocuments solution do + if not (openIds.Contains document.Id) then + document + } + + let cached = + seq { + for document in unopened do + match cache.TryGetCachedNavigableItems document.Id with + | ValueSome items -> struct (document, items) + | ValueNone -> () + } + + let cold = + seq { + for document in unopened do + if (cache.TryGetCachedNavigableItems document.Id).IsNone then + document + } struct (opened, cached, cold) From 4bae417438b204aca06791323d394d93502b8362 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 21 Sep 2026 00:38:23 +0200 Subject: [PATCH 27/29] Revert "Take the span ordinal helpers from illib instead of copying them" This reverts commit b0caf629826f57ba36db8231718b1ffef646367b. --- src/Compiler/Utilities/illib.fs | 19 -------------- src/Compiler/Utilities/illib.fsi | 15 ----------- .../src/FSharp.Editor/Common/Extensions.fs | 26 +++++++++++++++++-- .../Copilot/CopilotSymbolMapping.fs | 2 -- .../Copilot/CopilotSymbolSnippets.fs | 2 -- .../src/FSharp.Editor/FSharp.Editor.fsproj | 2 -- 6 files changed, 24 insertions(+), 42 deletions(-) diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index 2fd0459b2e6..d6302776cb7 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -112,25 +112,6 @@ module internal PervasiveAutoOpens = member inline x.IndexOfOrdinal(value: string, startIndex, count) = x.IndexOf(value, startIndex, count, StringComparison.Ordinal) - [] - type ReadOnlySpanCharExtensions = - - [] - static member inline EqualsOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = - str.Equals(value, StringComparison.Ordinal) - - [] - static member inline EqualsOrdinal(str: ReadOnlySpan, value: string) = - str.Equals(value.AsSpan(), StringComparison.Ordinal) - - [] - static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = - str.StartsWith(value.AsSpan(), StringComparison.Ordinal) - - [] - static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = - str.EndsWith(value.AsSpan(), StringComparison.Ordinal) - /// Get an initialization hole let getHole (r: _ ref) = match r.Value with diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index 183cc74cf27..bc04c2ca1ac 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -68,21 +68,6 @@ module internal PervasiveAutoOpens = member inline IndexOfOrdinal: value: string * startIndex: int * count: int -> int - [] - type ReadOnlySpanCharExtensions = - - [] - static member inline EqualsOrdinal: str: ReadOnlySpan * value: ReadOnlySpan -> bool - - [] - static member inline EqualsOrdinal: str: ReadOnlySpan * value: string -> bool - - [] - static member inline StartsWithOrdinal: str: ReadOnlySpan * value: string -> bool - - [] - static member inline EndsWithOrdinal: str: ReadOnlySpan * value: string -> bool - type Async with /// Runs the computation synchronously, always starting on the current thread. diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index f9695e68ecf..69991063180 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -7,6 +7,7 @@ open System open System.IO open System.Collections.Immutable open System.Collections.Generic +open System.Runtime.CompilerServices open System.Runtime.InteropServices open System.Threading open System.Threading.Tasks @@ -649,9 +650,30 @@ type Async with | null -> Async.RunSynchronouslyImmediate(computation, ?cancellationToken = cancellationToken) | _ -> Async.RunSynchronously(computation, ?cancellationToken = cancellationToken) -#if !NET7_0_OR_GREATER -open System.Runtime.CompilerServices +/// Ordinal comparisons of a span, which the BCL spells only through a StringComparison argument. +/// A copy of Internal.Utilities.Library's rather than a use of them: those are inline members of an +/// internal module, and optimization info for anything non-public is dropped at the assembly boundary, +/// so an --optimize+ build here fails on them with FS1116/FS1118. +[] +type ReadOnlySpanCharExtensions = + [] + static member inline EqualsOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.Equals(value, StringComparison.Ordinal) + + [] + static member inline EqualsOrdinal(str: ReadOnlySpan, value: string) = + str.Equals(value.AsSpan(), StringComparison.Ordinal) + + [] + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = + str.StartsWith(value.AsSpan(), StringComparison.Ordinal) + + [] + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.Ordinal) + +#if !NET7_0_OR_GREATER [] type ReadOnlySpanExtensions = [] diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index c0748410e73..d0168154541 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -9,8 +9,6 @@ open System.Collections.Generic open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.Imaging -open Internal.Utilities.Library - open FSharp.Compiler.EditorServices open FSharp.Compiler.Syntax diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index aa953b26c2d..1937769f820 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -5,8 +5,6 @@ module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolSnippets open System -open Internal.Utilities.Library - open FSharp.Compiler.EditorServices /// A module scope can span a whole file, which is more than a chat prompt can usefully carry. diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 1d9783e1061..f5d6a8e0b04 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -31,8 +31,6 @@ - - From a4bda08889195564c852ee5aa61830a7cb2e5b02 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 21 Sep 2026 10:23:27 +0200 Subject: [PATCH 28/29] Look a declaration up in the tests without FSharp.Editor's inline helper Array.tryPickV is an inline member of FSharp.Editor's internal Extensions module, so a test project compiled with --optimize+ cannot inline it and fails with FS1116/FS1118 - which Debug builds hide, and every Windows CI leg builds in Release. FSharp.Core's Array.tryPick has no voption counterpart, so it is unwrapped with Option.defaultWith directly. Co-Authored-By: Claude Sonnet 5 --- .../FSharp.Editor.Tests/CopilotContextProviderTests.fs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index e61077012fd..4fd407f7a40 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -97,12 +97,12 @@ let twice x = x * 2 let private itemNamed (fullyQualifiedName: string) = hitsIn cache Seq.empty ValueNone solution fullyQualifiedName - |> Array.tryPickV (fun (struct (item, _, _)) -> + |> Array.tryPick (fun (struct (item, _, _)) -> if CopilotSymbolMapping.fullyQualifiedName item = fullyQualifiedName then - ValueSome item + Some item else - ValueNone) - |> ValueOption.defaultWith (fun () -> failwith $"no declaration named {fullyQualifiedName}") + None) + |> Option.defaultWith (fun () -> failwith $"no declaration named {fullyQualifiedName}") let private contextIn cache solution (name: string) = CopilotSymbolQuery.symbolContext cache Seq.empty solution name From a657945991314bf9db8531996a1905661da9bf13 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 21 Sep 2026 10:28:01 +0200 Subject: [PATCH 29/29] Revert "Compare spans through ordinal helpers instead of spelling StringComparison out" This reverts commit 15f9eccc746f7eea1d970eb48858e7991477fa67. --- .../src/FSharp.Editor/Common/Extensions.fs | 26 ++----------------- .../Copilot/CopilotSymbolMapping.fs | 8 +++--- .../Copilot/CopilotSymbolSnippets.fs | 2 +- 3 files changed, 8 insertions(+), 28 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index 69991063180..f9695e68ecf 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -7,7 +7,6 @@ open System open System.IO open System.Collections.Immutable open System.Collections.Generic -open System.Runtime.CompilerServices open System.Runtime.InteropServices open System.Threading open System.Threading.Tasks @@ -650,30 +649,9 @@ type Async with | null -> Async.RunSynchronouslyImmediate(computation, ?cancellationToken = cancellationToken) | _ -> Async.RunSynchronously(computation, ?cancellationToken = cancellationToken) -/// Ordinal comparisons of a span, which the BCL spells only through a StringComparison argument. -/// A copy of Internal.Utilities.Library's rather than a use of them: those are inline members of an -/// internal module, and optimization info for anything non-public is dropped at the assembly boundary, -/// so an --optimize+ build here fails on them with FS1116/FS1118. -[] -type ReadOnlySpanCharExtensions = - - [] - static member inline EqualsOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = - str.Equals(value, StringComparison.Ordinal) - - [] - static member inline EqualsOrdinal(str: ReadOnlySpan, value: string) = - str.Equals(value.AsSpan(), StringComparison.Ordinal) - - [] - static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = - str.StartsWith(value.AsSpan(), StringComparison.Ordinal) - - [] - static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = - str.EndsWith(value.AsSpan(), StringComparison.Ordinal) - #if !NET7_0_OR_GREATER +open System.Runtime.CompilerServices + [] type ReadOnlySpanExtensions = [] diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index d0168154541..b1fc3084d57 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -111,8 +111,10 @@ let private lengthBefore (candidate: ReadOnlySpan) (segment: string) quote let tail = candidate.Slice(candidate.Length - length) if - tail.Slice(quotes, segment.Length).EqualsOrdinal segment - && (not quoted || tail.StartsWithOrdinal "``" && tail.EndsWithOrdinal "``") + tail.Slice(quotes, segment.Length).Equals(segment.AsSpan(), StringComparison.Ordinal) + && (not quoted + || tail.StartsWith("``".AsSpan(), StringComparison.Ordinal) + && tail.EndsWith("``".AsSpan(), StringComparison.Ordinal)) then candidate.Length - length else @@ -137,7 +139,7 @@ let hasFullyQualifiedName (candidate: string) (item: NavigableItem) = let enclosing = path.Length - container.Name.Length lengthBefore spelledPath container.Name (isContainerQuoted container) = enclosing - && spelledPath.Slice(0, enclosing).EqualsOrdinal(path.AsSpan(0, enclosing)) + && spelledPath.Slice(0, enclosing).Equals(path.AsSpan(0, enclosing), StringComparison.Ordinal) /// The text a picker query searches for. The inputs are positional: the member name first once the /// mention has been committed, as in "#fsharpSymbol:Namespace.Type", then the search text, then diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index 1937769f820..758f366448a 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -40,7 +40,7 @@ let declarationLines (sourceLines: string array) (scopes: Structure.ScopeRange s // Outlining reports a doc comment only once it spans several lines, so a one-line "///" in front of // a declaration is invisible to the scopes above. let isDocComment line = - sourceLines[line - 1].AsSpan().TrimStart().StartsWithOrdinal "///" + sourceLines[line - 1].AsSpan().TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) let rec docCommentStart line = if line > 1 && isDocComment (line - 1) then