From e1d4056bec09e451693a693bc91b20d1382a0980 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 09:42:41 +0200 Subject: [PATCH 01/13] Build multi-project and multi-target Roslyn solutions in FSharp.Editor 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 --- .../Helpers/RoslynHelpers.fs | 146 +++++++++++++++++- 1 file changed, 140 insertions(+), 6 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 25509f14ace..89a449eceb7 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -201,6 +201,14 @@ type TestHostServices() = override this.CreateWorkspaceServices(workspace) = new TestHostWorkspaceServices(this, workspace) +/// One Roslyn project instance of a multi-targeted F# project: its extra defines and the +/// synthetic files left out of it, as VS does per target framework. +type TargetInstance = + { + Defines: string list + ExcludedFileIds: string list + } + [] type RoslynTestHelpers private () = @@ -258,6 +266,33 @@ type RoslynTestHelpers private () = filePath = filePath ) + static member private ProjectInfoFor + (id, name, filePath, outputFilePath, documents, projectReferences: ProjectReference list, metadataReferences: MetadataReference seq) + = + ProjectInfo.Create( + id, + VersionStamp.Create(DateTime.UtcNow), + name, + name, + LanguageNames.FSharp, + filePath = filePath, + outputFilePath = outputFilePath, + documents = documents, + projectReferences = projectReferences, + metadataReferences = metadataReferences + ) + + static member private MetadataReferencesOf(options: FSharpProjectOptions, excludedPaths: string seq) = + let excluded = HashSet(excludedPaths, StringComparer.OrdinalIgnoreCase) + + options.OtherOptions + |> Seq.filter (fun x -> x.StartsWith("-r:", StringComparison.Ordinal)) + |> Seq.map _.Substring(3) + |> Seq.filter (excluded.Contains >> not) + |> Seq.map MetadataReference.CreateFromFile + |> Seq.cast + |> Seq.toList + static member SetProjectOptions projId (solution: Solution) (options: FSharpProjectOptions) = solution.Workspace.Services .GetService() @@ -331,12 +366,8 @@ type RoslynTestHelpers private () = let options = syntheticProject.GetProjectOptions checker - let metadataReferences = - options.OtherOptions - |> Seq.filter (fun x -> x.StartsWith("-r:")) - |> Seq.map (fun x -> x.Substring(3) |> MetadataReference.CreateFromFile :> MetadataReference) - - let projInfo = projInfo.WithMetadataReferences metadataReferences + let projInfo = + projInfo.WithMetadataReferences(RoslynTestHelpers.MetadataReferencesOf(options, [])) let solution = RoslynTestHelpers.CreateSolution [ projInfo ] @@ -344,6 +375,109 @@ type RoslynTestHelpers private () = solution, checker + /// One Roslyn project per synthetic project, wired with project references the way VS wires + /// project-to-project references, so the options manager builds in-memory F# references. + static member CreateMultiProjectSolution(syntheticProject: SyntheticProject) = + let checker = syntheticProject.SaveAndCheck() + + let projects = + syntheticProject.GetAllProjects() + |> List.distinctBy _.Name + |> List.map (fun project -> project, ProjectId.CreateNewId()) + + let projectIds = dict [ for project, id in projects -> project.Name, id ] + + let projectInfos = + [ + for project, id in projects do + let options = project.GetProjectOptions checker + + RoslynTestHelpers.ProjectInfoFor( + id, + project.Name, + project.ProjectFileName, + project.OutputFilename, + [ + for path in project.SourceFilePaths -> RoslynTestHelpers.CreateDocumentInfo id path (File.ReadAllText path) + ], + [ + for dependency in project.DependsOn -> ProjectReference projectIds[dependency.Name] + ], + RoslynTestHelpers.MetadataReferencesOf(options, project.DependsOn |> List.map _.OutputFilename) + ) + ] + + let solution = RoslynTestHelpers.CreateSolution projectInfos + + for project, id in projects do + project.GetProjectOptions checker + |> RoslynTestHelpers.SetProjectOptions id solution + + solution, checker + + /// One Roslyn project per target instance, all sharing the .fsproj path and the document file + /// paths, like the per-target-framework projects VS creates for a multi-targeted project. + static member CreateMultiTargetSolution(syntheticProject: SyntheticProject, instances: TargetInstance list) = + assert (syntheticProject.DependsOn = []) + + let checker = syntheticProject.SaveAndCheck() + let options = syntheticProject.GetProjectOptions checker + let metadataReferences = RoslynTestHelpers.MetadataReferencesOf(options, []) + + let instances = + [ + for instance in instances -> + let excludedPaths = + HashSet( + [ + for fileId in instance.ExcludedFileIds do + syntheticProject.GetFilePath fileId + + if (syntheticProject.Find fileId).HasSignatureFile then + syntheticProject.GetSignatureFilePath fileId + ], + StringComparer.OrdinalIgnoreCase + ) + + let sourceFiles = + syntheticProject.SourceFilePaths |> List.filter (excludedPaths.Contains >> not) + + let id = ProjectId.CreateNewId() + + let projectInfo = + RoslynTestHelpers.ProjectInfoFor( + id, + syntheticProject.Name, + syntheticProject.ProjectFileName, + syntheticProject.OutputFilename, + [ + for path in sourceFiles -> RoslynTestHelpers.CreateDocumentInfo id path (File.ReadAllText path) + ], + [], + metadataReferences + ) + + let instanceOptions = + { options with + SourceFiles = List.toArray sourceFiles + OtherOptions = + [| + yield! options.OtherOptions + for define in instance.Defines -> $"--define:{define}" + |] + } + + id, projectInfo, instanceOptions + ] + + let solution = + RoslynTestHelpers.CreateSolution [ for _, projectInfo, _ in instances -> projectInfo ] + + for id, _, instanceOptions in instances do + RoslynTestHelpers.SetProjectOptions id solution instanceOptions + + solution, [ for id, _, _ in instances -> id ] + static member GetFsDocument(code, ?customProjectOption: string, ?customEditorOptions) = let customProjectOptions = customProjectOption From 0125a60f5caaacf3e2ac6f534d7bb8b62e1b6d4d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 09:42:41 +0200 Subject: [PATCH 02/13] Resolve navigation targets through the workspace's current solution The document Roslyn hands to Go To Definition can come from a snapshot taken before every project of the solution finished loading. Deciding that a symbol is external because that snapshot has no document for its file sent F# to F# navigation into a generated signature, and the same lookup silently narrowed the scope of Find All References and Rename. Look the target up in the document's own solution first and in Workspace.CurrentSolution when it is missing, normalising the path and preferring the target-framework instance the origin project depends on. The branch of Go To Definition that already sits on the declaration moves into its own member. Co-Authored-By: Claude Fable 5.1 --- .../Common/CodeAnalysisExtensions.fs | 29 ++ .../LanguageService/SymbolHelpers.fs | 2 +- .../FSharp.Editor/LanguageService/Symbols.fs | 8 +- .../Navigation/FindUsagesService.fs | 9 +- .../Navigation/GoToDefinition.fs | 247 ++++++++---------- 5 files changed, 142 insertions(+), 153 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs b/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs index e0b29c8f9f1..1eaaaed3180 100644 --- a/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs @@ -117,3 +117,32 @@ 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 [] + + member document.TryGetSolutionDocumentFromPath(filePath: string) = + document.GetSolutionDocumentsWithFilePath filePath |> Seq.tryHeadV + + /// 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) + |> ValueOption.ofOption) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs b/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs index 36319820f80..becd935453a 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs @@ -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") diff --git a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs index 19e446f2d08..ecedeb3e536 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs @@ -64,11 +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 - |> Seq.toList + currentDocument.GetSolutionDocumentsWithFilePath filePath + |> List.map _.Project + |> List.distinctBy _.Id match projects with | [] -> None diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index a81d2ec3fed..fbdf37397e3 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -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) @@ -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 = diff --git a/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs b/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs index 05da71ef97e..81ed086e6ba 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs @@ -174,29 +174,26 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = && symbol1.DeclaringEntity.CompiledName = symbol2.DeclaringEntity.CompiledName | _ -> false + /// The navigable item for the range in the document, when the range fits the document's text. + let navigableItemAt (document: Document) (range: range) = + cancellableTask { + let! cancellationToken = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync(cancellationToken) + + return + RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, range) + |> ValueOption.map (fun textSpan -> FSharpGoToDefinitionNavigableItem(document, textSpan)) + } + /// Use an origin document to provide the solution & workspace used to /// find the corresponding textSpan and INavigableItem for the range let rangeToNavigableItem (range: range, document: Document) = cancellableTask { - let fileName = - try - System.IO.Path.GetFullPath range.FileName - with _ -> - range.FileName - - let refDocumentIds = document.Project.Solution.GetDocumentIdsWithFilePath fileName - - if not refDocumentIds.IsEmpty then - let refDocumentId = refDocumentIds.First() - let refDocument = document.Project.Solution.GetDocument refDocumentId - let! cancellationToken = Async.CancellationToken - let! refSourceText = refDocument.GetTextAsync(cancellationToken) |> Async.AwaitTask - - match RoslynHelpers.TryFSharpRangeToTextSpan(refSourceText, range) with - | ValueNone -> return None - | ValueSome refTextSpan -> return Some(FSharpGoToDefinitionNavigableItem(refDocument, refTextSpan)) - else - return None + match document.TryGetSolutionDocumentFromFSharpRange range with + | ValueNone -> return None + | ValueSome refDocument -> + let! navItem = navigableItemAt refDocument range + return ValueOption.toOption navItem } member _.TryGetExternalDeclarationAsync(targetSymbolUse: FSharpSymbolUse, metadataReferences: seq) = @@ -312,7 +309,7 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = if not (File.Exists fsfilePath) then return None else - let implDoc = originDocument.Project.Solution.TryGetDocumentFromPath fsfilePath + let implDoc = originDocument.TryGetSolutionDocumentFromPath fsfilePath match implDoc with | ValueNone -> return None @@ -336,14 +333,9 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = | ValueNone -> return None | ValueSome implTextSpan -> return Some(FSharpGoToDefinitionNavigableItem(implDoc, implTextSpan)) else - let targetDocument = - originDocument.Project.Solution.TryGetDocumentFromFSharpRange fsSymbolUse.Range - - match targetDocument with - | None -> return None - | Some targetDocument -> - let! navItem = rangeToNavigableItem (fsSymbolUse.Range, targetDocument) - return navItem + match originDocument.TryGetSolutionDocumentFromFSharpRange fsSymbolUse.Range with + | ValueNone -> return None + | ValueSome targetDocument -> return! rangeToNavigableItem (fsSymbolUse.Range, targetDocument) } /// if the symbol is defined in the given file, return its declaration location, otherwise use the targetSymbol to find the first @@ -373,6 +365,55 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = return implSymbol.Range } + /// The navigable item for the target symbol's declaration in the implementation document. + member private this.FindNavigableDeclarationIn(targetSymbolUse: FSharpSymbolUse, implDocument: Document) = + cancellableTask { + let! declarationRange = this.FindSymbolDeclarationInDocument(targetSymbolUse, implDocument) + + match declarationRange with + | None -> return ValueNone + | Some declarationRange -> return! navigableItemAt implDocument declarationRange + } + + /// The caret is already on the declaration: in a signature file the target is the implementation, + /// in an implementation file it is the signature. + member private this.FindCounterpartOfDeclarationAtCaret + ( + originDocument: Document, + targetSymbolUse: FSharpSymbolUse, + checkFileResults: FSharpCheckFileResults, + lexerSymbol: LexerSymbol, + fcsTextLineNumber: int, + textLineString: string + ) = + cancellableTask { + if isSignatureFile originDocument.FilePath then + let implFilePath = Path.ChangeExtension(originDocument.FilePath, "fs") + + if not (File.Exists implFilePath) then + return ValueNone + else + match originDocument.TryGetSolutionDocumentFromPath implFilePath with + | ValueNone -> return ValueNone + | ValueSome implDocument -> return! this.FindNavigableDeclarationIn(targetSymbolUse, implDocument) + else + let declarations = + checkFileResults.GetDeclarationLocation( + fcsTextLineNumber, + lexerSymbol.Ident.idRange.EndColumn, + textLineString, + lexerSymbol.FullIsland, + true + ) + + match declarations with + | FindDeclResult.DeclFound sigRange -> + match originDocument.TryGetSolutionDocumentFromFSharpRange sigRange with + | ValueNone -> return ValueNone + | ValueSome sigDocument -> return! navigableItemAt sigDocument sigRange + | _ -> return ValueNone + } + member internal this.FindDefinitionAtPosition(originDocument: Document, position: int) = cancellableTask { let userOpName = "FindDefinitionAtPosition" @@ -417,8 +458,9 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = match declarations with | FindDeclResult.ExternalDecl(assembly, targetExternalSym) -> let projectOpt = - originDocument.Project.Solution.Projects - |> Seq.tryFindV (fun p -> p.AssemblyName.Equals(assembly, StringComparison.OrdinalIgnoreCase)) + originDocument.TryFindInSolutions(fun solution -> + solution.Projects + |> Seq.tryFindV (fun p -> p.AssemblyName.Equals(assembly, StringComparison.OrdinalIgnoreCase))) match projectOpt with | ValueSome project -> @@ -456,122 +498,45 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = return ValueSome(FSharpGoToDefinitionResult.ExternalAssembly(targetSymbolUse, metadataReferences), idRange) | FindDeclResult.DeclFound targetRange -> - // If the file is not associated with a document, it's considered external. - if not (originDocument.Project.Solution.ContainsDocumentWithFilePath(targetRange.FileName)) then + match originDocument.TryGetSolutionDocumentFromFSharpRange targetRange with + | ValueNone -> + // No document for the file anywhere in the workspace: the symbol comes from an assembly. let metadataReferences = originDocument.Project.MetadataReferences return ValueSome(FSharpGoToDefinitionResult.ExternalAssembly(targetSymbolUse, metadataReferences), idRange) - else if - // if goto definition is called as we are already at the declaration location of a symbol in - // either a signature or an implementation file then we jump to its respective position in the document - lexerSymbol.Range = targetRange - then - // jump from signature to the corresponding implementation - if isSignatureFile originDocument.FilePath then - let implFilePath = Path.ChangeExtension(originDocument.FilePath, "fs") - - if not (File.Exists implFilePath) then - return ValueNone + | ValueSome _ when lexerSymbol.Range = targetRange -> + let! navItem = + this.FindCounterpartOfDeclarationAtCaret( + originDocument, + targetSymbolUse, + checkFileResults, + lexerSymbol, + fcsTextLineNumber, + textLineString + ) + + return + navItem + |> ValueOption.map (fun navItem -> FSharpGoToDefinitionResult.NavigableItem navItem, idRange) + | ValueSome targetDocument -> + // gotoDefn origin = signature, destination = signature; origin = implementation, destination = implementation + let! navItem = + if isSignatureFile targetRange.FileName && preferSignature then + navigableItemAt targetDocument targetRange else - let implDocument = - originDocument.Project.Solution.TryGetDocumentFromPath implFilePath - - match implDocument with - | ValueNone -> return ValueNone - | ValueSome implDocument -> - let! targetRange = this.FindSymbolDeclarationInDocument(targetSymbolUse, implDocument) - - match targetRange with - | None -> return ValueNone - | Some targetRange -> - let! implSourceText = implDocument.GetTextAsync(cancellationToken) - - let implTextSpan = - RoslynHelpers.TryFSharpRangeToTextSpan(implSourceText, targetRange) - - match implTextSpan with - | ValueNone -> return ValueNone - | ValueSome implTextSpan -> - let navItem = FSharpGoToDefinitionNavigableItem(implDocument, implTextSpan) - return ValueSome(FSharpGoToDefinitionResult.NavigableItem(navItem), idRange) - - else // jump from implementation to the corresponding signature - let declarations = - checkFileResults.GetDeclarationLocation( - fcsTextLineNumber, - idRange.EndColumn, - textLineString, - lexerSymbol.FullIsland, - true - ) + // Bugfix: apparently the target document is not always a signature file + let implFilePath = + if isSignatureFile targetDocument.FilePath then + Path.ChangeExtension(targetDocument.FilePath, "fs") + else + targetDocument.FilePath - match declarations with - | FindDeclResult.DeclFound targetRange -> - let sigDocument = - originDocument.Project.Solution.TryGetDocumentFromPath targetRange.FileName - - match sigDocument with - | ValueNone -> return ValueNone - | ValueSome sigDocument -> - let! sigSourceText = sigDocument.GetTextAsync(cancellationToken) - - let sigTextSpan = RoslynHelpers.TryFSharpRangeToTextSpan(sigSourceText, targetRange) - - match sigTextSpan with - | ValueNone -> return ValueNone - | ValueSome sigTextSpan -> - let navItem = FSharpGoToDefinitionNavigableItem(sigDocument, sigTextSpan) - return ValueSome(FSharpGoToDefinitionResult.NavigableItem(navItem), idRange) - | _ -> return ValueNone - // when the target range is different follow the navigation convention of - // - gotoDefn origin = signature , gotoDefn destination = signature - // - gotoDefn origin = implementation, gotoDefn destination = implementation - else - let sigDocument = - originDocument.Project.Solution.TryGetDocumentFromPath targetRange.FileName + match originDocument.TryGetSolutionDocumentFromPath implFilePath with + | ValueNone -> CancellableTask.singleton ValueNone + | ValueSome implDocument -> this.FindNavigableDeclarationIn(targetSymbolUse, implDocument) - match sigDocument with - | ValueNone -> return ValueNone - | ValueSome sigDocument -> - let! sigSourceText = sigDocument.GetTextAsync(cancellationToken) - let sigTextSpan = RoslynHelpers.TryFSharpRangeToTextSpan(sigSourceText, targetRange) - - match sigTextSpan with - | ValueNone -> return ValueNone - | ValueSome sigTextSpan -> - // if the gotodef call originated from a signature and the returned target is a signature, navigate there - if isSignatureFile targetRange.FileName && preferSignature then - let navItem = FSharpGoToDefinitionNavigableItem(sigDocument, sigTextSpan) - return ValueSome(FSharpGoToDefinitionResult.NavigableItem(navItem), idRange) - else // we need to get an FSharpSymbol from the targetRange found in the signature - // that symbol will be used to find the destination in the corresponding implementation file - let implFilePath = - // Bugfix: apparently sigDocument not always is a signature file - if isSignatureFile sigDocument.FilePath then - Path.ChangeExtension(sigDocument.FilePath, "fs") - else - sigDocument.FilePath - - let implDocument = - originDocument.Project.Solution.TryGetDocumentFromPath implFilePath - - match implDocument with - | ValueNone -> return ValueNone - | ValueSome implDocument -> - let! targetRange = this.FindSymbolDeclarationInDocument(targetSymbolUse, implDocument) - - match targetRange with - | None -> return ValueNone - | Some targetRange -> - let! implSourceText = implDocument.GetTextAsync(cancellationToken) - - let implTextSpan = - RoslynHelpers.TryFSharpRangeToTextSpan(implSourceText, targetRange) - - match implTextSpan with - | ValueNone -> return ValueNone - | ValueSome implTextSpan -> - let navItem = FSharpGoToDefinitionNavigableItem(implDocument, implTextSpan) - return ValueSome(FSharpGoToDefinitionResult.NavigableItem(navItem), idRange) + return + navItem + |> ValueOption.map (fun navItem -> FSharpGoToDefinitionResult.NavigableItem navItem, idRange) | _ -> return ValueNone } From bc81990fcea176564f12a07bbb5d80c8b78ef9c0 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 09:42:41 +0200 Subject: [PATCH 03/13] Test Go To Definition against a solution snapshot that predates the target document Co-Authored-By: Claude Fable 5.1 --- .../GoToDefinitionServiceTests.fs | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs b/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs index fe10a42a125..68d8755ac89 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs @@ -2,6 +2,8 @@ namespace FSharp.Editor.Tests +open System +open System.Threading open Xunit open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text @@ -9,8 +11,8 @@ open Microsoft.VisualStudio.FSharp.Editor open FSharp.Compiler.EditorServices open FSharp.Compiler.Text open FSharp.Editor.Tests.Helpers +open FSharp.Test.ProjectGeneration open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks -open System.Threading module GoToDefinitionServiceTests = @@ -149,3 +151,111 @@ let f_IWSAM_flex_StaticProperty(x: #IStaticProperty<'T>) = let expected = Some(3, 3, 20, 34) GoToDefinitionTest(fileContents, caretMarker, expected) + + let private symbolUseAt (document: Document) (sourceText: SourceText) position = + maybe { + let textLine = sourceText.Lines.GetLineFromPosition position + let fcsTextLineNumber = Line.fromZ (sourceText.Lines.GetLinePosition position).Line + + let! lexerSymbol = + Tokenizer.getSymbolAtPosition ( + document.Id, + sourceText, + position, + document.FilePath, + [], + SymbolLookupKind.Greedy, + false, + false, + None, + CancellationToken.None + ) + + let _, checkFileResults = + document.GetFSharpParseAndCheckResultsAsync userOpName + |> CancellableTask.runSynchronouslyWithoutCancellation + + return! + checkFileResults.GetSymbolUseAtLocation( + fcsTextLineNumber, + lexerSymbol.Ident.idRange.EndColumn, + textLine.ToString(), + lexerSymbol.FullIsland + ) + } + + /// An app project referencing a library project. The app document comes from a snapshot that + /// predates the library's document, the way Roslyn hands out documents while a solution is + /// still loading, while the workspace's current solution already has it. + module internal StaleSnapshot = + + let library = SyntheticProject.Create("Library", sourceFile "Library" []) + + let app = + { SyntheticProject.Create( + "App", + { sourceFile "App" [ "Library" ] with + ExtraSource = "let mapped = List.map id [ 1 ]" + } + ) with + DependsOn = [ library ] + } + + let solution, _ = RoslynTestHelpers.CreateMultiProjectSolution app + let appPath = app.GetFilePath "App" + let libraryPath = library.GetFilePath "Library" + + let private documentId path = + solution.GetDocumentIdsWithFilePath path |> Seq.exactlyOne + + let appDocument = + solution.RemoveDocument(documentId libraryPath).GetDocument(documentId appPath) + + let appSourceText = appDocument.GetTextAsync(CancellationToken.None).Result + + /// The position of the last character of the text, inside the identifier it ends with. + let positionOf (text: string) = + appSourceText.ToString().IndexOf(text, StringComparison.Ordinal) + text.Length + - 1 + + let findDefinitionAt position = + GoToDefinition(FSharpMetadataAsSourceService()).FindDefinitionAtPosition(appDocument, position) + |> CancellableTask.runSynchronouslyWithoutCancellation + + [] + let ``goto definition finds the target document through the workspace when the origin snapshot predates it`` () = + let position = StaleSnapshot.positionOf "ModuleLibrary.f" + let document = StaleSnapshot.appDocument + + let range = + findDefinition (document, StaleSnapshot.appSourceText, position, [], None) + |> Option.defaultWith (fun () -> failwith "declaration not found") + + Assert.Equal(StaleSnapshot.libraryPath, range.FileName) + Assert.True(Option.isNone (document.Project.Solution.TryGetDocumentFromFSharpRange(range, document.Project.Id))) + + match document.TryGetSolutionDocumentFromFSharpRange range with + | ValueSome target -> Assert.Equal(StaleSnapshot.libraryPath, target.FilePath) + | ValueNone -> failwith "the workspace's current solution has the library document" + + match StaleSnapshot.findDefinitionAt position with + | ValueSome(FSharpGoToDefinitionResult.NavigableItem item, _) -> Assert.Equal(StaleSnapshot.libraryPath, item.Document.FilePath) + | result -> failwith $"expected a navigable item, got %A{result}" + + [] + let ``goto definition treats a symbol whose file is in no solution as external`` () = + match StaleSnapshot.findDefinitionAt (StaleSnapshot.positionOf "List.map") with + | ValueSome(FSharpGoToDefinitionResult.ExternalAssembly _, _) -> () + | result -> failwith $"expected an external assembly, got %A{result}" + + [] + let ``find references scope includes the declaring project the origin snapshot does not know`` () = + let position = StaleSnapshot.positionOf "ModuleLibrary.f" + + let symbolUse = + symbolUseAt StaleSnapshot.appDocument StaleSnapshot.appSourceText position + |> Option.defaultWith (fun () -> failwith "symbol not found") + + match symbolUse.GetSymbolScope StaleSnapshot.appDocument with + | Some(SymbolScope.Projects(projects, _)) -> Assert.Contains(StaleSnapshot.library.Name, projects |> List.map _.Name) + | scope -> failwith $"expected a project scope, got %A{scope}" From d59b0679798638c3de6954dc9be88762452599f7 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 09:46:14 +0200 Subject: [PATCH 04/13] Add the release note for PR #20462 Co-Authored-By: Claude Fable 5.1 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index e6034dca8df..f065b6f6f4f 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -20,6 +20,7 @@ * Reduce allocations in the VS project options reactor: the command-line options and project options caches and the mailbox reply payloads now hold struct tuples, and `IProjectSite.CompilationBinOutputPath` returns `string voption` picked with a new `Array.tryPickV`. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413)) * 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 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 From a198fd439c9b4497ab0521baa959939ae5bc04a5 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 15:45:17 +0200 Subject: [PATCH 05/13] Document TryGetSolutionDocumentFromPath Co-Authored-By: Claude Sonnet 5 --- vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs b/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs index 1eaaaed3180..dd3b687b9a5 100644 --- a/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs @@ -138,6 +138,7 @@ type Document with | ids -> ValueSome [ for id in ids -> solution.GetDocument id ]) |> ValueOption.defaultValue [] + /// The first document with the file path, from whichever project includes it. member document.TryGetSolutionDocumentFromPath(filePath: string) = document.GetSolutionDocumentsWithFilePath filePath |> Seq.tryHeadV From 5b6d3fba4fde37c9d767459b25a6853e6d244915 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 15:51:29 +0200 Subject: [PATCH 06/13] Route the symbol scope's project list through Seq List.map followed by List.distinctBy allocates the mapped list and then the distinct one; a Seq pipeline materializes once, at Seq.toList. Co-Authored-By: Claude Sonnet 5 --- vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs index ecedeb3e536..fccb12bc21e 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs @@ -65,8 +65,9 @@ type FSharpSymbolUse with else let projects = currentDocument.GetSolutionDocumentsWithFilePath filePath - |> List.map _.Project - |> List.distinctBy _.Id + |> Seq.map _.Project + |> Seq.distinctBy _.Id + |> Seq.toList match projects with | [] -> None From 0b6ccbb61b081cdfab9f2620610088dc16dff5d6 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 16:00:04 +0200 Subject: [PATCH 07/13] Route the multi-project test helper's project list through Seq Co-Authored-By: Claude Sonnet 5 --- .../tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 89a449eceb7..0ef629aa6c4 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -382,8 +382,9 @@ type RoslynTestHelpers private () = let projects = syntheticProject.GetAllProjects() - |> List.distinctBy _.Name - |> List.map (fun project -> project, ProjectId.CreateNewId()) + |> Seq.distinctBy _.Name + |> Seq.map (fun project -> struct (project, ProjectId.CreateNewId())) + |> Seq.toArray let projectIds = dict [ for project, id in projects -> project.Name, id ] From 8e58be6bdfb37e42bf35a36c8b95f1a0721bef1e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 16:03:05 +0200 Subject: [PATCH 08/13] Collapse the declaration lookup into match! Co-Authored-By: Claude Sonnet 5 --- vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs b/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs index 81ed086e6ba..263f6479817 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs @@ -368,9 +368,7 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = /// The navigable item for the target symbol's declaration in the implementation document. member private this.FindNavigableDeclarationIn(targetSymbolUse: FSharpSymbolUse, implDocument: Document) = cancellableTask { - let! declarationRange = this.FindSymbolDeclarationInDocument(targetSymbolUse, implDocument) - - match declarationRange with + match! this.FindSymbolDeclarationInDocument(targetSymbolUse, implDocument) with | None -> return ValueNone | Some declarationRange -> return! navigableItemAt implDocument declarationRange } From aa6db4df5df07ab5047ecfb391f7f78f0ed3e897 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 16:06:51 +0200 Subject: [PATCH 09/13] Make TargetInstance a struct record Co-Authored-By: Claude Fable 5.1 --- vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 0ef629aa6c4..51b0be1b7cc 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -203,6 +203,7 @@ type TestHostServices() = /// One Roslyn project instance of a multi-targeted F# project: its extra defines and the /// synthetic files left out of it, as VS does per target framework. +[] type TargetInstance = { Defines: string list From d830ea5ff41f849e639f06c998810528f098c016 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 16:11:47 +0200 Subject: [PATCH 10/13] Feed the excluded paths to the HashSet as a seq Co-Authored-By: Claude Fable 5.1 --- .../tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 51b0be1b7cc..92a6628d685 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -431,13 +431,13 @@ type RoslynTestHelpers private () = for instance in instances -> let excludedPaths = HashSet( - [ + seq { for fileId in instance.ExcludedFileIds do syntheticProject.GetFilePath fileId if (syntheticProject.Find fileId).HasSignatureFile then syntheticProject.GetSignatureFilePath fileId - ], + }, StringComparer.OrdinalIgnoreCase ) From e38d254ad967739c4cb07bf5433e55ac7a15e6a0 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 16:50:48 +0200 Subject: [PATCH 11/13] Make CreateMultiTargetSolution's instance tuples struct Co-Authored-By: Claude Sonnet 5 --- .../tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 92a6628d685..6a19c584895 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -469,7 +469,7 @@ type RoslynTestHelpers private () = |] } - id, projectInfo, instanceOptions + struct (id, projectInfo, instanceOptions) ] let solution = From 0c7c5c25f748ccb9dd2096b7c167e7604176fc25 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 16 Sep 2026 17:39:33 +0200 Subject: [PATCH 12/13] Document CreateMultiTargetSolution and return its results as a struct tuple CreateMultiProjectSolution and CreateMultiTargetSolution now both return a struct tuple, so callers destructure with the struct pattern. Co-Authored-By: Claude Sonnet 5 --- .../FSharp.Editor.Tests/GoToDefinitionServiceTests.fs | 2 +- .../tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs b/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs index 68d8755ac89..a4c6d60b1c4 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs @@ -201,7 +201,7 @@ let f_IWSAM_flex_StaticProperty(x: #IStaticProperty<'T>) = DependsOn = [ library ] } - let solution, _ = RoslynTestHelpers.CreateMultiProjectSolution app + let struct (solution, _) = RoslynTestHelpers.CreateMultiProjectSolution app let appPath = app.GetFilePath "App" let libraryPath = library.GetFilePath "Library" diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 6a19c584895..72e5ac9a27d 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -415,10 +415,15 @@ type RoslynTestHelpers private () = project.GetProjectOptions checker |> RoslynTestHelpers.SetProjectOptions id solution - solution, checker + struct (solution, checker) + /// /// One Roslyn project per target instance, all sharing the .fsproj path and the document file /// paths, like the per-target-framework projects VS creates for a multi-targeted project. + /// + /// The project every instance is made from; it must not depend on other projects. + /// One Roslyn project per entry: its extra defines and the synthetic files left out of it. + /// The solution and the id of each instance's project, in the order of . static member CreateMultiTargetSolution(syntheticProject: SyntheticProject, instances: TargetInstance list) = assert (syntheticProject.DependsOn = []) @@ -478,7 +483,7 @@ type RoslynTestHelpers private () = for id, _, instanceOptions in instances do RoslynTestHelpers.SetProjectOptions id solution instanceOptions - solution, [ for id, _, _ in instances -> id ] + struct (solution, [ for id, _, _ in instances -> id ]) static member GetFsDocument(code, ?customProjectOption: string, ?customEditorOptions) = let customProjectOptions = From 98a1a20be36b69356916dbf7fcc32545d2fbf81b Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 14:30:55 +0200 Subject: [PATCH 13/13] Prefer the origin's own project when a path names several documents A sibling file looked up by path took whichever document came first, while the same lookup by range prefers the origin's project or one it depends on. The instances of a multi-targeted project hold the same file and read it under their own defines, so the path lookup now goes through that rule too. Co-Authored-By: Claude Opus 5 (1M context) --- .../FSharp.Editor/Common/CodeAnalysisExtensions.fs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs b/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs index dd3b687b9a5..ecd0fa48a90 100644 --- a/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs @@ -138,12 +138,15 @@ type Document with | ids -> ValueSome [ for id in ids -> solution.GetDocument id ]) |> ValueOption.defaultValue [] - /// The first document with the file path, from whichever project includes it. - member document.TryGetSolutionDocumentFromPath(filePath: string) = - document.GetSolutionDocumentsWithFilePath filePath |> Seq.tryHeadV - /// 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) |> 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