Skip to content

Search a project's priority documents first in Navigate To - #20615

Open
xperiandri wants to merge 11 commits into
dotnet:mainfrom
xperiandri:fix/navigate-to-priority-documents
Open

xperiandri wants to merge 11 commits into
dotnet:mainfrom
xperiandri:fix/navigate-to-priority-documents

Conversation

@xperiandri

Copy link
Copy Markdown
Contributor

Roslyn passes SearchProjectAsync a set of priorityDocuments — the documents the user already has open — but the F# implementation never looked at it, searching project.Documents in whatever order the project enumerates them. On a large project, the throttled parse queue could spend its first slots on files the user has never opened, so declarations from their own open files only showed up once that first batch finished.

Partition the project's documents by priority before searching, and search the priority batch first. The open documents are parsed and their results returned before the rest of the project is even queued.

Stacked on #20483 — only the last commit here is new; the diff will shrink to just that once #20483 merges.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 22, 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

@github-actions github-actions Bot added the ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager label Sep 22, 2026
@github-actions

This comment has been minimized.

@xperiandri
xperiandri force-pushed the fix/navigate-to-priority-documents branch from 6ca4511 to 2419dc1 Compare September 22, 2026 11:34
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@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.

🤖 🕵️ AI review — verify independently.

return items
return NavigateTo.GetNavigableItems parseResults.ParseTree
| path ->
let defines = document.GetFSharpQuickDefines() |> String.concat ";"

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.

🤖 🕵️ [P2] Cold linked projects show declarations from the wrong #if branch — the fallback COMPILED;EDITING cache key reuses the FOO project's parse in the non-FOO project.

// Shared path and text version; parsing options not yet cached.
// Search the FOO project, then the project without FOO.
module Shared
#if FOO
let fooOnly = 1
#else
let plainOnly = 1
#endif
// Non-FOO project: fooOnly is returned; plainOnly is missing.

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.

The sharing is real, and it is the approximate path rather than a leak into the exact one.

A project whose options have not arrived has no defines to parse with, so the document is parsed with the editing defaults and the entry is stored under them. Two instances that are both cold therefore read one entry, and for a file with #if that entry can hold the wrong branch. What keeps it out of the exact search is the flag on the entry: GetNavigableItems takes a cached entry only when not entry.Approximate (line 139), and every entry written from a quick parse carries Approximate = true. The only caller that reads them is GetNavigableItemsWhileLoading, whose contract is explicitly "results from a previously computed cache, even if that cache is out of date", and whose results the search window marks as incomplete.

So the declarations from the wrong branch are visible exactly where stale declarations are allowed to be, and the first exact search reparses with the instance's real defines and overwrites the entry.

What is worth tightening is the one case where the two are indistinguishable: a project whose real defines happen to equal the editing defaults would read a cold instance's approximate entry as its own. The flag still refuses it for the exact search, so the effect is a redundant reparse rather than a wrong result — but the key deserves to say "defines unknown" rather than spell out a define set the instance never asked for, the same way AnyDefines says "no defines matter here". I will make that key distinct.

|> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount)

// The documents the user has open go first, so they are parsed and cached before the rest of the project.
let! priorityResults = search priority

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.

🤖 🕵️ [P2] Priority batching delays all Navigate To results — two 700 ms document loads on 16 workers took about 1.4 s instead of 0.8 s; this API publishes nothing until both batches finish.

let! results = search (Seq.append priority rest)
return results |> Array.concat |> Array.toImmutableArray

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 one I do not think holds, because of which method it is on.

IFSharpNavigateToSearchService.SearchProjectAsync returns Task<ImmutableArray<FSharpNavigateToSearchResult>>: it has no callback and cannot publish anything before it returns, so the user sees nothing from this project until the whole project is searched, with or without the priority split. Ordering the documents inside it cannot delay that — the same documents are searched on the same throttle, only in a different order — and the measured 1.4 s against 0.8 s is what two batches of one document each cost when the batches are run one after another, which is the shape the repro builds rather than the shape here: priority documents and the rest go through one whenAllThrottled over one sequence, not two awaited halves.

Where publishing early does exist is the loading path, SearchCachedDocumentsAsync, which takes onResultsFound and reports per document as each finishes — and that one already searches priority documents first, for exactly the reason you are pointing at.

If the ordering inside a single throttled pass is measurably worse than none, I would like to see it as a run of the editor tests rather than a synthetic two-document case, since the whole point of the parameter is that Roslyn hands us the documents the user is looking at.

@T-Gro T-Gro added the AI-reviewed PR reviewed by AI review council label Sep 25, 2026
@T-Gro
T-Gro self-requested a review September 25, 2026 08:54
xperiandri and others added 2 commits September 26, 2026 02:18
…r tests

Test helpers so far put every synthetic file into one Roslyn project. CreateMultiProjectSolution
creates one project per synthetic project with project references, the way VS wires
project-to-project references; CreateMultiTargetSolution creates one project per target
instance sharing the project path and the document paths, the way VS loads a multi-targeted
project.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ead its text only for matches

Roslyn's NavigateTo searcher hands the F# service every target-framework
instance of a project, one after another, and the service parsed every file of
each instance, read the text of every file before matching anything, and
started all of that for a project at once. On a solution with 135 project
instances that meant one parse and one file read per file per framework,
thousands of concurrent tasks, and results that arrived long after the user
stopped typing.

The first instance of a project file in the solution now searches every file;
the other instances only search the files they alone compile and the files
whose parse depends on the defines, known from whichever instance parsed the
file first. A file's text is read only when one of its declarations matched,
and a project's files are searched at most ProcessorCount at a time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
xperiandri and others added 7 commits September 26, 2026 02:18
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Skipping a file in every instance but the first is wrong twice over, as the
review showed. Go To All scoped to the current project submits only that
project, so the instance that was told to stay quiet is the only one asked, and
a shared declaration disappears from it once the parse has taught the service
the file holds no directives. And the flag that decision reads was keyed by path
alone, so an edit that puts a declaration behind `#if` left the previous answer
in place.

Nothing needs skipping. `NavigateToSearcher` pools its seen set with
`NavigateToSearchResultComparer`, which already collapses results by file path
and span, so every instance can report what it compiles.

What the instances should share is the parse, which is what costs. A parse whose
tree holds no conditional directives does not depend on the defines, so it is
kept under a key no define set can equal and every instance reuses it; one that
does hold them is kept per define set, since those instances genuinely parse the
file differently. The entry carries the text version, so an edit is a new entry
rather than a stale flag.

The test that asserted a declaration is reported once across the instances went
with the skip: that is the searcher's job, not this service's. In its place are
the two cases from the review — a lone instance reporting a shared declaration,
and a declaration an edit puts behind a directive — and one that keeps the
define-keyed parse honest by checking such a declaration does not reach the
instance without the define.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cache was a dictionary from a struct tuple of two strings to an anonymous
struct record, spelled out in the type arguments at the one place it is
declared, and read back as `struct (key, path)` wherever it is used. Nothing at
those sites said which string was the defines and which the path.

Give both a name at the top of the file. They stay struct records, so the key
keeps the same equality and the entries allocate no more than before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IFSharpNavigateToSearchService.SearchProjectAsync is handed the documents the
user has open, and F# named the parameter and ignored it: every document of the
project went through the same throttled pass in project order. The search that
runs while the solution loads already puts them first, as does Roslyn's own
C# and Visual Basic search.

Search the priority documents in one throttled pass and the rest of the project
in a second, so the files in view are parsed and cached before the others.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 26, 2026 00:18
@xperiandri
xperiandri force-pushed the fix/navigate-to-priority-documents branch from 2419dc1 to f56537e Compare September 26, 2026 00:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Address the parsing-options cache key, clarify the non-streaming behavior, and correct duplicate or overstated release notes.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

Updates F# Navigate To to prioritize open documents and improve multi-target parsing efficiency.

Changes:

  • Adds priority-document batching, throttling, and parse-result caching.
  • Adds multi-target tests and Roslyn test helpers.
  • Registers tests and updates release notes.
