Match a path-mapped file name against the solution, not the current directory - #20519
xperiandri wants to merge 11 commits into
Conversation
✅ Release notes checked
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
| else | ||
| let projects = | ||
| currentDocument.Project.Solution.GetDocumentIdsWithFilePath(filePath) | ||
| currentDocument.Project.Solution.GetDocumentIdsWithFSharpFileName loc.FileName |
There was a problem hiding this comment.
🤖🕵️ [P1] Find All References loses the consumer's reference when an unrelated document has the mapped suffix. The executed service returns 0 references here; base returns 1. The suffix match selects only Unrelated and bypasses the assembly-reference fallback. Check the defining assembly before restricting the scope.
// C:\package\Library.fs -> ExternalLibrary.dll, --pathmap:C:\package=.
module ExternalLibrary
let value = 42
// Consumer/App.fs; references ExternalLibrary.dll
module Consumer
let result = ExternalLibrary.value // Find All References on value
// Unrelated/Library.fs; separate solution project, no reference to the DLL
module Unrelated
let value = 99There was a problem hiding this comment.
Fixed in 13d5226a53, the way you describe: the defining assembly is checked before the scope is narrowed.
A rooted name resolves to exactly its document, so it keeps the scope it finds. A relative one is matched by its tail, which an unrelated file can share, so the projects it reaches are filtered to those whose AssemblyName is the symbol's own assembly. When none is — your case, where only Unrelated matched — the list is empty and GetSymbolScope returns None, which is the scope the search had before the name reached any document at all, and the assembly-reference fallback runs as it did.
The filter deliberately does not apply to rooted names: a file linked into several projects legitimately reaches documents in all of them, and each compiles its own assembly.
| member self.GetDocumentIdsWithFSharpFileName(fileName: string) = | ||
| match fileName with | ||
| | null -> [] | ||
| | rooted when Path.IsPathRooted rooted -> self.GetDocumentIdsWithFilePath(Path.GetFullPathSafe rooted) |> List.ofSeq |
There was a problem hiding this comment.
🤖🕵️ [P2] Foreign mapped filenames now throw during document lookup on the editor's .NET Framework runtime. An imported DLL carrying this filename previously returned no matches; Path.IsPathRooted now throws before GetFullPathSafe can protect the lookup. Keep invalid/non-native filenames on a non-throwing path.
Imported declaration filename: /home/build/a|b/Library.fs
Base: 0 matching documents
HEAD: System.ArgumentException: Illegal characters in path.
There was a problem hiding this comment.
Fixed in 13d5226a53. isRootedPath answers false where Path.IsPathRooted would throw ArgumentException, and both callers — isTheFileAt and GetDocumentIdsWithFSharpFileName — go through it, so a name this platform cannot spell reaches no document instead of throwing out of a lookup whose callers expect an answer.
Covered by a name this platform cannot spell names no document in PathMapNavigationTests, with your /home/build/a|b/Library.fs.
| [ | ||
| for project in self.Projects do | ||
| for document in project.Documents do | ||
| if relative |> isTheFileAt document.FilePath then |
There was a problem hiding this comment.
🤖🕵️ [P2] Repeated external-declaration lookups add seconds and gigabytes of allocations to Find All References. Project.FindFSharpReferencesAsync resolves the same declaration once per searched project, so a missing mapped source repeats this whole-solution scan. On desktop CLR, 201 lookups over 20,000 documents measured 2.72 s and 1.91 GB allocated, versus 0.37 ms and 32 KB before. Normalize the suffix once and reuse lookup results, including misses, across the search.
// ExternalLibrary.dll was built from Library.fs with --pathmap:C:\package=.\
// Solution: 200 projects, 100 documents each, all referencing that DLL.
// Library.fs is not in the solution. Find All References on value:
let result = ExternalLibrary.value
// Repeated declaration lookup input: .\\Library.fsThere was a problem hiding this comment.
Fixed in 13d5226a53: the scan's answer is kept per solution, in a ConditionalWeakTable<Solution, ConcurrentDictionary<string, DocumentId list>> keyed by the name, so the whole-solution walk happens once per name however many projects the search covers. A miss is stored as the empty list, which is the case your measurement is about — a mapped source that is not in the solution is asked for once per project and found nowhere every time.
The table is keyed by the solution instance, so a new solution snapshot starts empty rather than answering from a stale document set, and nothing has to invalidate it.
Kept the two-call assertions in the documents a mapped name reaches are the same on every search — they guard the answer, not the timing; the 2.72 s and 1.91 GB in your repro come from the walk itself, which now runs once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1482407 to
8075288
Compare
T-Gro
left a comment
There was a problem hiding this comment.
🤖🕵️ 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.
|
🔍 Tooling Safety Check — Affects-Design-Time
|
…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>
A project built with DeterministicSourcePaths or an explicit PathMap hands the IDE a `--pathmap:` option. FCS applies the map when it pickles the ranges of the in-memory reference other projects check against, so every symbol imported from such a project names a mapped, relative file that no workspace document has, and Go To Definition ends in the generated signature instead of the source. The map is a property of the build output; the IDE now drops it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An assembly built with a path map names its source files relative to a root it never records. Resolving such a name with Path.GetFullPath resolved it against the process's current directory, which is not that root and is not even the solution's - it is wherever the last component to set it left it - so the answer differed between sessions and named a file that does not exist. Navigation then took the symbol for an external one and opened generated metadata instead of its source. A name that arrives relative is now matched by its tail against the paths the solution already holds, anchored on a separator so that it matches whole directories rather than the tail of one. A rooted name still goes through the workspace's index, so nothing changes for a build without a map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Finding the document a range names got the caret to the right file, and then the search for the declaration inside it compared the range's file name to the document's path with `=`. Under a path map the first is relative to a root the assembly never records and the second is absolute, so they never match: the search fell through to a full check of the file, looking for uses of a symbol that belongs to another compilation, and came back with nothing. Both places now go through one rule, `isTheFileAt`, rather than two spellings of it, so a name a path map left relative is matched by its tail wherever a file is identified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8f8c79f to
86ff6f3
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved moderate issues remain in path handling and Go To Definition/Find All References lookup paths.
Review effort: Lite
Findings: 3
Open (4)
What changed in this PR
Updates F# editor navigation to resolve path-mapped source filenames against solution documents.
Changes:
- Adds path-aware filename matching for navigation and symbol resolution.
- Removes path-map options from IDE project options.
- Adds multi-project tests, helpers, and release notes.
| File | Reviewed changes |
|---|---|
vsintegration/tests/FSharp.Editor.Tests/PathMapNavigationTests.fs |
Adds path-map navigation coverage; current test does not exercise relative declaration ranges. |
vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs |
Adds multi-target helpers; the new helpers currently have no test call sites. |
vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj |
Includes the new test sources. |
vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs |
Adds path-aware declaration comparison; earlier rooted-path lookups still bypass the new matcher. |
vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs |
Uses solution-aware file resolution; Find All References still uses the older declaration lookup. |
vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs |
Filters path-map options from IDE project options. |
vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs |
Adds relative filename matching; deterministic virtual roots and remaining Find All References lookup paths need correction. |
docs/release-notes/.VisualStudio/18.vNext.md |
Documents the fix; contains a duplicate path-map entry. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| | None -> Some docId | ||
|
|
||
| self.GetDocumentIdsWithFilePath filePath |> List.ofSeq |> matchingDoc | ||
| self.GetDocumentIdsWithFSharpFileName range.FileName |> matchingDoc |
There was a problem hiding this comment.
Right, and fixed in 5d3faff9e4: rangeToDocumentSpans now asks GetDocumentIdsWithFSharpFileName, so a declaration whose range a path map left relative gets its document and its span like any other, instead of being reported as a definition with no navigable location.
Two more lookups in the same class were left: rangeToNavigableItem resolved the range's file through the on-disk index although its caller had already found the document for that range — it now uses that document — and Go To Definition's DeclFound branch, answered in the thread on GoToDefinition.fs.
The resolver itself is covered by PathMapNavigationTests; the seven FindReferences tests and the Go To Definition tests still pass. What no editor test can build is the case these lookups are about — a reference to an assembly on disk that was really compiled with --pathmap — so the routing is what is verified, not an end-to-end mapped reference.
| else | ||
| let projects = | ||
| currentDocument.Project.Solution.GetDocumentIdsWithFilePath(filePath) | ||
| currentDocument.Project.Solution.GetDocumentIdsWithFSharpFileName loc.FileName |
There was a problem hiding this comment.
Fixed in 5d3faff9e4: rangeToDocumentSpans in FindUsagesService.fs now resolves the declaration range through GetDocumentIdsWithFSharpFileName, so a mapped name reaches its document and the definition gets a navigable span. The Go To Definition lookups you raise on GoToDefinition.fs went the same way in that commit.
|
|
||
| match targetSymbolUse.Symbol.DeclarationLocation with | ||
| | Some decl when decl.FileName = filePath -> return decl | ||
| | Some decl when decl.FileName |> isTheFileAt filePath -> return decl |
There was a problem hiding this comment.
Correct, and fixed in 5d3faff9e4. In FindDefinitionAtPosition the DeclFound branch now decides "external" by GetDocumentIdsWithFSharpFileName targetRange.FileName, and both places that then opened the target — the signature document for a declaration elsewhere, and the one reached from GetDeclarationLocation — go through TryGetDocumentFromFSharpRange targetRange, which handles a rooted name and a mapped one alike.
The paths built from a document's own file name (Path.ChangeExtension on originDocument.FilePath or on sigDocument.FilePath) keep TryGetDocumentFromPath: those come from the workspace and are rooted by construction.
rangeToNavigableItem was the third one: it resolved the range's file through the on-disk index although the caller had already found the document for that range, so it now takes that document.
| * Go To Definition, Find All References and Rename reach the source of a symbol whose assembly was built with `--pathmap` (as `DeterministicSourcePaths` sets it). Such an assembly names its files relative to a root it does not record, and that name was resolved against the process's current directory — which is not the root, and belongs to whatever last set it — so navigation landed on a file that does not exist and fell back to generated metadata. Such a name is now matched against the paths the solution already knows. | ||
| * Go To Definition, Find All References and Rename reach the source of a symbol whose assembly was built with `--pathmap` (as `DeterministicSourcePaths` sets it). Such an assembly names its files relative to a root it does not record, and that name was resolved against the process's current directory — which is not the root, and belongs to whatever last set it — so navigation landed on a file that does not exist and fell back to generated metadata. Such a name is now matched against the paths the solution already knows. ([PR #20519](https://github.com/dotnet/fsharp/pull/20519)) |
There was a problem hiding this comment.
Fixed in c9a267cb30: the copy without the link is gone. Linking the note added a second line instead of editing the first one.
A name a path map left relative is matched by its tail, so a file of an unrelated project that ends the same way answers to it. The search then restricted itself to that project and never looked at the one that references the assembly, so a use went missing while an unrelated declaration's uses were reported instead. Only a project that compiles the symbol's own assembly may narrow the search now; a rooted name, which resolves to exactly its document, is unaffected, and so is a file linked into several projects. Two more things the same lookup got wrong. A declaration imported from an assembly can carry a name written on another operating system, and `Path.IsPathRooted` throws on one where .NET Framework runs, out of a lookup whose callers expect "no document" for a name it cannot place. And the scan it falls back to walks every document of the solution while Find All References asks for the same name once per project it searches, so the answer is now kept for the solution - a miss as much as a hit, since a name outside the solution is asked for just as often. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scan compares every document of the solution against the name, and the comparison rebuilt the name each time: split, filtered, joined and interpolated per document, for a name that does not change between them. The tail a path must end with is built once for the lookup now, and each path is read where it lies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Go To Definition's `DeclFound` branch and the declaration spans Find All References reports still asked the workspace's index of the paths on disk for the file a range names, so a name a path map left relative reached no document there: the definition was called external and the reference lost its declaration. Both now go through the resolver that matches such a name against the solution's own paths. `rangeToNavigableItem` resolved the file again although its caller had already found the document for that range, so it uses that document. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Linking the note to the PR added a second copy of it instead of editing the first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>


Go To Definition, Find All References and Rename on a symbol whose assembly was built with a path map —
DeterministicSourcePaths, or a<PathMap>set inDirectory.Build.props— open generated metadata instead of the symbol's source.A path-mapped assembly names each source file relative to the map's root and does not record the root. The editor resolved that name with
Path.GetFullPath, that is, againstEnvironment.CurrentDirectory, which belongs to the process rather than the solution and holds whatever the last component to set it left there; the path it produced named a file that does not exist, no document matched, and the symbol was taken for an external one.Solution.GetDocumentIdsWithFSharpFileNamenow answers which documents a compiler range's file name denotes: a rooted name goes through the workspace's index as before, so nothing changes for a build without a map, and a relative name is matched by its tail against the paths the solution's documents already have, anchored on a separator so that it matches whole directories and never the tail of one — including the doubled separator the compiler writes when the map's replacement ends in one. The scan runs only for a relative name, which means only for a symbol declared in a path-mapped assembly, and once per navigation: the ranges of the references found are the solution's own and rooted. Paths are compared case-insensitively; they are not identifiers.This is the editor half. #20518 stops the compiler naming the directory twice in
GetDeclarationLocation, and #20470 keeps the map out of the options the IDE builds. Two lookups in other pending work resolve the same kind of name and will route through the same rule:GetSolutionDocumentsWithFilePathin #20462, and the comparisonFindSymbolDeclarationInDocumentmakes in #20492.Stacked on #20470, which adds
PathMapNavigationTests.fs; the diff shrinks to its own commit once that merges.🤖 Generated with Claude Code