diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ef64e1a75c4..93a176db9e9 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -14,6 +14,7 @@ * Make Alt+F1 (momentary toggle) work for inlay hints. ([PR #19421](https://github.com/dotnet/fsharp/pull/19421)) * Fix doubled F# diagnostics in tooltips. ([Issue #16360](https://github.com/dotnet/fsharp/issues/16360)) * Fix `NotSupportedException` in the memory-mapped-file optimization when copying `ReadOnlyMemory` into `MemoryMappedFileViewStream`. ([Issue #20263](https://github.com/dotnet/fsharp/issues/20263)) +* 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..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 c313d4f27ee..446fff005ec 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 6bc86ae57a3..8e3159e92b1 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 } 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}" 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