Search a project's priority documents first in Navigate To - #20615
xperiandri wants to merge 11 commits into
Conversation
✅ Release notes checked
|
This comment has been minimized.
This comment has been minimized.
6ca4511 to
2419dc1
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.
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.
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.
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.
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.
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.
🤖 🕵️ AI review — verify independently.
| return items | ||
| return NavigateTo.GetNavigableItems parseResults.ParseTree | ||
| | path -> | ||
| let defines = document.GetFSharpQuickDefines() |> String.concat ";" |
There was a problem hiding this comment.
🤖 🕵️ [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.There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
🤖 🕵️ [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.toImmutableArrayThere was a problem hiding this comment.
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.
…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>
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>
2419dc1 to
f56537e
Compare
There was a problem hiding this comment.
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
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 ";" |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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.
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.
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>
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
🔍 Tooling Safety Check — Affects-Design-Time
|


Roslyn passes
SearchProjectAsynca set ofpriorityDocuments— the documents the user already has open — but the F# implementation never looked at it, searchingproject.Documentsin 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