Skip to content

Offer F# declarations to the Copilot chat "#" mention picker - #20409

Open
xperiandri wants to merge 29 commits into
dotnet:mainfrom
xperiandri:copilot-mentions
Open

xperiandri wants to merge 29 commits into
dotnet:mainfrom
xperiandri:copilot-mentions

Conversation

@xperiandri

@xperiandri xperiandri commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

GitHub Copilot Chat's # mention picker in Visual Studio reads symbols off the Roslyn Compilation, which F# projects do not have, so F# types, modules, members and values never appeared in it.

FSharp.Editor now 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

@github-actions

github-actions Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

✅ Release notes checked


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`vsintegration/src` docs/release-notes/.VisualStudio/18.vNext.md

@xperiandri

Copy link
Copy Markdown
Contributor Author
image image

@github-actions github-actions Bot added ⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure ⚠️ Affects-Restore Tooling check: PR touches NuGet packages or feeds labels Aug 31, 2026
@github-actions

This comment has been minimized.

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. CopilotSymbolQuery holds all the lookup logic and takes a Solution directly, so it's unit-tested with no VS workspace, while FSharpCopilotContextProvider stays 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-Shared so both consumers share one instance) keeps the picker responsive per keystroke.
  • Throttling via whenAllThrottled ProcessorCount mirrors FindReferencesAsync, so a query doesn't launch a parse-per-document storm.
  • Exhaustive mappings. symbolContextType/imageId cover all 11 NavigableItemKind cases — 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 the null→match conversion 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)

  1. Exception safety at package load (LanguageService.fs). The registration task handles a null proxy (Copilot absent), but only that. If GetProxyAsync/RegisterContextProviderAsync throws — e.g. a Copilot contract-version mismatch, given you're compile-pinned to 18.9.918 but bind-redirect to whatever VS ships — the exception escapes the afterPackageLoadedTasks task. Worth confirming AddTask(false, …) isolates a faulting task, or wrapping the body in try/with so a Copilot hiccup can't perturb F# package load.

  2. Batch query is sequential, O(all docs) per query (QueryMentionBatchAsync). for query in queries do let! … = queryMentions query runs 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.

  3. One-line declarations drop their doc comment. In definitionLines, a construct with no body scope (/// doc + let x = 1) falls through to declarationLine, 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.

  4. Nit: the test's hardcoded "C:\\test.fs" is fine for Windows-only VS tests but couples to RoslynTestHelpers' 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. 🎉

@T-Gro T-Gro added the AI-reviewed PR reviewed by AI review council label Sep 2, 2026
@T-Gro
T-Gro self-requested a review September 2, 2026 14:44
@xperiandri

Copy link
Copy Markdown
Contributor Author

#20443 has some improvements beneficial for this PR
@T-Gro let's proceed with it first

T-Gro
T-Gro approved these changes Sep 4, 2026 •

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ AI review — verify independently.

Comment thread vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs Outdated
Comment thread tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj Outdated
@github-actions github-actions Bot added ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager ⚠️ Affects-Agent-Config Tooling check: PR modifies AI agent instructions or workflows labels Sep 11, 2026
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ 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.

Comment thread vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs
Comment thread vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs Outdated
Comment thread vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs
Comment thread vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs Outdated
Comment thread vsintegration/src/FSharp.Editor/Copilot/ActiveDocumentTracker.fs Outdated
xperiandri and others added 13 commits September 16, 2026 17:59
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>
xperiandri and others added 2 commits September 17, 2026 00:41
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>
Comment on lines +145 to +200
/// 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
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@T-Gro

T-Gro commented Sep 18, 2026

Copy link
Copy Markdown
Member

@xperiandri :

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?
Isn't this more of an accessibility issue, the helpers would need to be in the .fsi (are they?) and be public/internal also in what they use (you cannot inline if the contents are using something not visible to the caller)

xperiandri and others added 6 commits September 20, 2026 20:14
…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>
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>
@github-actions

This comment has been minimized.

@xperiandri

Copy link
Copy Markdown
Contributor Author

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?
Isn't this more of an accessibility issue, the helpers would need to be in the .fsi (are they?) and be public/internal also in what they use (you cannot inline if the contents are using something not visible to the caller)

I created a separate PR #20604

Here eliminated this question

@T-Gro
T-Gro enabled auto-merge (squash) September 21, 2026 09:45
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Agent-Config, Affects-Build-Infra, Affects-Design-Time, Affects-Restore
Affects-Agent-Config: Changes agent instructions or workflow configuration.
Affects-Build-Infra: Changes build scripts or MSBuild configuration.
Affects-Design-Time: Changes editor or language-service behavior.
Affects-Restore: Changes package feeds, references, or restore configuration.

Generated by PR Tooling Safety Check · gpt56 1.9M · ◷

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ Affects-Agent-Config Tooling check: PR modifies AI agent instructions or workflows ⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager ⚠️ Affects-Restore Tooling check: PR touches NuGet packages or feeds AI-reviewed PR reviewed by AI review council

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants