Skip to content

Match a path-mapped file name against the solution, not the current directory - #20519

Open
xperiandri wants to merge 11 commits into
dotnet:mainfrom
xperiandri:fix/pathmap-relative-document-names
Open

xperiandri wants to merge 11 commits into
dotnet:mainfrom
xperiandri:fix/pathmap-relative-document-names

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Go To Definition, Find All References and Rename on a symbol whose assembly was built with a path map — DeterministicSourcePaths, or a <PathMap> set in Directory.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, against Environment.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.GetDocumentIdsWithFSharpFileName now 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: GetSolutionDocumentsWithFilePath in #20462, and the comparison FindSymbolDeclarationInDocument makes in #20492.

Stacked on #20470, which adds PathMapNavigationTests.fs; the diff shrinks to its own commit once that merges.

🤖 Generated with Claude Code

@github-actions

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

xperiandri added a commit to xperiandri/fsharp that referenced this pull request Sep 11, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager label Sep 11, 2026
@github-actions

This comment has been minimized.

else
let projects =
currentDocument.Project.Solution.GetDocumentIdsWithFilePath(filePath)
currentDocument.Project.Solution.GetDocumentIdsWithFSharpFileName loc.FileName

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.

🤖🕵️ [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 = 99

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

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

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

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] 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.fs

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

@T-Gro
T-Gro self-requested a review September 11, 2026 12:59
@T-Gro T-Gro added the AI-reviewed PR reviewed by AI review council label Sep 11, 2026
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Sep 11, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the fix/pathmap-relative-document-names branch from 1482407 to 8075288 Compare September 11, 2026 16:16

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

🤖🕵️ 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.

@github-project-automation github-project-automation Bot moved this from New to In Progress in F# Compiler and Tooling Sep 15, 2026
@T-Gro T-Gro added the vsintegration-only Changes only Visual Studio integration, plus optional docs, release notes, or tests label Sep 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Design-Time
Affects-Design-Time: Compiler service or Visual Studio behavior changed.

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

xperiandri and others added 7 commits September 26, 2026 03:00
…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>
Copilot AI lite review requested due to automatic review settings September 26, 2026 01:00
@xperiandri
xperiandri force-pushed the fix/pathmap-relative-document-names branch from 8f8c79f to 86ff6f3 Compare September 26, 2026 01:00

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

Unresolved moderate issues remain in path handling and Go To Definition/Find All References lookup paths.

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

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

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.

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

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

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.

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.

Comment on lines +10 to +11
* 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))

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 c9a267cb30: the copy without the link is gone. Linking the note added a second line instead of editing the first one.

xperiandri and others added 2 commits September 26, 2026 03:52
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>
xperiandri and others added 2 commits September 26, 2026 13:11
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>

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 vsintegration-only Changes only Visual Studio integration, plus optional docs, release notes, or tests

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants