Offer F# declarations to the Copilot chat "#" mention picker - #20409
xperiandri wants to merge 29 commits into
Conversation
✅ Release notes checked
|
This comment has been minimized.
This comment has been minimized.
db075ee to
80d555b
Compare
T-Gro
left a comment
There was a problem hiding this comment.
Really nice piece of work. The design is clean, the comments explain the why behind every non-obvious choice, and it fills a real gap — F# projects have no Roslyn Compilation, so Copilot's built-in provider never saw F# symbols.
What stood out as excellent
- Separation of concerns.
CopilotSymbolQueryholds all the lookup logic and takes aSolutiondirectly, so it's unit-tested with no VS workspace, whileFSharpCopilotContextProviderstays a thin brokered-service adapter. That split is exactly why the tests read so well. - No project-wide typecheck. Reusing the NavigateTo parse-tree cache (now cleanly extracted as
FSharpNavigableItemsCache, MEF-Sharedso both consumers share one instance) keeps the picker responsive per keystroke. - Throttling via
whenAllThrottled ProcessorCountmirrorsFindReferencesAsync, so a query doesn't launch a parse-per-document storm. - Exhaustive mappings.
symbolContextType/imageIdcover all 11NavigableItemKindcases — no partial-match surprises. - The cache extraction preserves the backtick/operator substring-match fallback verbatim; the
struct-tuple change on that per-keystroke path and thenull→matchconversion are tidy. - Release note added; tests cover search, dedup, snippet extent, doc-comment inclusion, kind mapping, and the snippet-location round-trip.
Suggestions (none blocking)
-
Exception safety at package load (
LanguageService.fs). The registration task handles anullproxy (Copilot absent), but only that. IfGetProxyAsync/RegisterContextProviderAsyncthrows — e.g. a Copilot contract-version mismatch, given you're compile-pinned to18.9.918but bind-redirect to whatever VS ships — the exception escapes theafterPackageLoadedTaskstask. Worth confirmingAddTask(false, …)isolates a faulting task, or wrapping the body intry/withso a Copilot hiccup can't perturb F# package load. -
Batch query is sequential, O(all docs) per query (
QueryMentionBatchAsync).for query in queries do let! … = queryMentions queryruns one full-solution scan per query, serially. Batches are usually tiny so it's fine in practice, but if Copilot ever sends several, they could be de-duplicated or run through the same throttle rather than back-to-back. -
One-line declarations drop their doc comment. In
definitionLines, a construct with no body scope (/// doc+let x = 1) falls through todeclarationLine, item.Range.EndLine, so its doc comment isn't captured — unlike the multi-line path, which deliberately reaches back over the doc comment. Minor; a follow-up could widen the one-line case to include an immediately-preceding///block. -
Nit: the test's hardcoded
"C:\\test.fs"is fine for Windows-only VS tests but couples toRoslynTestHelpers' internal path.
I reviewed statically and confirmed the in-tree helpers (whenAllThrottled, chooseV/tryHeadV/toImmutableArray, ValueOption.ofNullable) and the NavigableItemKind shape; I didn't run a VS-hosted build, so the Microsoft.VisualStudio.Copilot contract surface is taken on faith from the package reference.
Only item 1 feels worth a second look before merge. Thanks for this — it's going to be a delightful quality-of-life win for F# users in the Copilot picker. 🎉
80d555b to
28f13e7
Compare
T-Gro
left a comment
There was a problem hiding this comment.
🤖🕵️ AI review — verify independently.
432ff25 to
540463c
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
T-Gro
left a comment
There was a problem hiding this comment.
🤖🕵️ Please shorten the description using this guidance. Focus on the problem and why the change is needed, in simplified technical English. Leave the implementation inventory to the Files tab and retain necessary caveats.
GetProxyAsync<ICopilotRegistrationService> 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.
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Reverts the copies of dotnet#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<char>[]) and added a System.Memory reference that fails restore with NU1510 - neither belongs here, and dotnet#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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
CancellableTask<unit> is CancellationToken -> Task<unit>, the shape Task.parallelDoLimit takes, so the hand-rolled worker loop is one call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CheckCodeFormatting on this branch fails without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
A fixed three subscriptions, once per text view: one allocation instead of three cons cells. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
80f959a to
3cac379
Compare
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 <noreply@anthropic.com>
…rst 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 <noreply@anthropic.com>
| /// 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<DocumentId>) (solution: Solution) = | ||
| 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) | ||
|
|
||
| /// Visits the documents tier by tier until `enough` answers, parsing the cold ones for at most | ||
| /// `budgetMs`. The budget stops handing out documents rather than cancelling a parse under way: | ||
| /// the first query of a session has to survive its first parse to answer at all. | ||
| let private scanTiers | ||
| (cache: FSharpNavigableItemsCache) | ||
| (openIds: HashSet<DocumentId>) | ||
| (solution: Solution) | ||
| (budgetMs: int64) | ||
| (enough: unit -> bool) | ||
| (collect: Document -> NavigableItem array -> unit) | ||
| = | ||
| cancellableTask { | ||
| let! ct = CancellableTask.getCancellationToken () | ||
| let struct (opened, cached, cold) = tiers cache openIds solution | ||
|
|
||
| let parseAndCollect (document: Document) = | ||
| cancellableTask { | ||
| let! items = cache.GetNavigableItems document | ||
| collect document items | ||
| } | ||
|
|
||
| do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect | ||
|
|
||
| if not (enough ()) then | ||
| for struct (document, items) in cached do | ||
| ct.ThrowIfCancellationRequested() | ||
| collect document items | ||
|
|
||
| if not (enough ()) then | ||
| let budget = Stopwatch.StartNew() | ||
|
|
||
| let scan (document: Document) = | ||
| cancellableTask { | ||
| if budget.ElapsedMilliseconds < budgetMs && not (enough ()) then | ||
| do! parseAndCollect document | ||
| } | ||
|
|
||
| do! cold |> CancellableTask.forEachThrottled parallelism scan | ||
| } |
There was a problem hiding this comment.
This looks stupid; we always iterate all while we need only a fraction.
Only opened must always be resolved; everything else must be lazy
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 <noreply@anthropic.com>
|
What is "and inline doesn't survive the assembly boundary from a project reference the way it does from a NuGet-shipped assembly" supposed to mean? |
…rison out The Copilot mapping compared spans by passing StringComparison.Ordinal to every call. dotnet#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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…hem" This reverts commit b0caf62.
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 <noreply@anthropic.com>
…ingComparison out" This reverts commit 15f9ecc.
This comment has been minimized.
This comment has been minimized.
I created a separate PR #20604 Here eliminated this question |
|
🔍 Tooling Safety Check — Affects-Agent-Config, Affects-Build-Infra, Affects-Design-Time, Affects-Restore
|


GitHub Copilot Chat's
#mention picker in Visual Studio reads symbols off the RoslynCompilation, which F# projects do not have, so F# types, modules, members and values never appeared in it.FSharp.Editornow proffers Copilot's context-provider contracts as a brokered service, answering from the existing Navigate To parse-tree cache rather than a project-wide type check, so the picker keeps up with typing. A picked mention resolves by fully qualified name against the current solution and attaches the whole declaration, doc comment included.Solution-wide document scanning is throttled the same way Find All References throttles its per-document type-checks, so a query does not launch a parse per document all at once.
🤖 Generated with Claude Code