File Summary
vsintegration/​tests/​FSharp.Editor.Tests/​NavigateToSearchServiceTests.fs Tests priority document ordering.
vsintegration/​tests/​FSharp.Editor.Tests/​MultiTargetNavigateToSearchTests.fs Adds multi-target caching coverage.
vsintegration/​tests/​FSharp.Editor.Tests/​Helpers/​RoslynHelpers.fs Adds multi-target solution helpers.
vsintegration/​tests/​FSharp.Editor.Tests/​FSharp.Editor.Tests.fsproj Registers the new tests.
vsintegration/​src/​FSharp.Editor/​Navigation/​NavigateToSearchService.fs Implements priority ordering, throttling, and caching.
docs/​release-notes/​.VisualStudio/​18.vNext.md Documents the Navigate To changes.

return items
return NavigateTo.GetNavigableItems parseResults.ParseTree
| path ->
let defines = document.GetFSharpQuickDefines() |> String.concat ";"

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.

You are right, and it was worse than the language version: fixed in 6e78b9dccf.

The key now carries the language version beside the defines, and both are read only where the project has produced its options — TryGetCompilationDefinesAndLangVersionForEditingDocument, which answers nothing instead of the editing defaults. A project whose options have not arrived parses for itself. What made that necessary is that the editing defaults are not a neutral placeholder: they are exactly the define set a project that defines nothing of its own really has, so a document keyed under them read that project's parse — with the branch of a #if that project chose, not its own.

The remaining FSharpParsingOptions cannot differ for one path: ApplyLineDirectives is false for every document the editor parses, and IsInteractive follows from the file's own extension.

New test, a project whose options have not arrived does not answer from another project's parse: two projects hold the same file behind one TextLoader — a linked file open in the editor, which is the only way the two documents report the same text version and one can read the other's entry at all — one of them with options that define nothing, the other with none. Before the fix the second answered from the first's parse; now it asks for the parse it cannot have and is cancelled, as it was before any entry existed. Thirteen *NavigateTo* tests pass.

* Build a single-file project's `OtherOptions` reference flags with one array comprehension instead of two `Array.ofSeq` calls and an `Array.append`. ([PR #20499](https://github.com/dotnet/fsharp/pull/20499))
* Fix syntax coloring being lost for a whole file when one symbol resolves into metadata that could not be read. ([Issue #20269](https://github.com/dotnet/fsharp/issues/20269), [PR #20274](https://github.com/dotnet/fsharp/pull/20274))
* Go To All (Ctrl+T) on a multi-targeted F# project no longer parses and reads every file once per target framework: the first instance searches every file, the others only the files they alone compile or that use conditional compilation, and a file's text is read only for a matched declaration. ([PR #20483](https://github.com/dotnet/fsharp/pull/20483))
* Go To All (Ctrl+T) on a multi-targeted F# project no longer parses every file once per target framework: a file whose parse holds no conditional directives does not depend on the defines, so every instance reuses the one parse, and a file's text is read only for a matched declaration. ([PR #20483](https://github.com/dotnet/fsharp/pull/20483))

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.

Fixed in 810adcd2e2, the second way you suggest. The entry describing the old approach — the first instance searches every file, the others only what they alone compile — was superseded by the one describing the parse sharing that replaced it, and stayed behind. It is now the note for this change, the priority documents, and the one for #20483 remains.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

xperiandri and others added 2 commits September 26, 2026 13:38
The defines a file was parsed with are not the whole of what its tree depends on: the
language version decides what the parser accepts, and the defines were taken from the
editing defaults when the project had not produced its options yet - a set another
project really has, so its parse answered for a project that never asked for it, with
the branch of a `#if` that project chose.

The key now carries the language version, and both it and the defines are read only
where the project has produced them: a project without options parses for itself
instead of reading an entry that is not about it. The rest of the parsing options
cannot differ for one path - the editor never applies line directives, and whether a
file is interactive follows from its extension.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The note for the parse sharing replaced the one for skipping files, which stayed; this
change had none of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Design-Time
Affects-Design-Time: Compiler-service or IDE design-time behavior changes.

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

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-Design-Time Tooling check: PR touches type providers or dependency manager AI-reviewed PR reviewed by AI review council

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

3 participants