diff --git a/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj b/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj index 29cca6690d0..726443902e0 100644 --- a/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj +++ b/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj @@ -160,8 +160,8 @@ - - + + @@ -207,6 +207,7 @@ + diff --git a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/CommonWorkflows.fs b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/CommonWorkflows.fs new file mode 100644 index 00000000000..4f6c1ae0f05 --- /dev/null +++ b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/CommonWorkflows.fs @@ -0,0 +1,108 @@ +module FSharp.Compiler.ComponentTests.FSharpChecker.CommonWorkflows + +open System +open System.IO + +open Xunit + +open FSharp.Test.ProjectGeneration + +let projectDir = "test-projects" + +let makeTestProject () = + let name = $"testProject{Guid.NewGuid().ToString()[..7]}" + let dir = Path.GetFullPath projectDir + { + Name = name + ProjectDir = dir ++ name + SourceFiles = [ + sourceFile "First" [] + sourceFile "Second" ["First"] + sourceFile "Third" ["First"] + { sourceFile "Last" ["Second"; "Third"] with EntryPoint = true } + ] + DependsOn = [] + } + +[] +let ``Edit file, check it, then check dependent file`` () = + projectWorkflow (makeTestProject()) { + updateFile "First" breakDependentFiles + checkFile "First" expectSignatureChanged + saveFile "First" + checkFile "Second" expectErrors + } + +[] +let ``Edit file, don't check it, check dependent file`` () = + projectWorkflow (makeTestProject()) { + updateFile "First" breakDependentFiles + saveFile "First" + checkFile "Second" expectErrors + } + +[] +let ``Check transitive dependency`` () = + projectWorkflow (makeTestProject()) { + updateFile "First" breakDependentFiles + saveFile "First" + checkFile "Last" expectSignatureChanged + } + +[] +let ``Change multiple files at once`` () = + projectWorkflow (makeTestProject()) { + updateFile "First" (setPublicVersion 2) + updateFile "Second" (setPublicVersion 2) + updateFile "Third" (setPublicVersion 2) + saveAll + checkFile "Last" (expectSignatureContains "val f: x: 'a -> (ModuleFirst.TFirstV_2<'a> * ModuleSecond.TSecondV_2<'a>) * (ModuleFirst.TFirstV_2<'a> * ModuleThird.TThirdV_2<'a>) * TLastV_1<'a>") + } + +[] +let ``Files depend on signature file if present`` () = + (makeTestProject() + |> updateFile "First" (fun f -> { f with HasSignatureFile = true }) + |> projectWorkflow) { + updateFile "First" breakDependentFiles + saveFile "First" + checkFile "Second" expectNoChanges + } + +[] +let ``Adding a file`` () = + projectWorkflow (makeTestProject()) { + addFileAbove "Second" (sourceFile "New" []) + updateFile "Second" (addDependency "New") + saveAll + checkFile "Last" (expectSignatureContains "val f: x: 'a -> (ModuleNew.TNewV_1<'a> * ModuleFirst.TFirstV_1<'a> * ModuleSecond.TSecondV_1<'a>) * (ModuleFirst.TFirstV_1<'a> * ModuleThird.TThirdV_1<'a>) * TLastV_1<'a>") + } + +[] +let ``Removing a file`` () = + projectWorkflow (makeTestProject()) { + removeFile "Second" + saveAll + checkFile "Last" expectErrors + } + +[] +let ``Changes in a referenced project`` () = + let name = $"library{Guid.NewGuid().ToString()[..7]}" + let dir = Path.GetFullPath projectDir + let library = { + Name = name + ProjectDir = dir ++ name + SourceFiles = [ sourceFile "Library" [] ] + DependsOn = [] + } + + let project = + { makeTestProject() with DependsOn = [library] } + |> updateFile "First" (addDependency "Library") + + projectWorkflow project { + updateFile "Library" updatePublicSurface + saveFile "Library" + checkFile "Last" expectSignatureChanged + } diff --git a/tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj b/tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj index 5410b2abb15..aac6dca42d0 100644 --- a/tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj +++ b/tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj @@ -17,6 +17,7 @@ scriptlib.fsx + diff --git a/tests/FSharp.Test.Utilities/ProjectGeneration.fs b/tests/FSharp.Test.Utilities/ProjectGeneration.fs new file mode 100644 index 00000000000..4f3ad9b4aee --- /dev/null +++ b/tests/FSharp.Test.Utilities/ProjectGeneration.fs @@ -0,0 +1,424 @@ +/// Tools for generating synthetic projects where we can model dependencies between files. +/// +/// Each file in the project has a string identifier. It then contains a type and a function. +/// The function calls functions from all the files the given file depends on and returns their +/// results + it's own type in a tuple. +/// +/// To model changes, we change the type name in a file which results in signatures of all the +/// dependent files also changing. +/// +/// To model breaking changes we change the name of the function which will make dependent files +/// not compile. +/// +/// To model changes to "private" code in a file we change the body of a second function which +/// no one calls. +/// +module FSharp.Test.ProjectGeneration + +open System +open System.IO +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Diagnostics +open FSharp.Compiler.Text +open Xunit + + +let private projectRoot = __SOURCE_DIRECTORY__ + +let private defaultFunctionName = "f" + + +type SyntheticSourceFile = + { + Id: string + /// This is part of the file's type name + PublicVersion: int + InternalVersion: int + DependsOn: string list + /// Changing this makes dependent files' code invalid + FunctionName: string + HasSignatureFile: bool + HasErrors: bool + EntryPoint: bool + } + + member this.FileName = $"File{this.Id}.fs" + member this.SignatureFileName = $"{this.FileName}i" + +let sourceFile fileId deps = + { Id = fileId + PublicVersion = 1 + InternalVersion = 1 + DependsOn = deps + FunctionName = defaultFunctionName + HasSignatureFile = false + HasErrors = false + EntryPoint = false } + +type SyntheticProject = + { Name: string + ProjectDir: string + SourceFiles: SyntheticSourceFile list + DependsOn: SyntheticProject list } + + member this.Find fileId = + this.SourceFiles + |> List.tryFind (fun f -> f.Id = fileId) + |> Option.defaultWith (fun () -> failwith $"File with ID '{fileId}' not found in project {this.Name}.") + + member this.FindInAllProjects fileId = + this.GetAllFiles() + |> List.tryFind (fun (_, f) -> f.Id = fileId) + |> Option.defaultWith (fun () -> failwith $"File with ID '{fileId}' not found in any project.") + + member this.FindByPath path = + this.SourceFiles + |> List.tryFind (fun f -> this.ProjectDir ++ f.FileName = path) + |> Option.defaultWith (fun () -> failwith $"File {path} not found in project {this.Name}.") + + member this.ProjectFileName = this.ProjectDir ++ $"{this.Name}.fsproj" + + member this.OutputFilename = this.ProjectDir ++ $"{this.Name}.dll" + + member this.GetProjectOptions(checker: FSharpChecker) = + let baseOptions, _ = + checker.GetProjectOptionsFromScript("file.fs", SourceText.ofString "", assumeDotNetFramework = false) + |> Async.RunSynchronously + + { baseOptions with + ProjectFileName = this.ProjectFileName + ProjectId = None + SourceFiles = + [| for f in this.SourceFiles do + if f.HasSignatureFile then + this.ProjectDir ++ f.SignatureFileName + + this.ProjectDir ++ f.FileName |] + OtherOptions = + [| yield! baseOptions.OtherOptions + "--optimize+" + for p in this.DependsOn do + $"-r:{p.OutputFilename}" |] + ReferencedProjects = + [| for p in this.DependsOn do + FSharpReferencedProject.CreateFSharp(p.OutputFilename, p.GetProjectOptions checker) |] + IsIncompleteTypeCheckEnvironment = false + UseScriptResolutionRules = false + LoadTime = DateTime() + UnresolvedReferences = None + OriginalLoadReferences = [] + Stamp = None } + + member this.GetAllFiles() = + [ for f in this.SourceFiles do + this, f + for p in this.DependsOn do + yield! p.GetAllFiles() ] + + +module Internal = + + let renderSourceFile (project: SyntheticProject) (f: SyntheticSourceFile) = + seq { + $"module %s{project.Name}.Module{f.Id}" + + for p in project.DependsOn do + $"open {p.Name}" + + $"type T{f.Id}V_{f.PublicVersion}<'a> = T{f.Id} of 'a" + + $"let {f.FunctionName} x =" + + for dep in f.DependsOn do + $" Module{dep}.{defaultFunctionName} x," + + $" T{f.Id} x" + + $"let f2 x = x + {f.InternalVersion}" + + if f.HasErrors then + "let wrong = 1 + 'a'" + + if f.EntryPoint then + "[]" + "let main _ =" + " f 1 |> ignore" + " printfn \"Hello World!\"" + " 0" + } + |> String.concat Environment.NewLine + + let renderFsProj (p: SyntheticProject) = + seq { + """ + + + + Exe + net7.0 + + + + """ + + for f in p.SourceFiles do + if f.HasSignatureFile then + $"" + + $"" + + """ + + + """ + } + |> String.concat Environment.NewLine + + let writeFileIfChanged path content = + if not (File.Exists path) || File.ReadAllText(path) <> content then + File.WriteAllText(path, content) + + let writeFile (p: SyntheticProject) (f: SyntheticSourceFile) = + let fileName = p.ProjectDir ++ f.FileName + let content = renderSourceFile p f + writeFileIfChanged fileName content + + let validateFileIdsAreUnique (project: SyntheticProject) = + let ids = [ for _, f in project.GetAllFiles() -> f.Id ] + let duplicates = ids |> List.groupBy id |> List.filter (fun (_, g) -> g.Length > 1) + + if duplicates.Length > 0 then + failwith + $"""Source file IDs have to be unique across the project and all referenced projects. Found duplicates: {String.Join(", ", duplicates |> List.map fst)}""" + + +open Internal + + +[] +module ProjectOperations = + + let updateFile fileId updateFunction project = + let index = project.SourceFiles |> List.findIndex (fun file -> file.Id = fileId) + + { project with + SourceFiles = + project.SourceFiles + |> List.updateAt index (updateFunction project.SourceFiles[index]) } + + let updateFileInAnyProject fileId updateFunction (rootProject: SyntheticProject) = + let project, _ = + rootProject.GetAllFiles() + |> List.tryFind (fun (_, f) -> f.Id = fileId) + |> Option.defaultWith (fun () -> failwith $"File with ID '{fileId}' not found in any project") + + if project = rootProject then + updateFile fileId updateFunction project + else + let index = rootProject.DependsOn |> List.findIndex ((=) project) + + { rootProject with + DependsOn = + rootProject.DependsOn + |> List.updateAt index (updateFile fileId updateFunction project) } + + let private counter = (Seq.initInfinite id).GetEnumerator() + + let updatePublicSurface f = + counter.MoveNext() |> ignore + { f with PublicVersion = counter.Current } + + let updateInternal f = + counter.MoveNext() |> ignore + { f with InternalVersion = counter.Current } + + let breakDependentFiles f = { f with FunctionName = "g" } + + let setPublicVersion n f = { f with PublicVersion = n } + + let addDependency fileId f : SyntheticSourceFile = + { f with DependsOn = fileId :: f.DependsOn } + + let checkFile fileId (project: SyntheticProject) (checker: FSharpChecker) = + let file = project.Find fileId + let contents = renderSourceFile project file + let absFileName = project.ProjectDir ++ file.FileName + + checker.ParseAndCheckFileInProject( + absFileName, + 0, + SourceText.ofString contents, + project.GetProjectOptions checker + ) + + let getTypeCheckResult (parseResults: FSharpParseFileResults, checkResults: FSharpCheckFileAnswer) = + Assert.True(not parseResults.ParseHadErrors) + + match checkResults with + | FSharpCheckFileAnswer.Aborted -> failwith "Type checking was aborted" + | FSharpCheckFileAnswer.Succeeded checkResults -> checkResults + + let getSignature parseAndCheckResults = + match (getTypeCheckResult parseAndCheckResults).GenerateSignature() with + | Some s -> s.ToString() + | None -> "" + + let expectOk parseAndCheckResults _ = + let checkResult = getTypeCheckResult parseAndCheckResults + + if checkResult.Diagnostics.Length > 0 then + failwith $"Expected no errors, but there were some: \n%A{checkResult.Diagnostics}" + + let expectErrors parseAndCheckResults _ = + let checkResult = getTypeCheckResult parseAndCheckResults + + if + (checkResult.Diagnostics + |> Array.where (fun d -> d.Severity = FSharpDiagnosticSeverity.Error)) + .Length = 0 + then + failwith "Expected errors, but there were none" + + let expectSignatureChanged result (oldSignature: string, newSignature: string) = + expectOk result () + Assert.NotEqual(oldSignature, newSignature) + + let expectSignatureContains expected result (_oldSignature, newSignature) = + expectOk result () + Assert.Contains(expected, newSignature) + + let expectNoChanges result (oldSignature: string, newSignature: string) = + expectOk result () + Assert.Equal(oldSignature, newSignature) + + let rec saveProject (p: SyntheticProject) generateSignatureFiles checker = + async { + Directory.CreateDirectory(p.ProjectDir) |> ignore + + for ref in p.DependsOn do + do! saveProject ref generateSignatureFiles checker + + for i in 0 .. p.SourceFiles.Length - 1 do + let file = p.SourceFiles[i] + writeFile p file + + if file.HasSignatureFile && generateSignatureFiles then + let project = { p with SourceFiles = p.SourceFiles[0..i] } + let! results = checkFile file.Id project checker + let signature = getSignature results + let signatureFileName = p.ProjectDir ++ file.SignatureFileName + writeFileIfChanged signatureFileName signature + + writeFileIfChanged (p.ProjectDir ++ $"{p.Name}.fsproj") (renderFsProj p) + } + +type WorkflowContext = + { Project: SyntheticProject + Signatures: Map } + +type ProjectWorkflowBuilder(initialProject: SyntheticProject, ?checker: FSharpChecker) = + + let checker = defaultArg checker (FSharpChecker.Create()) + + let mapProject f workflow = + async { + let! ctx = workflow + return { ctx with Project = f ctx.Project } + } + + member this.Checker = checker + + member this.Yield _ = + async { + validateFileIdsAreUnique initialProject + + do! saveProject initialProject true checker + + let! results = checker.ParseAndCheckProject(initialProject.GetProjectOptions checker) + + if not (Array.isEmpty results.Diagnostics) then + failwith $"Project {initialProject.Name} failed initial check: \n%A{results.Diagnostics}" + + let! signatures = + Async.Sequential + [ for file in initialProject.SourceFiles do + async { + let! result = checkFile file.Id initialProject checker + let signature = getSignature result + return file.Id, signature + } ] + + return + { Project = initialProject + Signatures = Map signatures } + } + + member this.Run(workflow: Async) = + try + Async.RunSynchronously workflow + finally + if Directory.Exists initialProject.ProjectDir then + Directory.Delete(initialProject.ProjectDir, true) + + /// Change contents of given file using `processFile` function. + /// Does not save the file to disk. + [] + member this.UpdateFile(workflow: Async, fileId: string, processFile) = + workflow |> mapProject (updateFileInAnyProject fileId processFile) + + /// Add a file above given file in the project. + [] + member this.AddFileAbove(workflow: Async, addAboveId: string, newFile) = + workflow + |> mapProject (fun project -> + let index = + project.SourceFiles + |> List.tryFindIndex (fun f -> f.Id = addAboveId) + |> Option.defaultWith (fun () -> failwith $"File {addAboveId} not found") + + { project with SourceFiles = project.SourceFiles |> List.insertAt index newFile }) + + /// Remove a file from the project. The file is not deleted from disk. + [] + member this.RemoveFile(workflow: Async, fileId: string) = + workflow + |> mapProject (fun project -> + { project with SourceFiles = project.SourceFiles |> List.filter (fun f -> f.Id <> fileId) }) + + /// Parse and type check given file and process the results using `processResults` function. + [] + member this.CheckFile(workflow: Async, fileId: string, processResults) = + async { + let! ctx = workflow + let! results = checkFile fileId ctx.Project checker + + let oldSignature = ctx.Signatures[fileId] + let newSignature = getSignature results + + processResults results (oldSignature, newSignature) + + return { ctx with Signatures = ctx.Signatures.Add(fileId, newSignature) } + } + + /// Save given file to disk. + [] + member this.SaveFile(workflow: Async, fileId: string) = + async { + let! ctx = workflow + let project, file = ctx.Project.FindInAllProjects fileId + writeFile project file + return ctx + } + + /// Save all files to disk. + [] + member this.SaveAll(workflow: Async) = + async { + let! ctx = workflow + do! saveProject ctx.Project false checker + return ctx + } + +/// Execute a set of operations on a given synthetic project. +/// The project is saved to disk and type checked at the start. +let projectWorkflow project = ProjectWorkflowBuilder project