Skip to content

Answer the Navigate To search that runs while the solution loads - #20492

Draft
xperiandri wants to merge 11 commits into
dotnet:mainfrom
xperiandri:feature/navigate-to-while-loading
Draft

Answer the Navigate To search that runs while the solution loads#20492
xperiandri wants to merge 11 commits into
dotnet:mainfrom
xperiandri:feature/navigate-to-while-loading

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Draft: it cannot build against the Roslyn this repository references yet. dotnet/roslyn#85213,
the contract this implements, merged on 2026-09-09, but main still builds against Roslyn
5.12.0-1.26428.6 (dotnet/roslyn@92a36ef, 2026-08-28), which predates it. It needs the next
dependency update from dotnet/roslyn. The diff also carries #20409, see What you are looking at.

Navigate To runs a search of its own while a solution is still loading, and F# contributes nothing to
it. NavigateToSearcher dispatches that phase through IAdvancedNavigateToSearchService, which the
F# language service does not implement, so every F# project is reported complete and searched not at
all. No full search follows — that is deliberate on the Roslyn side ("Telemetry shows no meaningful
change if we do a full search after this point") — so on a large solution Ctrl+T lists nothing F#
declares until the user searches again.

The search itself was never the problem: it is a parse away, and it needs no type checking. What it
lacked is the project's compilation options, which do not exist yet during load, so
GetFSharpParseResultsAsync raises. GetFSharpQuickParseResultsAsync parses with whatever parsing
options the project system has already produced, or defaults — a dictionary read, no reactor, no I/O,
which is what makes it safe to call for every document of every project while the solution loads.

Those defines can be the wrong ones, and the document's version does not change when the real options
arrive, so a version stamp alone would let an approximate parse answer the accurate search: a
declaration behind #if could be missed, or reported from a branch that never compiles. The cache
entry now carries whether it was approximate, and the accurate path refuses those and reparses. The
loading path takes either, since its contract allows out-of-date results.

SearchCachedDocumentsAsync follows the shape of the C# and VB service: priority documents and the
projects holding them are searched first, results are reported per document rather than per project so
the first ones appear while the rest are still parsing, and each project is reported complete once its
documents are done. The parses of every project take turns on one throttle that leaves a core free, so
a search during load cannot take the cores away from the load itself.

What you are looking at

The branch is main plus the commits of #20409, which adds FSharpNavigableItemsCache, the
per-document parse cache this extends, and two commits of its own:

  • Give the quick parsing options the file they parse. A project with no options yet got quick
    parsing options with no source files, and ParseFile throws looking for the last compiland. Nothing
    parsed with them before a project had loaded; the loading search does.
  • Answer the Navigate-To search that runs while the solution loads, the change itself.

Once #20409 merges, the diff is those two commits.

Verification

Against a local build of Roslyn that contains dotnet/roslyn#85213:

  • NavigateToSearchWhileLoadingTests, three tests: the loading search finds a declaration where the
    accurate search raises for want of options; a file behind #if FOO read under the wrong defines
    returns nothing and does not poison the accurate search that follows once --define:FOO arrives;
    and every project is reported complete exactly once whether or not anything was found in it. With
    the approximate check removed, the first two fail.
  • With NavigateToSearchServiceTests and CopilotContextProviderTests, 31 pass. The full
    FSharp.Editor.Tests run: 7219 passed, 2 skipped, 4 failed — fsiShouldTriggerCompletionInFsxFile
    and three type provider tests, which fail the same way on this branch's base without its commits.
  • FSharp.Editor builds with -p:Optimize=true.

Checked live as well, on the branch this was first written on, with the Roslyn build deployed to the
experimental hive: on a 26-project mixed C#/F# solution, breaking in the F# implementation gives the
stack ProcessOrderedProjectsAsyncNavigateToSearcher.SearchCachedDocumentsAsync → the external
access adapter → here, with the active document and priority documents intact, and Ctrl+T lists F#
declarations immediately while the solution is still loading.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

Warning

No PR link found in some release notes, please consider adding it.

Change path Release notes path Description
`src/Compiler` docs/release-notes/.FSharp.Compiler.Service/11.0.100.md No current pull request URL (#20492) found, please consider adding it
`vsintegration/src` docs/release-notes/.VisualStudio/18.vNext.md

xperiandri and others added 11 commits September 11, 2026 16:50
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 <noreply@anthropic.com>
…e 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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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<char>[] 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<char>-typed
sibling cannot be compiled - the CLR disallows instantiating
FSharpFunc<ReadOnlySpan<char>, _> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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.
With no options from the project system yet, the quick parsing options
carried no source files, and ParseFile throws looking for the last
compiland. Nothing parsed with them before a project had loaded; the
Navigate To search that runs during load does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Navigate To runs a search of its own during load, dispatched through
`IAdvancedNavigateToSearchService`. F# did not implement it, so every F#
project was reported complete and searched not at all, and no full search
follows by design — nothing F# declares could be found until the user searched
again.

The search is a parse away. What it lacked is the project's compilation
options, which do not exist yet during load, so `GetFSharpParseResultsAsync`
raises. `GetFSharpQuickParseResultsAsync` parses with whatever parsing options
the project system has already produced, or defaults: a dictionary read, no
reactor, no I/O, which is what makes it safe to call for every document of
every project while the solution loads.

Those defines can be the wrong ones, and the document's version does not change
when the real options arrive, so the version stamp alone would let an
approximate parse answer the accurate search: a declaration behind `#if` could
be missed, or reported from a branch that never compiles. The cache entry
carries whether it was approximate, and the accurate path refuses those,
reparsing instead. The loading path takes either, since its contract allows
out-of-date results.

`SearchCachedDocumentsAsync` follows the shape of the C# and VB service:
priority documents and the projects that hold them are searched first, results
are reported per document rather than per project so the first ones appear
while the rest are still parsing, and each project is reported complete once it
is done. The parses of every project take turns on one throttle that leaves a
core free, so a search during load cannot take the cores away from the load
itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feature/navigate-to-while-loading branch from ddffeff to 6bf2a72 Compare September 11, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

1 participant