diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 2d032095814..6bf314a41f4 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -23,6 +23,7 @@ * 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 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)) +* 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 diff --git a/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs b/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs index e0b29c8f9f1..ecd0fa48a90 100644 --- a/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs @@ -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) + |> 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 diff --git a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs b/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs index 8a5debfdc84..5c297d7fac9 100644 --- a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs +++ b/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs @@ -102,6 +102,35 @@ type internal InlineRenameInfo let symbolDisplayName = symbolUse.Symbol.DisplayName + /// 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 @@ -151,23 +180,32 @@ 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) - - return - [| - for symbolUse in symbolUses do - match Tokenizer.TryFSharpRangeToTextSpanForEditor(sourceText, symbolUse, symbolDisplayName) with - | ValueSome textSpan -> yield FSharpInlineRenameLocation(document, textSpan) - | ValueNone -> () - |] - } + match solution.GetDocument documentId with + | null -> () + | document -> + yield + cancellableTask { + let! cancellationToken = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync(cancellationToken) + let! symbolUses = usesInTextOf document sourceText symbolUses + + return + [| + for symbolUse in symbolUses do + match + Tokenizer.TryFSharpRangeToTextSpanForEditor(sourceText, symbolUse, symbolDisplayName) + with + | ValueSome textSpan -> yield FSharpInlineRenameLocation(document, textSpan) + | ValueNone -> () + |] + } } |> CancellableTask.whenAll 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..fccb12bc21e 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs @@ -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 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..263f6479817 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,53 @@ 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 { + match! this.FindSymbolDeclarationInDocument(targetSymbolUse, implDocument) 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 +456,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 +496,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 } diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index a62b25af1cf..2a9f3ba1ca4 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -95,6 +95,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs b/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs index fe10a42a125..0640253448b 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,216 @@ 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. The app document also + /// holds an edit of its buffer that the workspace has not seen. + 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 ]\nlet again = ModuleLibrary.f 2" + } + ) with + DependsOn = [ library ] + } + + let struct (solution, _) = RoslynTestHelpers.CreateMultiProjectSolution app + let appPath = app.GetFilePath "App" + let libraryPath = library.GetFilePath "Library" + let unsavedEdit = "// not saved yet" + + let private documentId path = + solution.GetDocumentIdsWithFilePath path |> Seq.exactlyOne + + /// The app document of the stale snapshot, holding the text `edit` makes of the saved one. + let appDocumentWith (edit: string -> string) = + let appId = documentId appPath + + let savedText = + solution.GetDocument(appId).GetTextAsync(CancellationToken.None).Result.ToString() + + solution.RemoveDocument(documentId libraryPath).WithDocumentText(appId, SourceText.From(edit savedText)).GetDocument(appId) + + let appDocument = + appDocumentWith (fun saved -> $"{saved}{Environment.NewLine}{unsavedEdit}") + + 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}" + + [] + [] + [] + [] + let ``rename from a snapshot that predates the declaring document edits the documents of that snapshot`` + (insertedBeforeUse: string, appended: string) + = + let useOfF = "ModuleLibrary.f" + + let document = + StaleSnapshot.appDocumentWith (fun saved -> saved.Replace($"= {useOfF}", $"= {insertedBeforeUse}{useOfF}") + appended) + + let snapshot = document.Project.Solution + + let originText = document.GetTextAsync(CancellationToken.None).Result.ToString() + + let position = + originText.LastIndexOf(useOfF, StringComparison.Ordinal) + useOfF.Length - 1 + + let renameInfo = + InlineRenameService().GetRenameInfoAsync(document, position, CancellationToken.None).Result + + Assert.NotNull renameInfo + + let locations = + renameInfo.FindRenameLocationsAsync(false, false, CancellationToken.None).Result + + Assert.Equal(2, locations.Locations.Count) + + Assert.All( + locations.Locations, + (fun location -> + Assert.Same(snapshot, location.Document.Project.Solution) + Assert.Equal("f", location.Document.GetTextAsync(CancellationToken.None).Result.ToString location.TextSpan)) + ) + + let newSolution = + locations.GetReplacementsAsync("g", CancellationToken.None).Result.NewSolution + + // What Roslyn's rename session commits: the documents changed against the solution it started from. + let changedDocuments = + newSolution.GetChanges(snapshot).GetProjectChanges() + |> Seq.collect _.GetChangedDocuments() + |> Seq.toList + + Assert.Equal([ document.Id ], changedDocuments) + + let renamedText = + newSolution.GetDocument(document.Id).GetTextAsync(CancellationToken.None).Result.ToString() + + Assert.Equal(originText.Replace(useOfF, "ModuleLibrary.g"), renamedText) + + [] + let ``goto definition from a signature reaches the implementation its own target instance compiles`` () = + let project = + SyntheticProject.Create( + "MultiTarget", + { sourceFile "A" [] with + Source = "#if NET\nlet onlyOnNet = 1\n#endif" + SignatureFile = SignatureFile.Custom "#if NET\nval onlyOnNet: int\n#endif" + } + ) + + let struct (solution, instances) = + RoslynTestHelpers.CreateMultiTargetSolution( + project, + [ + { Defines = []; ExcludedFileIds = [] } + { + Defines = [ "NET" ] + ExcludedFileIds = [] + } + ] + ) + + let netInstance = solution.GetProject(List.last instances) + + let signature = + netInstance.Documents + |> Seq.find (fun document -> document.FilePath = project.GetSignatureFilePath "A") + + let position = + signature.GetTextAsync(CancellationToken.None).Result.ToString().IndexOf("onlyOnNet", StringComparison.Ordinal) + + let result = + GoToDefinition(FSharpMetadataAsSourceService()).FindDefinitionAtPosition(signature, position) + |> CancellableTask.runSynchronouslyWithoutCancellation + + match result with + | ValueSome(FSharpGoToDefinitionResult.NavigableItem item, _) -> + Assert.Equal(project.GetFilePath "A", item.Document.FilePath) + Assert.Equal(netInstance.Id, item.Document.Project.Id) + | result -> failwith $"expected the implementation, got %A{result}"