Search each file of a multi-targeted F# project once in Go To All - #20483
Search each file of a multi-targeted F# project once in Go To All#20483xperiandri wants to merge 4 commits into
Conversation
…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>
❗ Release notes requiredYou can open this PR in browser to add release notes: open in github.dev
|
| syntheticProject.GetAllProjects() | ||
| |> List.distinctBy _.Name | ||
| |> List.map (fun project -> project, ProjectId.CreateNewId()) |
There was a problem hiding this comment.
| syntheticProject.GetAllProjects() | |
| |> List.distinctBy _.Name | |
| |> List.map (fun project -> project, ProjectId.CreateNewId()) | |
| syntheticProject.GetAllProjects() | |
| |> Seq.distinctBy _.Name | |
| |> Seq.map (fun project -> project, ProjectId.CreateNewId()) | |
| |> Seq.toList |
| let instances = | ||
| project.Solution.Projects | ||
| |> Seq.filter (fun p -> p.FilePath = projectPath) | ||
| |> Seq.map _.Id | ||
| |> List.ofSeq | ||
|
|
||
| fun (document: Document) -> | ||
| match document.FilePath with | ||
| | null -> true | ||
| | path -> | ||
| let documentIds = project.Solution.GetDocumentIdsWithFilePath path | ||
|
|
||
| let owner = | ||
| instances | ||
| |> List.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id)) |
There was a problem hiding this comment.
I suppose it will be more performant, no?
| let instances = | |
| project.Solution.Projects | |
| |> Seq.filter (fun p -> p.FilePath = projectPath) | |
| |> Seq.map _.Id | |
| |> List.ofSeq | |
| fun (document: Document) -> | |
| match document.FilePath with | |
| | null -> true | |
| | path -> | |
| let documentIds = project.Solution.GetDocumentIdsWithFilePath path | |
| let owner = | |
| instances | |
| |> List.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id)) | |
| let instances = | |
| project.Solution.Projects | |
| |> Seq.filter (fun p -> p.FilePath = projectPath) | |
| |> Seq.map _.Id | |
| |> Seq.toArray | |
| fun (document: Document) -> | |
| match document.FilePath with | |
| | null -> true | |
| | path -> | |
| let documentIds = project.Solution.GetDocumentIdsWithFilePath path | |
| let owner = | |
| instances | |
| |> Array.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id)) |
| instances | ||
| |> List.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id)) | ||
|
|
||
| owner = project.Id |
There was a problem hiding this comment.
🤖🕵️ Shared declarations disappear from Current Project searches on a non-owner target after the cache warms.
// Existing multi-target fixture; fresh service, second target active.
let p = solution.GetProject instances[1]
let run () =
service.SearchProjectAsync(p, ImmutableArray.Empty, "plainUse",
service.KindsProvided, CancellationToken.None).Result
run () // contains plainUse
run () // emptyRoslyn submits only the active project for this scope. Preserve project-local results; the solution-order owner is not searched.
| let cache = ConcurrentDictionary<DocumentId, VersionStamp * NavigableItem array>() | ||
|
|
||
| /// Whether the file's parse depends on the defines, by file path: known once any instance has parsed it. | ||
| let conditionalDirectives = |
There was a problem hiding this comment.
🤖🕵️ Newly conditional declarations are missing from the first solution search after an edit. Warm the cache on a shared file without directives, then replace its text in both target instances with:
module ModuleSecond
#if FOO
let addedFoo = 1
#endifSearching addedFoo in the FOO instance first, then the plain owner, returns no result; repeating finds it. Roslyn prioritizes the active project, so this order occurs in normal searches. Validate the cached flag against the current text version before skipping.
Description
Go To All (Ctrl+T / Code Search) on a multi-targeted F# solution showed F# results late — often only on the second search — and the window stalled while it searched. Reproduced on a solution with 135 project instances (26 project files, five target frameworks each for the app projects).
How the search runs: Roslyn's
NavigateToSearcherhands the F# service every project instance one after another (the ExternalAccess bridge loopsSearchProjectAsyncover the projects of a group), and only publishes a project's results when the whole project is done. The F# service then:DocumentIdand the defines differ;Change (
NavigateToSearchService.fs):ParsedInput … Trivia.ConditionalDirectives), remembered per file path from whichever instance parsed the file first; an instance that meets a file before any parse of it searches it as before. Results for one file therefore come from one instance, except under conditional compilation.ProcessorCountat a time (whenAllThrottled).Not changed: Roslyn searches only its own persisted index while the solution is not fully loaded (
SearchCachedDocumentsAsyncis skipped for services withoutIAdvancedNavigateToSearchService), so F# results still appear only once the solution has loaded; that needs an ExternalAccess extension.Tests:
MultiTargetNavigateToSearchTestsloads one project as two instances (one withoutFOOand without the fourth file, one with both) throughRoslynTestHelpers.CreateMultiTargetSolutionand searches both instances in solution order: a declaration in a file every instance compiles, one under#if FOO, and one in the instance-only file are each reported exactly once. The first commit (test helpers) is shared with #20462.No timings are claimed: per search the work goes from one parse and one file read per file per framework to one parse per file (plus the files with conditional directives) and a read per matched file.
Checklist
Test cases added
Performance benchmarks added in case of performance changes
Release notes entry updated:
🤖 Generated with Claude Code