Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
34d460a
Build multi-project and multi-target Roslyn solutions in FSharp.Edito…
xperiandri Sep 6, 2026
cd696e9
Resolve navigation targets through the workspace's current solution
xperiandri Sep 6, 2026
d4370d3
Test Go To Definition against a solution snapshot that predates the t…
xperiandri Sep 6, 2026
0957b3f
Add the release note for PR #20462
xperiandri Sep 6, 2026
b7ae603
Document TryGetSolutionDocumentFromPath
xperiandri Sep 16, 2026
6009f9d
Route the symbol scope's project list through Seq
xperiandri Sep 16, 2026
a8f6891
Route the multi-project test helper's project list through Seq
xperiandri Sep 16, 2026
15bde63
Collapse the declaration lookup into match!
xperiandri Sep 16, 2026
bfbea5a
Make TargetInstance a struct record
xperiandri Sep 16, 2026
f114645
Feed the excluded paths to the HashSet as a seq
xperiandri Sep 16, 2026
5c590bb
Make CreateMultiTargetSolution's instance tuples struct
xperiandri Sep 16, 2026
e1f171b
Document CreateMultiTargetSolution and return its results as a struct…
xperiandri Sep 16, 2026
35b6f23
Prefer the origin's own project when a path names several documents
xperiandri Sep 26, 2026
b1ea8a1
Rename only the uses the rename's own solution holds
xperiandri Oct 1, 2026
d95d3e1
Test that Go To Definition reads the origin's own target instance
xperiandri Oct 1, 2026
52cd981
Find a rename's uses in the text the rename's own solution holds
xperiandri Oct 11, 2026
9ddcd11
Bring the rebased branch to what it had before the rebase
xperiandri Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/release-notes/.VisualStudio/18.vNext.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* 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 Definition into an F# project built with a path map (`DeterministicSourcePaths` or `PathMap`) opens its source instead of a generated signature: the IDE no longer applies `--pathmap` to the project options it checks with. ([PR #20470](https://github.com/dotnet/fsharp/pull/20470))
* Fix completion after a closed backticked identifier on the same line. ([PR #20785](https://github.com/dotnet/fsharp/issues/20785))
* Go To Definition on a symbol declared in another project of the solution no longer opens a generated signature when the origin document comes from a solution snapshot that predates that project's documents: navigation, Find All References and Rename now look the target up in the workspace's current solution as well. ([PR #20462](https://github.com/dotnet/fsharp/pull/20462))

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,36 @@ type Solution with
| Some projectId -> self.TryGetDocumentIdFromFSharpRange(range, projectId)
| None -> self.TryGetDocumentIdFromFSharpRange range
|> Option.map self.GetDocument

type Document with

/// Runs a lookup against this document's solution and, when it finds nothing, against the
/// workspace's current solution: the document may come from a snapshot taken before every
/// project of the solution finished loading.
member document.TryFindInSolutions(find: Solution -> 'T voption) =
match find document.Project.Solution with
| ValueSome found -> ValueSome found
| ValueNone -> find document.Project.Solution.Workspace.CurrentSolution

/// Every document with the file path, from whichever project includes it.
member document.GetSolutionDocumentsWithFilePath(filePath: string) =
let filePath = Path.GetFullPathSafe filePath

document.TryFindInSolutions(fun solution ->
match solution.GetDocumentIdsWithFilePath filePath with
| ids when ids.IsEmpty -> ValueNone
| ids -> ValueSome [ for id in ids -> solution.GetDocument id ])
|> ValueOption.defaultValue []

/// The document for the range's file, preferring this document's project or one it depends on.
member document.TryGetSolutionDocumentFromFSharpRange(range: range) =
document.TryFindInSolutions(fun solution ->
solution.TryGetDocumentFromFSharpRange(range, document.Project.Id)

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.

🤖🕵️ Stale target-framework document suppresses the current solution match.

App -> Library.net
stale: Library.netstandard/Library.fs
current: Library.net + Library.netstandard

|> ValueOption.ofOption)

/// The document with the file path, preferring this document's project or one it depends on: the
/// instances of a multi-targeted project hold the same file, and the file is read under the defines of
/// whichever instance answers, so the origin's own instance is the one that answers about its own code.
member document.TryGetSolutionDocumentFromPath(filePath: string) =
Range.mkRange filePath Position.pos0 Position.pos0
|> document.TryGetSolutionDocumentFromFSharpRange
142 changes: 89 additions & 53 deletions vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,35 @@ type internal InlineRenameInfo
ValueSome(FSharpKeywords.NormalizeIdentifierBackticks symbolDisplayName)
| _ -> ValueNone

/// The search reads the text the workspace holds for a document, so the ranges it finds are ranges of that
/// text. Where the rename's solution holds another text, the uses are found in the text the rename edits.
let usesInTextOf (document: Document) (text: SourceText) (found: range[]) =
cancellableTask {
let! cancellationToken = CancellableTask.getCancellationToken ()

let! searchedThisText =
match document.Project.Solution.Workspace.CurrentSolution.GetDocument document.Id with
| null -> CancellableTask.singleton false
| searched ->
cancellableTask {
let! searchedText = searched.GetTextAsync cancellationToken
return text.ContentEquals searchedText
}

if searchedThisText then
return found
else
let! _, checkFileResults = document.GetFSharpParseAndCheckResultsAsync(nameof (InlineRenameInfo))

return
checkFileResults.GetUsesOfSymbolInFile(
symbolUse.Symbol,
relatedSymbolKinds = RelatedSymbolUseKind.All,
cancellationToken = cancellationToken
)
|> Array.map _.Range
}

override _.CanRename = true
override _.LocalizedErrorMessage = null
override _.TriggerSpan = triggerSpan
Expand Down Expand Up @@ -157,62 +186,69 @@ type internal InlineRenameInfo
cancellableTask {
let! symbolUsesByDocumentId = symbolUses

// Roslyn's rename session commits only documents of the solution it started from, and the search can
// find a document through the workspace's newer solution: such a use cannot be part of this rename.
let solution = document.Project.Solution

let! results =
seq {
for (KeyValue(documentId, symbolUses)) in symbolUsesByDocumentId do

cancellableTask {
let! cancellationToken = CancellableTask.getCancellationToken ()
let document = document.Project.Solution.GetDocument(documentId)
let! sourceText = document.GetTextAsync(cancellationToken)

let! recordOptions =
match recordTypeName with
| ValueSome name ->
cancellableTask {
let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof (InlineRenameInfo))
return ValueSome(defines, langVersion, name)
}
| ValueNone -> CancellableTask.singleton ValueNone

let tryGetEditSpan (textSpan: TextSpan) =
match recordOptions with
| ValueSome(defines, langVersion, name) when not textSpan.IsEmpty ->
// Record construction/update references can describe expressions, not type-name tokens.
match
Tokenizer.getSymbolAtPosition (
document.Id,
sourceText,
textSpan.End - 1,
document.FilePath,
defines,
SymbolLookupKind.Greedy,
false,
false,
Some langVersion,
cancellationToken
)
with
| Some token when FSharpKeywords.NormalizeIdentifierBackticks token.Ident.idText = name ->
match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, token.Range) with
| ValueSome tokenSpan when tokenSpan.End = textSpan.End && tokenSpan.Start >= textSpan.Start ->
ValueSome tokenSpan
| _ -> ValueNone
| _ -> ValueNone
| ValueSome _ -> ValueNone
| ValueNone -> ValueSome textSpan

return
[|
for symbolUse in symbolUses do
match
Tokenizer.TryFSharpRangeToTextSpanForEditor(sourceText, symbolUse, symbolDisplayName)
|> ValueOption.bind tryGetEditSpan
with
| ValueSome textSpan -> yield FSharpInlineRenameLocation(document, textSpan)
| _ -> ()
|]
}
match solution.GetDocument documentId with
| null -> ()
| document ->
yield
cancellableTask {
let! cancellationToken = CancellableTask.getCancellationToken ()
let! sourceText = document.GetTextAsync(cancellationToken)
let! symbolUses = usesInTextOf document sourceText symbolUses

let! recordOptions =
match recordTypeName with
| ValueSome name ->
cancellableTask {
let! defines, langVersion = document.GetFsharpParsingOptionsAsync(nameof (InlineRenameInfo))
return ValueSome(defines, langVersion, name)
}
| ValueNone -> CancellableTask.singleton ValueNone

let tryGetEditSpan (textSpan: TextSpan) =
match recordOptions with
| ValueSome(defines, langVersion, name) when not textSpan.IsEmpty ->
// Record construction/update references can describe expressions, not type-name tokens.
match
Tokenizer.getSymbolAtPosition (
document.Id,
sourceText,
textSpan.End - 1,
document.FilePath,
defines,
SymbolLookupKind.Greedy,
false,
false,
Some langVersion,
cancellationToken
)
with
| Some token when FSharpKeywords.NormalizeIdentifierBackticks token.Ident.idText = name ->
match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, token.Range) with
| ValueSome tokenSpan when tokenSpan.End = textSpan.End && tokenSpan.Start >= textSpan.Start ->
ValueSome tokenSpan
| _ -> ValueNone
| _ -> ValueNone
| ValueSome _ -> ValueNone
| ValueNone -> ValueSome textSpan

return
[|
for symbolUse in symbolUses do
match
Tokenizer.TryFSharpRangeToTextSpanForEditor(sourceText, symbolUse, symbolDisplayName)
|> ValueOption.bind tryGetEditSpan
with
| ValueSome textSpan -> yield FSharpInlineRenameLocation(document, textSpan)
| ValueNone -> ()
|]
}
}
|> CancellableTask.whenAll

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ module internal SymbolHelpers =
let otherFile = getOtherFile currentDocument.FilePath

let! otherFileCheckResults =
match currentDocument.Project.Solution.TryGetDocumentFromPath otherFile with
match currentDocument.TryGetSolutionDocumentFromPath otherFile with
| ValueSome doc ->
cancellableTask {
let! _, checkFileResults = doc.GetFSharpParseAndCheckResultsAsync("findReferencedSymbolsAsync")
Expand Down
7 changes: 3 additions & 4 deletions vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,9 @@ type FSharpSymbolUse with
Some(SymbolScope.Projects([ currentDocument.Project ], isSymbolLocalForProject))
else
let projects =
currentDocument.Project.Solution.GetDocumentIdsWithFilePath(filePath)
|> Seq.map (fun x -> x.ProjectId)
|> Seq.distinct
|> Seq.map currentDocument.Project.Solution.GetProject
currentDocument.GetSolutionDocumentsWithFilePath filePath
|> Seq.map _.Project
|> Seq.distinctBy _.Id
|> Seq.toList

match projects with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,15 @@ module FSharpFindUsagesService =
}

// File can be included in more than one project, hence single `range` may results with multiple `Document`s.
let rangeToDocumentSpans (solution: Solution, range: range, symbolName: string) =
let rangeToDocumentSpans (document: Document, range: range, symbolName: string) =
if range.Start = range.End then
CancellableTask.singleton [||]
else
cancellableTask {
let documentIds = solution.GetDocumentIdsWithFilePath(range.FileName)

let! spans =
seq {
for documentId in documentIds do
for doc in document.GetSolutionDocumentsWithFilePath range.FileName do
cancellableTask {
let doc = solution.GetDocument(documentId)
let! cancellationToken = CancellableTask.getCancellationToken ()
let! sourceText = doc.GetTextAsync(cancellationToken)

Expand Down Expand Up @@ -119,7 +116,7 @@ module FSharpFindUsagesService =

let! declarationSpans =
match declarationRange with
| Some range -> rangeToDocumentSpans (document.Project.Solution, range, symbol.Ident.idText)
| Some range -> rangeToDocumentSpans (document, range, symbol.Ident.idText)
| None -> CancellableTask.singleton [||]

let declarationSpans =
Expand Down
Loading
Loading