diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 476dd7c3..ea1a4281 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "fantomas": { - "version": "7.0.3", + "version": "7.0.5", "commands": [ "fantomas" ], diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..9d677c69 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,59 @@ +# Copilot Instructions for Ionide.ProjInfo + +## Project Overview +- **Ionide.ProjInfo** is a set of F# libraries and tools for parsing and evaluating `.fsproj` and `.sln` files, used by F# tooling (e.g., Ionide, FSAC, Fable, FSharpLint). +- Major components: + - `Ionide.ProjInfo`: Core project/solution parsing logic (uses Microsoft.Build APIs). + - `Ionide.ProjInfo.FCS`: Maps project data to FSharp.Compiler.Service types. + - `Ionide.ProjInfo.ProjectSystem`: High-level project system for editor tooling (change tracking, notifications, caching). + - `Ionide.ProjInfo.Tool`: CLI for debugging project cracking. + +## Build & Test Workflows +- **Restore tools:** `dotnet tool restore` +- **Build solution:** `dotnet build ionide-proj-info.sln` +- **Run all tests:** `dotnet run --project build -- Test` +- **Multi-TFM testing:** + - For specific TFM: + - LTS (net8.0) `dotnet run --project build -- Test:net8.0` + - STS (net9.0) `dotnet run --project build -- Test:net9.0` + +- **Test assets:** Test projects in `test/examples/` cover a wide range of real-world project structures (multi-TFM, C#/F#, old/new SDK, solution filters, etc.). + +## Key Patterns & Conventions +- **Project loading:** Prefer using the MSBuild loader; use `--graph` for graph-based loading in CLI tool. +- **Output formats:** CLI tool supports structured text, FCS options (`--fcs`), or JSON (`--serialize`). +- **Cross-language:** Handles both F# and C# projects/references. +- **Persistent caching:** ProjectSystem caches data for fast reloads. +- **Testing:** Uses Expecto for tests; see `test/Ionide.ProjInfo.Tests/` for patterns. +- **Release process:** Update `CHANGELOG.md`, tag, and push (see `CONTRIBUTING.md`). + +## Integration Points +- Consumed by Fable, FSAC, Ionide, FSharpLint, and F# Formatting. +- External dependencies: Microsoft.Build, FSharp.Compiler.Service (FCS). +- Nightly builds may require custom NuGet feeds (see `CONTRIBUTING.md`). + +## Where to Look +- **Architecture:** See `README.md` (root), `src/` for main libraries, `test/examples/` for project scenarios. +- **Developer workflow:** `CONTRIBUTING.md` for build/test/release details. +- **Tool usage:** `src/Ionide.ProjInfo.Tool/README.md` and `Program.fs`. +- **Test patterns:** `test/Ionide.ProjInfo.Tests/` and helpers in `FileUtils.fs`. + +## Code of Conduct +- See `CODE_OF_CONDUCT.md` for community standards. + +--- +For more, see https://github.com/ionide/dotnet-proj-info and linked docs. + + +### MCP Tools + +> [!IMPORTANT] + +You have access to a long-term memory system via the Model Context Protocol (MCP) at the endpoint `memorizer`. Use the following tools: +- `store`: Store a new memory. Parameters: `type`, `content` (markdown), `source`, `tags`, `confidence`, `relatedTo` (optional, memory ID), `relationshipType` (optional). +- `search`: Search for similar memories. Parameters: `query`, `limit`, `minSimilarity`, `filterTags`. +- `get`: Retrieve a memory by ID. Parameter: `id`. +- `getMany`: Retrieve multiple memories by their IDs. Parameter: `ids` (list of IDs). +- `delete`: Delete a memory by ID. Parameter: `id`. +- `createRelationship`: Create a relationship between two memories. Parameters: `fromId`, `toId`, `type`. +Use these tools to remember, recall, relate, and manage information as needed to assist the user. You can also manually retrieve or relate memories by their IDs when necessary. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9afc3688 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# Agent Guide for Ionide.ProjInfo +1. Restore local tools before anything else: `dotnet tool restore`. +2. Standard build: `dotnet build ionide-proj-info.sln` (FAKE build target `dotnet run --project build -- -t Build`). +3. Full test matrix: `dotnet run --project build -- -t Test` (runs net8/net9/net10 with temporary `global.json`). +4. Single test project: `dotnet test test/Ionide.ProjInfo.Tests/Ionide.ProjInfo.Tests.fsproj --filter "FullyQualifiedName~"` after ensuring the desired SDK via `global.json` and `BuildNet*` env vars. +5. Formatting uses Fantomas; prefer `dotnet run --project build -- -t CheckFormat`, and run `-t Format` only when necessary. +6. Repo follows .editorconfig: 4-space indent, LF endings, final newline; XML projects/yaml use 2 spaces. +7. F# formatting: Stroustrup braces, 240 char max line, limited blank lines, arrays/lists wrap after one item, multiline lambdas close on newline. +8. Naming: descriptive PascalCase for types/modules, camelCase for values/parameters, UPPER_CASE for constants/environment keys. +9. Imports: keep `open` statements grouped at top, ordered System → third-party → project namespaces; remove unused opens. +10. Types: prefer explicit types on public members and when inference harms clarity; use records/DU for shape safety. +11. Error handling: use `Result`/`Choice` for recoverable states, raise exceptions only when failing fast; log via FsLibLog where available. +12. Async workflows: keep side effects isolated; favor `task {}` or `async {}` consistently per module. +13. Tests use Expecto; follow `TestAssets.fs` helpers and keep assertions expressive. +14. Avoid introducing new dependencies without discussion; stick to existing FAKE pipeline for packaging/pushing. +15. Copilot rules apply: follow `.github/copilot-instructions.md` for architecture pointers and release workflow awareness. +16. No Cursor-specific rules present; if they appear later under `.cursor/`, integrate them here. +17. Always update CHANGELOG for user-facing changes and confirm Code of Conduct compliance. +18. Document CLI/tool behavior changes in respective `README.md` files under `src/*`. +19. Keep public APIs backward compatible; prefer additive changes and guard feature flags. +20. When unsure about SDK/runtime alignment, run `dotnet --version` and adjust `global.json` to match test needs. + + +## Resources + +### Core Documentation +- [FsAutoComplete GitHub Repository](https://github.com/ionide/FsAutoComplete) +- [LSP Specification](https://microsoft.github.io/language-server-protocol/) +- [F# Compiler Service Documentation](https://fsharp.github.io/FSharp.Compiler.Service/) + +### F# Development Guidelines +- [F# Style Guide](https://docs.microsoft.com/en-us/dotnet/fsharp/style-guide/) +- [F# Formatting Guidelines](https://docs.microsoft.com/en-us/dotnet/fsharp/style-guide/formatting) +- [F# Component Design Guidelines](https://docs.microsoft.com/en-us/dotnet/fsharp/style-guide/component-design-guidelines) + +### Project-Specific Guides +- [Creating a New Code Fix Guide](./docs/Creating%20a%20new%20code%20fix.md) +- [Ionide.ProjInfo Documentation](https://github.com/ionide/proj-info) +- [Fantomas Configuration](https://fsprojects.github.io/fantomas/) + +### Related Tools +- [FSharpLint](https://github.com/fsprojects/FSharpLint/) - Static analysis tool +- [Paket](https://fsprojects.github.io/Paket/) - Dependency management +- [FAKE](https://fake.build/) - Build automation (used for scaffolding) + + +### MCP Tools + +> [!IMPORTANT] + +You have access to a long-term memory system via the Model Context Protocol (MCP) at the endpoint `memorizer`. Use the following tools: +- `store`: Store a new memory. Parameters: `type`, `content` (markdown), `source`, `tags`, `confidence`, `relatedTo` (optional, memory ID), `relationshipType` (optional). +- `search`: Search for similar memories. Parameters: `query`, `limit`, `minSimilarity`, `filterTags`. +- `get`: Retrieve a memory by ID. Parameter: `id`. +- `getMany`: Retrieve multiple memories by their IDs. Parameter: `ids` (list of IDs). +- `delete`: Delete a memory by ID. Parameter: `id`. +- `createRelationship`: Create a relationship between two memories. Parameters: `fromId`, `toId`, `type`. +Use these tools to remember, recall, relate, and manage information as needed to assist the user. You can also manually retrieve or relate memories by their IDs when necessary. diff --git a/Directory.Packages.props b/Directory.Packages.props index 1cf0f9d5..b1c1138e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,7 +38,7 @@ - + diff --git a/build/Program.fs b/build/Program.fs index ed443634..91934d83 100644 --- a/build/Program.fs +++ b/build/Program.fs @@ -1,8 +1,9 @@ -open Fake.Core +open Fake.Core open Fake.DotNet open Fake.IO open Fake.IO.Globbing.Operators open Fake.Core.TargetOperators +open System System.Environment.CurrentDirectory <- (Path.combine __SOURCE_DIRECTORY__ "..") @@ -104,7 +105,19 @@ let init args = "net10.0", Some "BuildNet10" ] + let cleanupGlobalJson = + let globalJsonPath = Path.combine "test" "global.json" + + fun () -> + try + if System.IO.File.Exists globalJsonPath then + System.IO.File.Delete globalJsonPath + with _ -> + () + let testTFM tfm = + cleanupGlobalJson () + try exec "dotnet" $"new globaljson --force --sdk-version {tfmToSdkMap.[tfm]} --roll-forward LatestMinor" "test" Map.empty @@ -123,10 +136,18 @@ let init args = | Some envVar -> Map.ofSeq [ envVar, "true" ] | None -> Map.empty - exec "dotnet" $"test --blame --blame-hang-timeout 120s --framework {tfm} --logger trx --logger GitHubActions -c %s{configuration} .\\Ionide.ProjInfo.Tests\\Ionide.ProjInfo.Tests.fsproj -- %s{failedOnFocus}" "test" envs + let timeoutInSeconds = + (TimeSpan.FromMinutes 5).TotalSeconds + |> int + + exec + "dotnet" + $"test --blame --blame-hang-timeout %d{timeoutInSeconds}s --framework %s{tfm} --logger trx --logger GitHubActions -c %s{configuration} .\\Ionide.ProjInfo.Tests\\Ionide.ProjInfo.Tests.fsproj -- %s{failedOnFocus}" + "test" + envs |> ignore finally - System.IO.File.Delete "test\\global.json" + cleanupGlobalJson () Target.create "Test" DoNothing @@ -204,7 +225,11 @@ let init args = Target.create "Release" DoNothing "Clean" - ==> "CheckFormat" + ==> "Default" + |> ignore + + "Clean" + ?=> "CheckFormat" ==> "Build" ==> "Test" ==> "Default" diff --git a/global.json b/global.json index 73d21416..9b4f20aa 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,6 @@ { "sdk": { "version": "8.0.100", - "rollForward": "latestMinor", - "allowPrerelease": true + "rollForward": "latestMinor" } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 41c4972d..34b5e6f8 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - true + $(NoWarn);FS3535 true true true embedded diff --git a/src/Ionide.ProjInfo/Ionide.ProjInfo.fsproj b/src/Ionide.ProjInfo/Ionide.ProjInfo.fsproj index f7c99594..6a466263 100644 --- a/src/Ionide.ProjInfo/Ionide.ProjInfo.fsproj +++ b/src/Ionide.ProjInfo/Ionide.ProjInfo.fsproj @@ -12,6 +12,7 @@ + diff --git a/src/Ionide.ProjInfo/Library.fs b/src/Ionide.ProjInfo/Library.fs index 50907e56..7ec5407d 100644 --- a/src/Ionide.ProjInfo/Library.fs +++ b/src/Ionide.ProjInfo/Library.fs @@ -1,4 +1,4 @@ -namespace Ionide.ProjInfo +namespace Ionide.ProjInfo open System open System.Collections.Generic @@ -14,6 +14,16 @@ open System.Runtime.InteropServices open Ionide.ProjInfo.Logging open Patterns +type ProjectNotRestoredError<'e, 'buildResult> = + static abstract NotRestored: 'buildResult -> 'e + +type ParseError<'BuildResult> = + | NotRestored of 'BuildResult + + interface ProjectNotRestoredError, 'BuildResult> with + static member NotRestored(result) = NotRestored result + + /// functions for .net sdk probing module SdkDiscovery = @@ -341,7 +351,7 @@ type BinaryLogGeneration = module ProjectLoader = type LoadedProject = - internal + | StandardProject of ProjectInstance | TraversalProject of ProjectInstance /// This could be things like shproj files, or other things that aren't standard projects @@ -395,6 +405,29 @@ module ProjectLoader = and set (v: LoggerVerbosity): unit = () } + type ErrorLogger() = + let errors = ResizeArray() + member this.Errors = errors :> seq<_> + + member this.Message = + this.Errors + |> Seq.sortBy (fun e -> e.Timestamp) + |> Seq.map (fun e -> $"{e.ProjectFile} {e.Message}") + |> String.concat "\n" + + interface ILogger with + member this.Initialize(eventSource: IEventSource) : unit = eventSource.ErrorRaised.Add errors.Add + + member this.Parameters + with get (): string = "" + and set (v: string): unit = () + + member this.Shutdown() : unit = () + + member this.Verbosity + with get (): LoggerVerbosity = LoggerVerbosity.Detailed + and set (v: LoggerVerbosity): unit = () + let internal stringWriterLogger (writer: StringWriter) = { new ILogger with member this.Initialize(eventSource: IEventSource) : unit = @@ -417,7 +450,8 @@ module ProjectLoader = let combined = Dictionary(collection.GlobalProperties) for kvp in otherProperties do - combined.Add(kvp.Key, kvp.Value) + combined.TryAdd(kvp.Key, kvp.Value) + |> ignore combined @@ -532,7 +566,7 @@ module ProjectLoader = let pi = project.CreateProjectInstance() getTfm pi isLegacyFrameworkProj - let createLoggers (path: string) (binaryLogs: BinaryLogGeneration) (sw: StringWriter) = + let createLoggers (path: string) (binaryLogs: BinaryLogGeneration) (sw: StringWriter) (errLogs: ErrorLogger option) = let swLogger = stringWriterLogger (sw) let msBuildLogger = msBuildToLogProvider () @@ -544,26 +578,53 @@ module ProjectLoader = [ swLogger msBuildLogger + match errLogs with + | Some logger -> logger :> ILogger + | None -> () match binaryLogs with | BinaryLogGeneration.Off -> () | BinaryLogGeneration.Within dir -> Microsoft.Build.Logging.BinaryLogger(Parameters = logFilePath (dir, path)) :> ILogger ] + let internal designTimeBuildTargetsCore = [| + "ResolveAssemblyReferencesDesignTime" + "ResolveProjectReferencesDesignTime" + "ResolvePackageDependenciesDesignTime" + "ResolveSDKReferencesDesignTime" + // Populates ReferencePathWithRefAssemblies which CoreCompile requires. + // This can be removed one day when Microsoft.FSharp.Targets calls this. + "FindReferenceAssembliesForReferences" + "_GenerateCompileDependencyCache" + "_ComputeNonExistentFileProperty" + "BeforeBuild" + "BeforeCompile" + "CoreCompile" + "GetTargetPath" + + |] + + let defaultGlobalProps = [ + "ProvideCommandLineArgs", "true" + "DesignTimeBuild", "true" + "SkipCompilerExecution", "true" + "GeneratePackageOnBuild", "false" + "Configuration", "Debug" + "DefineExplicitDefaults", "true" + "BuildProjectReferences", "false" + "UseCommonOutputDirectory", "false" + "NonExistentFile", Path.Combine("__NonExistentSubDir__", "__NonExistentFile__") // Required by the Clean Target + "DotnetProjInfo", "true" + "InnerTargets", + designTimeBuildTargetsCore + |> String.concat ";" + ] + let getGlobalProps (tfm: string option) (globalProperties: (string * string) list) (propsSetFromParentCollection: Set) = [ - "ProvideCommandLineArgs", "true" - "DesignTimeBuild", "true" - "SkipCompilerExecution", "true" - "GeneratePackageOnBuild", "false" - "Configuration", "Debug" - "DefineExplicitDefaults", "true" - "BuildProjectReferences", "false" - "UseCommonOutputDirectory", "false" - "NonExistentFile", Path.Combine("__NonExistentSubDir__", "__NonExistentFile__") // Required by the Clean Target + yield! defaultGlobalProps if tfm.IsSome then "TargetFramework", tfm.Value - "DotnetProjInfo", "true" yield! globalProperties ] |> List.filter (fun (ourProp, _) -> not (propsSetFromParentCollection.Contains ourProp)) @@ -594,19 +655,7 @@ module ProjectLoader = "CoreCompile" |] else - [| - "ResolveAssemblyReferencesDesignTime" - "ResolveProjectReferencesDesignTime" - "ResolvePackageDependenciesDesignTime" - // Populates ReferencePathWithRefAssemblies which CoreCompile requires. - // This can be removed one day when Microsoft.FSharp.Targets calls this. - "FindReferenceAssembliesForReferences" - "_GenerateCompileDependencyCache" - "_ComputeNonExistentFileProperty" - "BeforeBuild" - "BeforeCompile" - "CoreCompile" - |] + [| yield! designTimeBuildTargetsCore |] let setLegacyMsbuildProperties isOldStyleProjFile = match LegacyFrameworkDiscovery.msbuildBinary.Value with @@ -625,7 +674,7 @@ module ProjectLoader = let legacyProjFormatXmlns = "xmlns=\"http://schemas.microsoft.com/developer/msbuild/2003\"" let lines: seq = File.ReadLines path - (Seq.tryFind (fun (line: string) -> line.Contains legacyProjFormatXmlns) lines) + Seq.tryFind (fun (line: string) -> line.Contains legacyProjFormatXmlns) lines |> Option.isSome else false @@ -645,7 +694,7 @@ module ProjectLoader = let project = findOrCreateMatchingProject path projectCollection globalProperties use sw = new StringWriter() - let loggers = createLoggers path binaryLogs sw + let loggers = createLoggers path binaryLogs sw None let pi = project.CreateProjectInstance() let designTimeTargets = designTimeBuildTargets isLegacyFrameworkProjFile @@ -854,11 +903,8 @@ module ProjectLoader = msbuildPropString "ProjectAssetsFile" |> Option.defaultValue "" RestoreSuccess = - match msbuildPropString "TargetFrameworkVersion" with - | Some _ -> true - | None -> - msbuildPropBool "RestoreSuccess" - |> Option.defaultValue false + msbuildPropBool "RestoreSuccess" + |> Option.defaultValue false Configurations = msbuildPropStringList "Configurations" @@ -914,6 +960,7 @@ module ProjectLoader = (analyzers: Analyzer list) (allProps: Map>) (allItems: Map>>) + (imports: string list) = let projDir = Path.GetDirectoryName path @@ -954,6 +1001,7 @@ module ProjectLoader = path ) + let project: ProjectOptions = { ProjectId = Some path ProjectFileName = path @@ -988,6 +1036,7 @@ module ProjectLoader = Analyzers = analyzers AllProperties = allProps AllItems = allItems + Imports = imports } @@ -999,9 +1048,7 @@ module ProjectLoader = | TraversalProjectInfo of ProjectReference list | OtherProjectInfo of ProjectInstance - let getLoadedProjectInfo (path: string) customProperties project : Result = - // let (LoadedProject p) = project - // let path = p.FullPath + let getLoadedProjectInfo<'e when ProjectNotRestoredError<'e, LoadedProject>> (path: string) customProperties project = match project with | LoadedProject.TraversalProject t -> @@ -1114,12 +1161,16 @@ module ProjectLoader = ) |> Seq.toList + let imports = + p.ImportPaths + |> Seq.toList + if not sdkInfo.RestoreSuccess then - Error "not restored" + Error('e.NotRestored project) else let proj = - mapToProject path commandLineArgs p2pRefs compileItems nuGetRefs sdkInfo props customProps analyzers allProperties allItems + mapToProject path commandLineArgs p2pRefs compileItems nuGetRefs sdkInfo props customProps analyzers allProperties allItems imports Ok(LoadedProjectInfo.StandardProjectInfo proj) | LoadedProject.Other p -> Ok(LoadedProjectInfo.OtherProjectInfo p) @@ -1268,6 +1319,7 @@ type WorkspaceLoaderViaProjectGraph private (toolsPath, ?globalProperties: (stri pg + let loadProjects (projects: ProjectGraph, customProperties: string list, binaryLogs: BinaryLogGeneration) = let handleError (msbuildErrors: string) (e: exn) = let msg = e.Message @@ -1326,7 +1378,7 @@ type WorkspaceLoaderViaProjectGraph private (toolsPath, ?globalProperties: (stri let bm = BuildManager.DefaultBuildManager use sw = new StringWriter() - let loggers = ProjectLoader.createLoggers "graph-build" binaryLogs sw + let loggers = ProjectLoader.createLoggers "graph-build" binaryLogs sw None let buildParameters = BuildParameters(Loggers = loggers) buildParameters.ProjectLoadSettings <- @@ -1379,13 +1431,13 @@ type WorkspaceLoaderViaProjectGraph private (toolsPath, ?globalProperties: (stri | Ok projectOptions -> Some projectOptions - | Error e -> + | Error(ParseError.NotRestored e) -> logger.error ( Log.setMessage "Failed loading projects {error}" >> Log.addContextDestructured "error" e ) - loadingNotification.Trigger(WorkspaceProjectState.Failed(projectPath, GenericError(projectPath, e))) + loadingNotification.Trigger(WorkspaceProjectState.Failed(projectPath, GenericError(projectPath, "not restored"))) None ) @@ -1555,8 +1607,8 @@ type WorkspaceLoader private (toolsPath: ToolsPath, ?globalProperties: (string * | ProjectLoader.LoadedProjectInfo.OtherProjectInfo p -> None lst, info - | Error msg -> - loadingNotification.Trigger(WorkspaceProjectState.Failed(p, GenericError(p, msg))) + | Error(ParseError.NotRestored e) -> + loadingNotification.Trigger(WorkspaceProjectState.Failed(p, GenericError(p, "Not restored"))) [], None let rec loadProjectList (projectList: string list) = diff --git a/src/Ionide.ProjInfo/ProjectLoader2.fs b/src/Ionide.ProjInfo/ProjectLoader2.fs new file mode 100644 index 00000000..e990573b --- /dev/null +++ b/src/Ionide.ProjInfo/ProjectLoader2.fs @@ -0,0 +1,721 @@ +namespace Ionide.ProjInfo + +open System +open System.Threading.Tasks +open System.Threading +open Microsoft.Build.Execution +open Microsoft.Build.Graph +open System.Collections.Generic +open Microsoft.Build.Evaluation +open Microsoft.Build.Framework +open ProjectLoader + +[] +module SemaphoreSlimExtensions = + + /// + /// An awaitable wrapper around a task whose result is disposable. The wrapper is not disposable, so this prevents usage errors like "use _lock = myAsync()" when the appropriate usage should be "use! _lock = myAsync())". + /// + [] + type AwaitableDisposable<'T when 'T :> IDisposable>(t: Task<'T>) = + member x.GetAwaiter() = t.GetAwaiter() + member x.AsTask() = t + static member op_Implicit(source: AwaitableDisposable<'T>) = source.AsTask() + + + type SemaphoreSlim with + + member x.LockAsync(?ct: CancellationToken) = + AwaitableDisposable( + task { + let ct = defaultArg ct CancellationToken.None + let t = x.WaitAsync(ct) + + do! t + + return + { new IDisposable with + member _.Dispose() = + // only release if the task completed successfully + // otherwise, we could be releasing a semaphore that was never acquired + if t.Status = TaskStatus.RanToCompletion then + x.Release() + |> ignore + } + } + ) + + +module internal Map = + + let inline mapAddSome key value map = + match value with + | Some v -> Map.add key v map + | None -> map + + let inline union loses wins = + Map.fold (fun acc key value -> Map.add key value acc) loses wins + + let inline ofDict dictionary = + dictionary + |> Seq.map (|KeyValue|) + |> Map.ofSeq + + + let inline copyToDict (map: Map<_, _>) = + // Have to use a mutable dictionary here because the F# Map doesn't have an Add method + let dictionary = Dictionary<_, _>() + + for KeyValue(k, v) in map do + dictionary.Add(k, v) + + dictionary :> IDictionary<_, _> + + +module BuildErrorEventArgs = + + let messages (e: BuildErrorEventArgs seq) = + e + |> Seq.sortBy (fun e -> e.Timestamp) + |> Seq.map (fun e -> $"{e.ProjectFile} {e.Message}") + |> String.concat "\n" + +[] +module internal BuildManagerExtensions = + + type BuildManager with + + /// + /// Prepares the BuildManager to receive build requests. + /// + /// Returns disposable that signals that no more build requests are expected (or allowed) and the BuildManager may clean up. This call blocks until all currently pending requests are complete. + /// + /// The build parameters. May be null + /// CancellationToken to cancel build submissions. + /// Disposable calling EndBuild. + member bm.StartBuild(?parameters: BuildParameters, ?ct: CancellationToken) = + let parameters = defaultArg parameters null + let ct = defaultArg ct CancellationToken.None + ct.ThrowIfCancellationRequested() + bm.BeginBuild parameters + + let cancelSubmissions = + ct.Register(fun () -> + // https://github.com/dotnet/msbuild/issues/3397 :( + bm.CancelAllSubmissions() + ) + + { new IDisposable with + member _.Dispose() = + cancelSubmissions.Dispose() + bm.EndBuild() + } + +module internal BuildManagerSession = + // multiple concurrent builds cannot be issued to BuildManager + // Creating SemaphoreSlim here so we only have one per application + let internal locker = new SemaphoreSlim(1, 1) + + +type BuildResultFailure<'e, 'buildResult> = + static abstract BuildFailure: 'buildResult * BuildErrorEventArgs list -> 'e + +module GraphBuildResult = + + /// + /// Groups build results by their associated project nodes. + /// + /// The GraphBuildResult to group. + /// The error logs from the failed build. + /// A dictionary where the key is the project node and the value is either a successful BuildResult or an error containing the failure details. + let resultsByNode<'e when BuildResultFailure<'e, BuildResult>> (result: GraphBuildResult) (errorLogs: BuildErrorEventArgs list) = + let errorLogsMap = + lazy + (errorLogs + |> List.groupBy (fun e -> e.ProjectFile) + |> Map.ofList) + + result.ResultsByNode + |> Seq.map (fun (KeyValue(k, v)) -> + match v.OverallResult with + | BuildResultCode.Success -> k, Ok v + | _ -> + let logs = + errorLogsMap.Value + |> Map.tryFind k.ProjectInstance.FullPath + |> Option.defaultValue [] + + k, Error('e.BuildFailure(v, logs)) + ) + + /// + /// Isolates failures from a GraphBuildResult, returning a sequence of KeyValuePairs where the value is an Error. + /// + /// The GraphBuildResult to isolate failures from. + /// The error logs from the failed build. + let isolateFailures (result: GraphBuildResult) (errorLogs: BuildErrorEventArgs list) = + resultsByNode result errorLogs + |> Seq.choose (fun (k, v) -> + match v with + | Ok v -> None + | Error e -> Some(k, e) + ) + +/// +/// Uses to run builds. +/// This should be treated as a singleton because the BuildManager only allows one build request running at a time. +/// +type BuildManagerSession(?bm: BuildManager) = + let locker = BuildManagerSession.locker + let bm = defaultArg bm BuildManager.DefaultBuildManager + + let tryGetErrorLogs (buildParameters: BuildParameters) = + buildParameters.Loggers + |> Seq.tryPick ( + function + | :? ProjectLoader.ErrorLogger as e -> Some e + | _ -> None + ) + |> Option.toList + |> Seq.collect (fun e -> e.Errors) + |> Seq.toList + + let lockAndStartBuild (ct: CancellationToken) buildParameters (a: unit -> Task<_>) = + task { + use! _lock = locker.LockAsync ct + use _ = bm.StartBuild(buildParameters, ct) + return! a () + } + + member private x.determineBuildOutput<'e when BuildResultFailure<'e, BuildResult>>(buildParameters, result: BuildResult) = + match result.OverallResult with + | BuildResultCode.Success -> Ok result + | _ -> Error('e.BuildFailure(result, tryGetErrorLogs buildParameters)) + + member private x.determineGraphBuildOutput<'e when BuildResultFailure<'e, GraphBuildResult>>(buildParameters, result: GraphBuildResult) = + match result.OverallResult with + | BuildResultCode.Success -> Ok result + | _ -> Error('e.BuildFailure(result, tryGetErrorLogs buildParameters)) + + + /// Submits a graph build request to the current build and starts it asynchronously. + /// GraphBuildRequestData encapsulates all of the data needed to submit a graph build request. + /// All of the settings which must be specified to start a build + /// CancellationToken to cancel build submissions. + /// The BuildResult + member x.BuildAsync(buildRequest: BuildRequestData, ?buildParameters: BuildParameters, ?ct: CancellationToken) = + let ct = defaultArg ct CancellationToken.None + + let buildParameters = + defaultArg + buildParameters + (BuildParameters( + Loggers = [ + msBuildToLogProvider () + ProjectLoader.ErrorLogger() + ] + )) + + lockAndStartBuild ct buildParameters + <| fun () -> + task { + let tcs = TaskCompletionSource<_> TaskCreationOptions.RunContinuationsAsynchronously + + bm + .PendBuildRequest(buildRequest) + .ExecuteAsync( + (fun sub -> + let result = sub.BuildResult + + match result.Exception with + | null -> tcs.SetResult(x.determineBuildOutput (buildParameters, result)) + | :? Microsoft.Build.Exceptions.BuildAbortedException as bae when ct.IsCancellationRequested -> + OperationCanceledException("Build was cancelled", bae, ct) + |> tcs.SetException + | e -> tcs.SetException e + ), + buildRequest + ) + + return! tcs.Task + } + + /// Submits a graph build request to the current build and starts it asynchronously. + /// GraphBuildRequestData encapsulates all of the data needed to submit a graph build request. + /// All of the settings which must be specified to start a build + /// CancellationToken to cancel build submissions. + /// the GraphBuildResult + member x.BuildAsync(graphBuildRequest: GraphBuildRequestData, ?buildParameters: BuildParameters, ?ct: CancellationToken) = + let ct = defaultArg ct CancellationToken.None + let buildParameters = defaultArg buildParameters (BuildParameters(Loggers = [ ProjectLoader.ErrorLogger() ])) + + lockAndStartBuild ct buildParameters + <| fun () -> + task { + let tcs = TaskCompletionSource<_> TaskCreationOptions.RunContinuationsAsynchronously + + bm + .PendBuildRequest(graphBuildRequest) + .ExecuteAsync( + (fun sub -> + + let result = sub.BuildResult + + match result.Exception with + | null -> tcs.SetResult(x.determineGraphBuildOutput (buildParameters, result)) + | :? Microsoft.Build.Exceptions.BuildAbortedException as bae when ct.IsCancellationRequested -> + OperationCanceledException("Build was cancelled", bae, ct) + |> tcs.SetException + | e -> tcs.SetException e + + ), + graphBuildRequest + ) + + return! tcs.Task + } + + +module ProjectPropertyInstance = + let tryFind (name: string) (properties: ProjectPropertyInstance seq) = + properties + |> Seq.tryFind (fun p -> p.Name = name) + |> Option.map (fun v -> v.EvaluatedValue) + + +type ProjectPath = string +type TargetFramework = string +type TargetFrameworks = string array + +module TargetFrameworks = + + /// + /// Parses a string containing TargetFrameworks into an array of TargetFrameworks. + /// + /// The string containing TargetFrameworks, separated by semicolons. + /// An array of TargetFrameworks, or None if the input is null or empty. + /// + /// This takes a string of the form "net5.0;net6.0;net7.0" and splits it into an array of TargetFrameworks. + /// + let parse (tfms: string) : TargetFramework array option = + tfms + |> Option.ofObj + |> Option.bind (fun tfms -> + tfms.Split( + ';', + StringSplitOptions.TrimEntries + ||| StringSplitOptions.RemoveEmptyEntries + ) + |> Option.ofObj + ) + + +// type ProjectMap<'a> = Map> + +// module ProjectMap = + +// let map (f: ProjectPath -> TargetFramework -> 'a -> 'a0) (m: ProjectMap<'a>) = +// m +// |> Map.map (fun k -> Map.map (f k)) + +// type ProjectProjectMap = ProjectMap +// type ProjectGraphMap = ProjectMap + +module internal ProjectLoading = + + // let getAllTfms (projectPath: ProjectPath) pc props = + // let p = findOrCreateMatchingProject projectPath pc props + // let pi = p.CreateProjectInstance() + + // pi.Properties + // |> (ProjectPropertyInstance.tryFind "TargetFramework" + // >> Option.map Array.singleton) + // |> Option.orElseWith (fun () -> + // pi.Properties + // |> ProjectPropertyInstance.tryFind "TargetFrameworks" + // |> Option.bind TargetFrameworks.parse + // ) + + // let selectFirstTfm (projectPath: string) = + // getAllTfms projectPath + // |> Option.bind Array.tryHead + + let inline defaultProjectInstanceFactory (projectPath: string) (xml: Dictionary) (collection: ProjectCollection) = + let props = Map.union (Map.ofDict xml) (Map.ofDict collection.GlobalProperties) + ProjectInstance(projectPath, props, toolsVersion = null, projectCollection = collection) + + +type ProjectLoader2 = + + /// + /// Default flags for build requests. + /// + /// BuildRequestDataFlags.SkipNonexistentTargets + /// ||| BuildRequestDataFlags.ClearCachesAfterBuild + /// ||| BuildRequestDataFlags.ProvideProjectStateAfterBuild + /// ||| BuildRequestDataFlags.IgnoreMissingEmptyAndInvalidImports + /// ||| BuildRequestDataFlags.ReplaceExistingProjectInstance + /// + static member DefaultFlags = + BuildRequestDataFlags.SkipNonexistentTargets + ||| BuildRequestDataFlags.ClearCachesAfterBuild + ||| BuildRequestDataFlags.ProvideProjectStateAfterBuild + ||| BuildRequestDataFlags.IgnoreMissingEmptyAndInvalidImports + ||| BuildRequestDataFlags.ReplaceExistingProjectInstance + + + /// + /// Finds or creates a project matching the specified entry project file and global properties. + /// + /// The project file to match. + /// Optional global properties to apply to the project. + /// Optional project collection to use for evaluation. + /// The evaluated project. + /// + /// This method evaluates the project file and returns the corresponding project. + /// It does not check for TargetFramework or TargetFrameworks properties; it simply returns the project as is. + /// + static member EvaluateAsProject(entryProjectFile: string, ?globalProperties: IDictionary, ?projectCollection: ProjectCollection) = + let pc = defaultArg projectCollection ProjectCollection.GlobalProjectCollection + let globalProperties = defaultArg globalProperties (new Dictionary()) + findOrCreateMatchingProject entryProjectFile pc globalProperties + + /// + /// Evaluates a sequence of project files, returning a sequence of projects. + /// + /// The project files to evaluate. + /// Optional global properties to apply to each project. + /// Optional project collection to use for evaluation. + /// A sequence of projects, each corresponding to a project file. + /// + /// This method evaluates each project file and returns the corresponding project. + /// It does not check for TargetFramework or TargetFrameworks properties; it simply returns the project as is. + /// + static member EvaluateAsProjects(entryProjectFiles: string seq, ?globalProperties: IDictionary, ?projectCollection: ProjectCollection) = + entryProjectFiles + |> Seq.map (fun file -> ProjectLoader2.EvaluateAsProject(file, ?globalProperties = globalProperties, ?projectCollection = projectCollection)) + + /// + /// Evaluates a sequence of project files, returning a sequence of projects for each TargetFramework + /// or TargetFrameworks defined in the project files. + /// + /// The project files to evaluate. + /// Optional global properties to apply to each project. + /// Optional project collection to use for evaluation. + /// A sequence of projects, each corresponding to a specific TargetFramework or TargetFrameworks defined in the project files. + /// + /// This method evaluates each project file and checks for the presence of a "TargetFramework" + /// property. If it exists, the project is returned as is. If it does not exist, it checks for the "TargetFrameworks" + /// property and splits it into individual TargetFrameworks. For each TargetFramework, it creates a new project + /// with the "TargetFramework" global property set to that TargetFramework. + /// + static member EvaluateAsProjectsAllTfms(entryProjectFiles: string seq, ?globalProperties: IDictionary, ?projectCollection: ProjectCollection) = + + let globalPropertiesMap = + lazy + (globalProperties + |> Option.map Map.ofDict + |> Option.defaultValue Map.empty) + + entryProjectFiles + |> Seq.collect (fun path -> + let p = ProjectLoader2.EvaluateAsProject(path, ?globalProperties = globalProperties, ?projectCollection = projectCollection) + let pi = p.CreateProjectInstance() + + match + pi.Properties + |> ProjectPropertyInstance.tryFind "TargetFramework" + with + | Some _ -> Seq.singleton p + | None -> + let tfms = + pi.Properties + |> ProjectPropertyInstance.tryFind "TargetFrameworks" + |> Option.bind TargetFrameworks.parse + |> Option.defaultValue Array.empty + + tfms + |> Seq.map (fun tfm -> + ProjectLoader2.EvaluateAsProject( + path, + globalProperties = + (globalPropertiesMap.Value + |> Map.add "TargetFramework" tfm + |> Map.copyToDict), + ?projectCollection = projectCollection + ) + ) + ) + + /// + /// Evaluates a project graph based on the specified entry project file a + /// + /// The entry project file to evaluate. + /// Optional global properties to apply to the project. + /// Optional project collection to use for evaluation. + /// Optional factory function to create project instances. + /// Optional cancellation token to cancel the evaluation. + /// A project graph representing the evaluated project. + /// + /// This method evaluates the project file and returns a project graph. + /// It does not check for TargetFramework or TargetFrameworks properties; it simply returns the project graph as is. + /// + static member EvaluateAsGraph(entryProjectFile: string, ?globalProperties: IDictionary, ?projectCollection: ProjectCollection, ?projectInstanceFactory, ?ct: CancellationToken) = + let globalProperties = defaultArg globalProperties null + ProjectLoader2.EvaluateAsGraph([ ProjectGraphEntryPoint(entryProjectFile, globalProperties = globalProperties) ], ?projectCollection = projectCollection, ?projectInstanceFactory = projectInstanceFactory, ?ct = ct) + + /// + /// Evaluates a project graph based on the specified entry project files + /// + /// The entry project files to evaluate. + /// Optional project collection to use for evaluation. + /// Optional factory function to create project instances. + /// Optional cancellation token to cancel the evaluation. + /// A project graph representing the evaluated projects. + /// + /// This method evaluates the project files and returns a project graph. + /// It does not check for TargetFramework or TargetFrameworks properties; it simply returns the project graph as is. + /// + static member EvaluateAsGraph(entryProjectFile: ProjectGraphEntryPoint seq, ?projectCollection: ProjectCollection, ?projectInstanceFactory, ?ct: CancellationToken) = + let pc = defaultArg projectCollection ProjectCollection.GlobalProjectCollection + let ct = defaultArg ct CancellationToken.None + + let projectInstanceFactory = + let inline defaultFunc path xml pc = + // (findOrCreateMatchingProject path pc xml).CreateProjectInstance() + ProjectLoading.defaultProjectInstanceFactory path xml pc + + defaultArg projectInstanceFactory defaultFunc + + ProjectGraph(entryProjectFile, pc, projectInstanceFactory, ct) + + + /// + /// Evaluates a project graph based on the specified entry project files, returning a ProjectGraph containing + /// projects for each TargetFramework or TargetFrameworks defined in the project files. + /// + /// The entry project files to evaluate. + /// Optional project collection to use for evaluation. + /// Optional factory function to create project instances. + /// A project graph representing the evaluated projects, each corresponding to a specific TargetFramework + /// or TargetFrameworks defined in the project files. + /// + /// + /// MSBuild's ProjectGraph natively handles multi-targeting by creating "outer build" nodes (with TargetFrameworks) + /// that reference "inner build" nodes (with individual TargetFramework values). However, when building a graph, + /// only entry point nodes are built directly. This method performs two evaluations: + /// 1. First pass: Discover all nodes in the graph (including outer/inner builds and all references) + /// 2. Second pass: Create a new graph with only nodes that have a TargetFramework property set + /// + /// This ensures that all inner builds are treated as entry points and get built directly, which is required + /// for design-time analysis scenarios where we need build results for each TFM. + /// + static member EvaluateAsGraphAllTfms(entryProjectFile: ProjectGraphEntryPoint seq, ?projectCollection: ProjectCollection, ?projectInstanceFactory) = + // MSBuild's ProjectGraph handles multi-TFM projects by creating: + // - OuterBuild nodes: TargetFramework is empty, TargetFrameworks is set (dispatchers) + // - InnerBuild nodes: TargetFramework is set (actual builds per TFM) + // - NonMultitargeting nodes: Neither property meaningfully set + // + // First pass: Evaluate to discover all project nodes including inner builds created from outer builds + let graph = + ProjectLoader2.EvaluateAsGraph(entryProjectFile, ?projectCollection = projectCollection, ?projectInstanceFactory = projectInstanceFactory) + + // Helper to get TargetFramework from global properties + let inline tryGetTfmFromGlobalProps (node: ProjectGraphNode) = + match node.ProjectInstance.GlobalProperties.TryGetValue "TargetFramework" with + | true, tfm -> Some tfm + | _ -> None + + // Helper to get TargetFramework from project properties + let inline tryGetFromProps (node: ProjectGraphNode) = + node.ProjectInstance.Properties + |> ProjectPropertyInstance.tryFind "TargetFramework" + + // Extract only nodes with a TargetFramework as new entry points + // This filters out outer builds (which have TargetFrameworks but not TargetFramework) + // and includes inner builds (which have TargetFramework set) + let innerBuildEntryPoints = + graph.ProjectNodes + |> Seq.choose (fun node -> + tryGetTfmFromGlobalProps node + |> Option.orElseWith (fun () -> tryGetFromProps node) + |> Option.map (fun _ -> ProjectGraphEntryPoint(node.ProjectInstance.FullPath, globalProperties = node.ProjectInstance.GlobalProperties)) + ) + + // Second pass: Re-evaluate with inner builds as entry points + // This ensures all inner builds are built directly and appear in ResultsByNode + ProjectLoader2.EvaluateAsGraph(innerBuildEntryPoints, ?projectCollection = projectCollection, ?projectInstanceFactory = projectInstanceFactory) + + /// + /// Executes a build request against the BuildManagerSession. + /// + /// The BuildManagerSession to use for the build.The project graph to build. + /// Optional build parameters to use for the build.Optional targets to build. Defaults to design-time build targets. + /// Optional flags for the build request. Defaults to ProjectLoader2.DefaultFlags. + /// Optional cancellation token to cancel the + /// build. + /// A either a GraphBuildResult or an error containing the failed build and message. + static member Execution(session: BuildManagerSession, graph: ProjectGraph, ?buildParameters: BuildParameters, ?targetsToBuild: string array, ?flags: BuildRequestDataFlags, ?ct: CancellationToken) = + task { + let targetsToBuild = defaultArg targetsToBuild (ProjectLoader.designTimeBuildTargets false) + + let flags = defaultArg flags ProjectLoader2.DefaultFlags + + let request = + GraphBuildRequestData(projectGraph = graph, targetsToBuild = targetsToBuild, hostServices = null, flags = flags) + + return! session.BuildAsync(request, ?buildParameters = buildParameters, ?ct = ct) + } + + /// + /// Executes a build request against the BuildManagerSession. + /// + /// The BuildManagerSession to use for the build.The project instance to build. + /// Optional build parameters to use for the build.Optional targets to build. Defaults to design-time build targets. + /// Optional flags for the build request. Defaults to ProjectLoader2.DefaultFlags. + /// Optional cancellation token to cancel the + /// build. + /// A either a BuildResult or an error containing the failed build and message. + static member Execution(session: BuildManagerSession, projectInstance: ProjectInstance, ?buildParameters: BuildParameters, ?targetsToBuild: string array, ?flags: BuildRequestDataFlags, ?ct: CancellationToken) = + task { + let targetsToBuild = defaultArg targetsToBuild (ProjectLoader.designTimeBuildTargets false) + + let flags = defaultArg flags ProjectLoader2.DefaultFlags + + let request = + BuildRequestData(projectInstance = projectInstance, targetsToBuild = targetsToBuild, hostServices = null, flags = flags) + + return! session.BuildAsync(request, ?buildParameters = buildParameters, ?ct = ct) + } + + /// + /// Walks the project references of the given projects and executes a build for each project. + /// + /// The BuildManagerSession to use for the build.The projects to walk references for. + /// Function to get build parameters for each project.Optional targets to build. Defaults to design-time build targets. + /// Optional flags for the build request. Defaults to ProjectLoader2.DefaultFlags. + /// Optional cancellation token to cancel the build. + /// A task that returns an array of BuildResult or an error containing the failed build and message. + /// + /// This method will visit each project, build it, and then recursively visit its references. + /// It will return an array of BuildResult for each project that was built. + /// If a project has already been visited, it will not be visited again. + /// + /// This is useful for scenarios where you want to build a project and all of its references. + /// + static member ExecutionWalkReferences(session: BuildManagerSession, projects: Project seq, buildParameters: Project -> BuildParameters option, ?targetsToBuild: string array, ?flags: BuildRequestDataFlags, ?ct: CancellationToken) = + task { + let projectsToVisit = Queue projects + + let visited = Dictionary>() + + while projectsToVisit.Count > 0 do + let p = projectsToVisit.Dequeue() + + match visited.TryGetValue p with + | true, _ -> () + | _ -> + + let projectInstance = p.CreateProjectInstance() + let bp = buildParameters p + + let! result = ProjectLoader2.Execution(session, projectInstance, ?buildParameters = bp, ?targetsToBuild = targetsToBuild, ?flags = flags, ?ct = ct) + visited.Add(p, result) + + match result with + | Ok result -> + let references = + result.ProjectStateAfterBuild.Items + |> Seq.choose (fun item -> + if + item.ItemType = "_MSBuildProjectReferenceExistent" + && item.HasMetadata "FullPath" + then + Some(item.GetMetadataValue "FullPath") + else + None + ) + + ProjectLoader2.EvaluateAsProjectsAllTfms(references, projectCollection = p.ProjectCollection) + |> Seq.filter ( + visited.ContainsKey + >> not + ) + |> Seq.iter projectsToVisit.Enqueue + | _ -> () + + return + visited.Values + |> Seq.toArray + } + + /// + /// Gets the project instance from a BuildResult. + /// + /// The BuildResult to get the project instance from. + /// The project instance from the BuildResult. + + static member GetProjectInstance(buildResult: BuildResult) = buildResult.ProjectStateAfterBuild + + /// + /// Gets the project instance from a sequence of BuildResults. + /// + /// The sequence of BuildResults to get the project instances from. + /// The project instances from the sequence of BuildResults. + static member GetProjectInstances(buildResults: BuildResult seq) = + buildResults + |> Seq.map ProjectLoader2.GetProjectInstance + + /// + /// Gets the project instances from a GraphBuildResult. + /// + /// The GraphBuildResult to get the project instances from. + /// The project instances from the GraphBuildResult. + static member GetProjectInstances(graphBuildResult: GraphBuildResult) = + graphBuildResult.ResultsByNode + |> Seq.map (fun (KeyValue(node, result)) -> ProjectLoader2.GetProjectInstance result) + + + /// + /// Parses a BuildResult or GraphBuildResult into a ProjectInfo. + /// + /// The BuildResult or GraphBuildResult to parse. + /// A sequence of ProjectInfo parsed from the BuildResult or GraphBuildResult. + static member Parse(graphBuildResult: GraphBuildResult) = + graphBuildResult + |> ProjectLoader2.GetProjectInstances + |> Seq.map ProjectLoader2.Parse + + /// + /// Parses a BuildResult into a ProjectInfo. + /// + /// The BuildResult to parse. + /// A ProjectInfo parsed from the BuildResult. + static member Parse(buildResult: BuildResult) = + buildResult + |> ProjectLoader2.GetProjectInstance + |> ProjectLoader2.Parse + + /// + /// Parses a sequence of ProjectInstances into a sequence of ProjectInfo. + /// + /// The sequence of ProjectInstances to parse. + /// A sequence of ProjectInfo parsed from the ProjectInstances. + static member Parse(projectInstances: ProjectInstance seq) = + projectInstances + |> Seq.toArray + |> Array.Parallel.map ProjectLoader2.Parse + + /// + /// Parses a ProjectInstance into a ProjectInfo. + /// + /// The ProjectInstance to parse. + /// A ProjectInfo parsed from the ProjectInstance. + static member Parse(projectInstances: ProjectInstance) = + ProjectLoader.getLoadedProjectInfo projectInstances.FullPath [] (ProjectLoader.StandardProject projectInstances) diff --git a/src/Ionide.ProjInfo/Types.fs b/src/Ionide.ProjInfo/Types.fs index f1daed8f..c97d525a 100644 --- a/src/Ionide.ProjInfo/Types.fs +++ b/src/Ionide.ProjInfo/Types.fs @@ -84,6 +84,10 @@ module Types = /// Will have Key Value pairs like "PkgIonide_Analyzers" -> "C:\Users\username\.nuget\packages\ionide.analyzers\0.14.7" /// The "analyzers/dotnet/fs" subfolder is not included here, just the package root Analyzers: Analyzer list + /// The full file paths of all the files that during evaluation contributed to this project instance. + /// This does not include projects that were never imported because a condition on an Import element was false. + /// The outer ProjectRootElement that maps to this project instance itself is not included. + Imports: string list } with /// ResolvedTargetPath is the path to the primary reference assembly for this project. diff --git a/test/Ionide.ProjInfo.Tests/Ionide.ProjInfo.Tests.fsproj b/test/Ionide.ProjInfo.Tests/Ionide.ProjInfo.Tests.fsproj index b947c34e..0b632e2e 100644 --- a/test/Ionide.ProjInfo.Tests/Ionide.ProjInfo.Tests.fsproj +++ b/test/Ionide.ProjInfo.Tests/Ionide.ProjInfo.Tests.fsproj @@ -12,27 +12,21 @@ + + - + - - - + + - @@ -40,9 +34,23 @@ - + - + + + $(MSBuildBinPath)\Microsoft.Build.dll + + + $(MSBuildBinPath)\Microsoft.Build.Framework.dll + + + $(MSBuildBinPath)\Microsoft.Build.Utilities.Core.dll + + + $(MSBuildBinPath)\Microsoft.Build.Tasks.Core.dll + + + \ No newline at end of file diff --git a/test/Ionide.ProjInfo.Tests/ProjectLoader2Tests.fs b/test/Ionide.ProjInfo.Tests/ProjectLoader2Tests.fs new file mode 100644 index 00000000..69f05ed6 --- /dev/null +++ b/test/Ionide.ProjInfo.Tests/ProjectLoader2Tests.fs @@ -0,0 +1,624 @@ +namespace Ionide.ProjInfo.Tests + +module ProjectLoader2Tests = + + + open System + open System.IO + open System.Diagnostics + open System.Threading + open System.Threading.Tasks + open Expecto + open Ionide.ProjInfo + open Ionide.ProjInfo.Types + open FileUtils + open Ionide.ProjInfo.Tests.TestUtils + open DotnetProjInfo.TestAssets + open Ionide.ProjInfo.Logging + open Expecto.Logging + open Ionide.ProjInfo.ProjectLoader + open Microsoft.Build.Graph + open Microsoft.Build.Evaluation + open Microsoft.Build.Framework + open Microsoft.Build.Execution + open System.Linq + + + type Binlogs(binlog: FileInfo) = + let sw = new StringWriter() + let errorLogger = new ErrorLogger() + + let loggers name = + ProjectLoader.createLoggers name (BinaryLogGeneration.Within(binlog.Directory)) sw (Some errorLogger) + + member x.ErrorLogger = errorLogger + member x.Loggers name = loggers name + + member x.Directory = binlog.Directory + member x.File = binlog + + interface IDisposable with + member this.Dispose() = sw.Dispose() + + + type TestEnv = { + Logger: Logger + FS: FileUtils + Binlog: Binlogs + Data: TestAssetProjInfo2 + Entrypoints: string seq + TestDir: DirectoryInfo + } with + + interface IDisposable with + member this.Dispose() = (this.Binlog :> IDisposable).Dispose() + + + let projectCollection () = + new ProjectCollection( + globalProperties = dict ProjectLoader.defaultGlobalProps, + loggers = null, + remoteLoggers = null, + toolsetDefinitionLocations = ToolsetDefinitionLocations.Local, + maxNodeCount = Environment.ProcessorCount, + onlyLogCriticalEvents = false, + loadProjectsReadOnly = true + ) + + type IWorkspaceLoader2 = + abstract member Load: paths: string list * ct: CancellationToken -> Task>> + + + let parseWithGraph (env: TestEnv) = + task { + let entrypoints = + env.Entrypoints + |> Seq.map ProjectGraphEntryPoint + + let loggers = env.Binlog.Loggers env.Binlog.File.Name + + // Evaluation + use pc = projectCollection () + let graph = ProjectLoader2.EvaluateAsGraphAllTfms(entrypoints, pc) + + // Execution + let bp = BuildParameters(Loggers = loggers) + let bm = new BuildManagerSession() + + let! (result: Result>) = ProjectLoader2.Execution(bm, graph, bp) + + + // Parse + let projectsAfterBuild = + match result with + | Ok result -> + ProjectLoader2.Parse result + |> Seq.choose (fun x -> + match x with + | Ok(LoadedProjectInfo.StandardProjectInfo x) -> Some x + | Result.Error(ParseError.NotRestored _) -> None + | _ -> None + ) + | Result.Error(BuildErrors.BuildErr(result, errorLogs)) -> + + Seq.empty + + return result, projectsAfterBuild + } + + let parseWithProjectWalker (env: TestEnv) = + task { + let path = env.Entrypoints + + let entrypoints = + path + |> Seq.collect (fun p -> + if p.EndsWith(".sln") then + p + |> InspectSln.tryParseSln + |> getResult + |> InspectSln.loadingBuildOrder + else + [ p ] + ) + // Evaluation + use pc = projectCollection () + + let allprojects = + ProjectLoader2.EvaluateAsProjectsAllTfms(entrypoints, projectCollection = pc) + |> Seq.toList + + let createBuildParametersFromProject (p: Project) = + let fi = FileInfo p.FullPath + let projectName = Path.GetFileNameWithoutExtension fi.Name + + let tfm = + match p.GlobalProperties.TryGetValue("TargetFramework") with + | true, tfm -> tfm.Replace('.', '_') + | _ -> "" + + let normalized = $"{projectName}-{tfm}" + Some(BuildParameters(Loggers = env.Binlog.Loggers normalized)) + + // Execution + let bm = new BuildManagerSession() + let! (results: Result> array) = ProjectLoader2.ExecutionWalkReferences(bm, allprojects, createBuildParametersFromProject) + + let projectsAfterBuild = + results + |> Seq.choose ( + function + | Ok result -> + match ProjectLoader2.Parse result with + | Ok(LoadedProjectInfo.StandardProjectInfo x) -> + let validProjectRestore = Newtonsoft.Json.JsonConvert.SerializeObject x + File.WriteAllText(Path.Combine(env.TestDir.FullName, "restored-project.json"), validProjectRestore) + Some x + | Result.Error(ParseError.NotRestored _) -> None + | _ -> None + | _ -> None + ) + + return results, projectsAfterBuild + } + + type RestoreCfg = + | DoRestore + | SkipRestore + + let testWithEnv2 restore name (data: TestAssetProjInfo2) f test = + test + name + (fun () -> + task { + let logger = Log.create (sprintf "Test '%s'" name) + let fs = FileUtils logger + + let testDir = inDir fs name + copyDirFromAssets fs data.ProjDir testDir + + let entrypoints = + data.EntryPoints + |> Seq.map (fun x -> + testDir + / x + ) + + match restore with + | DoRestore -> + entrypoints + |> Seq.iter (fun x -> + dotnet fs [ + "restore" + x + ] + |> checkExitCodeZero + ) + | SkipRestore -> () + + let binlog = new FileInfo(Path.Combine(testDir, $"{name}.binlog")) + use blc = new Binlogs(binlog) + + let env = { + Logger = logger + FS = fs + Binlog = blc + Data = data + Entrypoints = entrypoints + TestDir = DirectoryInfo testDir + } + + try + do! f env + with e -> + + logger.error ( + Message.eventX "binlog path {binlog}" + >> Message.setField "binlog" binlog.FullName + ) + + Exception.reraiseAny e + } + ) + + let testWithEnv name (data: TestAssetProjInfo2) f test = testWithEnv2 DoRestore name data f test + + + let applyTests testCaseTask name (info: TestAssetProjInfo2) = [ + testCaseTask + |> testWithEnv + $"Graph.{name}" + info + (fun env -> + task { + let! result, projectsAfterBuild = parseWithGraph env + + do! env.Data.ExpectsGraphResult result + do! env.Data.ExpectsProjectOptions projectsAfterBuild + } + ) + testCaseTask + |> testWithEnv + $"Project.{name}" + info + (fun env -> + task { + let! result, projectsAfterBuild = parseWithProjectWalker env + + do! env.Data.ExpectsProjectResult result + do! env.Data.ExpectsProjectOptions projectsAfterBuild + } + ) + ] + + + let buildManagerSessionTests toolsPath = + testList "buildManagerSessionTests" [ + yield! applyTests testCaseTask "loader2-no-solution-with-2-projects" ``loader2-no-solution-with-2-projects`` + + yield! applyTests testCaseTask "sample2-NetSdk-library2" ``sample2-NetSdk-library2`` + yield! applyTests ptestCaseTask "sample3-Netsdk-projs" ``sample3-Netsdk-projs-2`` + + yield! applyTests testCaseTask "sample4-NetSdk-multitfm" ``sample4-NetSdk-multitfm-2`` + yield! applyTests testCaseTask "sample5-NetSdk-lib-cs" ``sample5-NetSdk-lib-cs-2`` + yield! applyTests testCaseTask "sample6-NetSdk-sparse" ``sample6-Netsdk-Sparse-sln-2`` + yield! applyTests testCaseTask "sample7-oldsdk-projs" ``sample7-legacy-framework-multi-project-2`` + yield! applyTests testCaseTask "sample8-NetSdk-Explorer" ``sample8-NetSdk-Explorer-2`` + yield! applyTests testCaseTask "sample9-NetSdk-library" ``sample9-NetSdk-library-2`` + yield! applyTests testCaseTask "sample10-NetSdk-custom-targets" ``sample10-NetSdk-library-with-custom-targets-2`` + + yield! applyTests testCaseTask "sample-referenced-csharp-project" ``sample-referenced-csharp-project`` + // yield! applyTests testCaseTask "sample-workload" ``sample-workload`` + yield! applyTests testCaseTask "traversal-project" ``traversal-project`` + yield! applyTests testCaseTask "sample11-solution-with-other-projects" ``sample11-solution-with-other-projects`` + // yield! applyTests testCaseTask "sample12-solution-filter-with-one-project" ``sample12-solution-filter-with-one-project`` + yield! applyTests testCaseTask "sample13-solution-with-solution-files" ``sample13-solution-with-solution-files`` + // yield! applyTests testCaseTask "sample-14-slnx-solution" ``sample-14-slnx-solution`` + yield! applyTests testCaseTask "sample15-nuget-analyzers" ``sample15-nuget-analyzers`` + yield! applyTests testCaseTask "sample16-solution-with-solution-folders" ``sample16-solution-with-solution-folders`` + yield! applyTests testCaseTask "sample-netsdk-prodref" ``sample-netsdk-prodref`` + yield! applyTests testCaseTask "sample-netsdk-bad-cache" ``sample-netsdk-bad-cache-2`` + + testCaseTask + |> testWithEnv2 + SkipRestore + "missing-import" + ``missing-import`` + (fun env -> + task { + let! result, projectsAfterBuild = parseWithProjectWalker env + + do! env.Data.ExpectsProjectResult result + do! env.Data.ExpectsProjectOptions projectsAfterBuild + } + ) + + + testCaseTask + |> testWithEnv + "sample2-NetSdk-library2 - Graph" + ``sample2-NetSdk-library2`` + (fun env -> + task { + let projPath = + env.TestDir.FullName + / env.Data.EntryPoints.Single() + + let projDir = Path.GetDirectoryName projPath + + let path = + env.Entrypoints + |> Seq.map ProjectGraphEntryPoint + + let loggers = env.Binlog.Loggers env.Binlog.File.Name + + // Evaluation + use pc = projectCollection () + + let graph = ProjectLoader2.EvaluateAsGraphAllTfms(path, pc) + + // Execution + let bp = BuildParameters(Loggers = loggers) + let bm = new BuildManagerSession() + + let! (result: Result>) = ProjectLoader2.Execution(bm, graph, bp) + + let expectedSources = + [ + projDir + / "obj/Debug/netstandard2.0/n1.AssemblyInfo.fs" + projDir + / "obj/Debug/netstandard2.0/.NETStandard,Version=v2.0.AssemblyAttributes.fs" + projDir + / "Library.fs" + ] + |> List.map Path.GetFullPath + + match result with + | Result.Error _ -> failwith "expected success" + | Ok result -> + ProjectLoader2.Parse result + |> Seq.choose ( + function + | Ok(LoadedProjectInfo.StandardProjectInfo x) -> + let validProjectRestore = Newtonsoft.Json.JsonConvert.SerializeObject x + File.WriteAllText(Path.Combine(env.TestDir.FullName, "restored-project.json"), validProjectRestore) + Some x + | Result.Error(ParseError.NotRestored _) -> None + | _ -> None + ) + |> Seq.iter (fun x -> Expect.equal x.SourceFiles expectedSources "") + + () + + } + ) + + let contains (s: string) (o: string) = o.Contains(s) + let endsWith (s: string) (o: string) = o.EndsWith(s) + + let filesOfInterest = [ + endsWith "Directory.Build.props" + endsWith "Directory.Build.targets" + endsWith "Directory.Build.props" + endsWith ".sln.targets" + endsWith ".Build.rsp" + ] + + + testCaseTask + |> testWithEnv + "sample2-NetSdk-library2" + ``sample2-NetSdk-library2`` + (fun env -> + task { + let projPath = + env.TestDir.FullName + / env.Data.EntryPoints.Single() + + let projDir = Path.GetDirectoryName projPath + + + let entryPoints = env.Entrypoints + + let loggers = env.Binlog.Loggers + + // Evaluation + use pc = projectCollection () + + let createBuildParametersFromProject (p: Project) = + let fi = FileInfo p.FullPath + let projectName = Path.GetFileNameWithoutExtension fi.Name + + let tfm = + match p.GlobalProperties.TryGetValue("TargetFramework") with + | true, tfm -> tfm.Replace('.', '_') + | _ -> "" + + let normalized = $"{projectName}-{tfm}" + + Some(BuildParameters(Loggers = env.Binlog.Loggers normalized)) + + let projs = ProjectLoader2.EvaluateAsProjectsAllTfms(entryPoints, projectCollection = pc) + + let projs = Seq.toList projs + // Execution + + let bm = new BuildManagerSession() + + let! (results: Result<_, BuildErrors> array) = ProjectLoader2.ExecutionWalkReferences(bm, projs, createBuildParametersFromProject) + + let result = + results + |> Seq.head + + let expectedSources = + [ + projDir + / "obj/Debug/netstandard2.0/n1.AssemblyInfo.fs" + projDir + / "obj/Debug/netstandard2.0/.NETStandard,Version=v2.0.AssemblyAttributes.fs" + projDir + / "Library.fs" + ] + |> List.map Path.GetFullPath + + match result with + | Result.Error _ -> failwith "expected success" + | Ok result -> + ignore result.ProjectStateAfterBuild.ImportPaths + + match ProjectLoader2.Parse result with + + | Ok(LoadedProjectInfo.StandardProjectInfo x) -> + let validProjectRestore = Newtonsoft.Json.JsonConvert.SerializeObject x + File.WriteAllText(Path.Combine(env.TestDir.FullName, "restored-project.json"), validProjectRestore) + + Expect.equal x.SourceFiles expectedSources "" + | Result.Error(ParseError.NotRestored e) -> failwithf "%A" e + | otherwise -> failwith $"Unexpected result {otherwise}" + + } + ) + + testCaseTask + |> testWithEnv + "Concurrency - don't crash on concurrent builds" + ``loader2-concurrent`` + (fun env -> + task { + let path = + env.Entrypoints + |> Seq.map ProjectGraphEntryPoint + + use pc = projectCollection () + + let bp = BuildParameters(Loggers = env.Binlog.Loggers env.Binlog.File.Name) + + let bm = new BuildManagerSession() + + let work: Async>> = + async { + + // Evaluation + let graph = ProjectLoader2.EvaluateAsGraph(path, pc) + + // Execution + return! + ProjectLoader2.Execution(bm, graph, buildParameters = bp) + |> Async.AwaitTask + } + + // Should be throttled so concurrent builds won't fail + let! _ = + Async.Parallel [ + work + work + work + // work + ] + + + () + + } + ) + + testCaseTask + |> testWithEnv + "Failure mode 1" + ``loader2-failure-case1`` + (fun env -> + + task { + let path = + env.Entrypoints + |> Seq.map ProjectGraphEntryPoint + + let loggers = env.Binlog.Loggers env.Binlog.File.Name + + // Evaluation + use pc = projectCollection () + let graph = ProjectLoader2.EvaluateAsGraphAllTfms(path, pc) + + // Execution + let bp = BuildParameters(Loggers = loggers) + let bm = new BuildManagerSession() + + let! (result: Result>) = ProjectLoader2.Execution(bm, graph, bp) + Expect.isError result "expected error" + + match result with + | Ok _ -> failwith "expected error" + | Result.Error(BuildErrors.BuildErr(result, errorLogs)) -> + let results: (ProjectGraphNode * BuildErrors) seq = GraphBuildResult.isolateFailures result errorLogs + + let _, BuildErr(_, errors) = + results + |> Seq.head + + let actualError = + errors + |> Seq.head + |> _.Message + + Expect.equal actualError "Intentional failure" "expected error message" + } + ) + + + testCaseTask + |> testWithEnv + "Cancellation" + ``loader2-cancel-slow`` + (fun env -> + task { + let path = + env.Entrypoints + |> Seq.map ProjectGraphEntryPoint + + // Evaluation + use pc = projectCollection () + + let graph = ProjectLoader2.EvaluateAsGraph(path, pc) + + // Execution + let bp = BuildParameters(Loggers = env.Binlog.Loggers env.Binlog.File.Name) + let bm = new BuildManagerSession() + use cts = new CancellationTokenSource() + + try + cts.CancelAfter(TimeSpan.FromSeconds 1.) + + let! (_: Result>) = ProjectLoader2.Execution(bm, graph, bp, ct = cts.Token) + () + with + | :? OperationCanceledException as oce -> Expect.equal oce.CancellationToken cts.Token "expected cancellation" + | e -> Exception.reraiseAny e + + } + ) + + testCaseTask + |> testWithEnv2 + SkipRestore + "Handle non-restored projects gracefully" + ``sample2-NetSdk-library2`` + (fun env -> + task { + let path = + env.Entrypoints + |> Seq.map ProjectGraphEntryPoint + + let entryPoints = env.Entrypoints + // Evaluation + use pc = projectCollection () + + // let graph = ProjectLoader2.EvaluateAsGraph(path, pc) + + // Execution + let bp = BuildParameters(Loggers = env.Binlog.Loggers env.Binlog.File.Name) + let bm = new BuildManagerSession() + + let createBuildParametersFromProject (p: Project) = + let fi = FileInfo p.FullPath + let projectName = Path.GetFileNameWithoutExtension fi.Name + + let tfm = + match p.GlobalProperties.TryGetValue("TargetFramework") with + | true, tfm -> tfm.Replace('.', '_') + | _ -> "" + + let normalized = $"{projectName}-{tfm}" + + Some(BuildParameters(Loggers = env.Binlog.Loggers normalized)) + + let projs = ProjectLoader2.EvaluateAsProjectsAllTfms(entryPoints, projectCollection = pc) + + + let! (results: Result<_, BuildErrors> array) = ProjectLoader2.ExecutionWalkReferences(bm, projs, createBuildParametersFromProject) + + + let result = + results + |> Seq.head + + let result = Ionide.ProjInfo.Tests.Expect.isOk result "expected success on non-restored project" + + match ProjectLoader2.Parse result with + // | Ok(LoadedProjectInfo.StandardProjectInfo x) -> + // ignore x + + // let validProjectRestore = Newtonsoft.Json.JsonConvert.SerializeObject x + + // File.WriteAllText(Path.Combine(env.TestDir.FullName, "non-restored-project.json"), validProjectRestore) + // Expect.isFalse x.ProjectSdkInfo.RestoreSuccess "expected restore to fail" + | Result.Error(ParseError.NotRestored(StandardProject e)) -> () + | e -> failwithf "%A" e + + return () + + } + ) + + ] diff --git a/test/Ionide.ProjInfo.Tests/TestAssets.fs b/test/Ionide.ProjInfo.Tests/TestAssets.fs index a8959743..c617be7b 100644 --- a/test/Ionide.ProjInfo.Tests/TestAssets.fs +++ b/test/Ionide.ProjInfo.Tests/TestAssets.fs @@ -1,6 +1,28 @@ module DotnetProjInfo.TestAssets open FileUtils +open Ionide.ProjInfo.Types +open Expecto +open Microsoft.Build.Graph +open Microsoft.Build.Framework +open Ionide.ProjInfo +open Microsoft.Build.Execution +open System.Threading.Tasks + + +type BuildErrors<'BuildResult> = + | BuildErr of 'BuildResult * BuildErrorEventArgs list + + interface BuildResultFailure, 'BuildResult> with + static member BuildFailure(result, errorLogs) = BuildErr(result, errorLogs) + +type TestAssetProjInfo2 = { + ProjDir: string + EntryPoints: string seq + ExpectsGraphResult: Result> -> ValueTask + ExpectsProjectResult: Result> array -> ValueTask + ExpectsProjectOptions: ProjectOptions seq -> ValueTask +} type TestAssetProjInfo = { ProjDir: string @@ -394,3 +416,700 @@ let ``sample 16 solution folders (.slnx)`` = { TargetFrameworks = Map.empty ProjectReferences = [] } + +let ``loader2-solution-with-2-projects`` = { + ProjDir = "loader2-solution-with-2-projects" + EntryPoints = [ "loader2-solution-with-2-projects.sln" ] + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 3 "projects count" + + let classlibf1s = + projectsAfterBuild + |> Seq.filter (fun x -> x.ProjectFileName.EndsWith("classlibf1.fsproj")) + + Expect.hasLength classlibf1s 2 "" + + let classlibf1net80 = + classlibf1s + |> Seq.find (fun x -> x.TargetFramework = "net8.0") + + Expect.equal classlibf1net80.SourceFiles.Length 3 "classlibf1 source files" + + + let classlibf1ns21 = + classlibf1s + |> Seq.find (fun x -> x.TargetFramework = "netstandard2.1") + + Expect.equal classlibf1ns21.SourceFiles.Length 3 "classlibf1 source files" + + let classlibf2 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("classlibf2.fsproj")) + + Expect.equal classlibf2.SourceFiles.Length 3 "classlibf2 source files" + Expect.equal classlibf2.TargetFramework "netstandard2.0" "classlibf1 target framework" + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + + +let ``loader2-no-solution-with-2-projects`` = { + ProjDir = "loader2-no-solution-with-2-projects" + EntryPoints = [ + "src" + / "classlibf1" + / "classlibf1.fsproj" + ] + ExpectsProjectOptions = + fun projectsAfterBuild -> + let projectPaths = + projectsAfterBuild + |> Seq.map (_.ProjectFileName) + |> String.concat "\n" + + Expect.equal (Seq.length projectsAfterBuild) 3 $"Should be three projects but got {Seq.length projectsAfterBuild} : {projectPaths}" + + let classlibf1s = + projectsAfterBuild + |> Seq.filter (fun x -> x.ProjectFileName.EndsWith("classlibf1.fsproj")) + + Expect.hasLength classlibf1s 2 "" + + let classlibf1net80 = + classlibf1s + |> Seq.find (fun x -> x.TargetFramework = "net8.0") + + Expect.equal classlibf1net80.SourceFiles.Length 3 "classlibf1 source files" + + + let classlibf1ns21 = + classlibf1s + |> Seq.find (fun x -> x.TargetFramework = "netstandard2.1") + + Expect.equal classlibf1ns21.SourceFiles.Length 3 "classlibf1 source files" + + let classlibf2 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("classlibf2.fsproj")) + + Expect.equal classlibf2.SourceFiles.Length 3 "classlibf2 source files" + Expect.equal classlibf2.TargetFramework "netstandard2.0" "classlibf1 target framework" + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + + +let ``loader2-cancel-slow`` = { + ProjDir = "loader2-cancel-slow" + EntryPoints = [ + "classlibf1" + / "classlibf1.fsproj" + ] + ExpectsProjectOptions = fun _ -> ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``loader2-concurrent`` = { + ProjDir = "loader2-concurrent" + EntryPoints = [ + "classlibf1" + / "classlibf1.fsproj" + ] + ExpectsProjectOptions = fun _ -> ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``loader2-failure-case1`` = { + ProjDir = "loader2-failure-case1" + EntryPoints = [ "loader2-failure-case1.fsproj" ] + ExpectsProjectOptions = fun _ -> ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample2-NetSdk-library2`` = { + ProjDir = ``sample2 NetSdk library``.ProjDir + EntryPoints = [ ``sample2 NetSdk library``.ProjectFile ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 1 "Should have 1 project" + + let n1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("n1.fsproj")) + + // Check source files contain Library.fs and generated files + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "Should contain Library.fs" + + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("n1.AssemblyInfo.fs"))) + "Should contain AssemblyInfo.fs" + + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.Contains(".NETStandard,Version=v2.0.AssemblyAttributes.fs"))) + "Should contain AssemblyAttributes.fs" + + Expect.equal n1.SourceFiles.Length 3 "Should have 3 source files" + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample3-Netsdk-projs-2`` = { + ProjDir = ``sample3 Netsdk projs``.ProjDir + EntryPoints = [ ``sample3 Netsdk projs``.ProjectFile ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 3 "Should have 3 projects (c1, l1, l2)" + + // c1 - F# console + let c1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("c1.fsproj")) + + Expect.isTrue + (c1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Program.fs"))) + "c1 should contain Program.fs" + + Expect.equal c1.SourceFiles.Length 3 "c1 should have 3 source files" + + // l1 - C# lib + let l1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("l1.csproj")) + + Expect.isTrue + (l1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Class1.cs"))) + "l1 should contain Class1.cs" + + // l2 - F# lib + let l2 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("l2.fsproj")) + + Expect.isTrue + (l2.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "l2 should contain Library.fs" + + Expect.equal l2.SourceFiles.Length 3 "l2 should have 3 source files" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample4-NetSdk-multitfm-2`` = { + ProjDir = ``sample4 NetSdk multi tfm``.ProjDir + EntryPoints = [ ``sample4 NetSdk multi tfm``.ProjectFile ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + // Multi-TFM project returns multiple ProjectOptions (one per TFM) + Expect.isGreaterThanOrEqual (Seq.length projectsAfterBuild) 1 "Should have at least 1 project" + + let m1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("m1.fsproj")) + + // Check source files contain LibraryA.fs + Expect.isTrue + (m1.SourceFiles + |> List.exists (fun s -> s.EndsWith("LibraryA.fs"))) + "Should contain LibraryA.fs" + + Expect.isTrue + (m1.SourceFiles + |> List.exists (fun s -> s.EndsWith("m1.AssemblyInfo.fs"))) + "Should contain AssemblyInfo.fs" + + Expect.equal m1.SourceFiles.Length 3 "Should have 3 source files" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample5-NetSdk-lib-cs-2`` = { + ProjDir = ``sample5 NetSdk CSharp library``.ProjDir + EntryPoints = [ ``sample5 NetSdk CSharp library``.ProjectFile ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 1 "Should have 1 project" + + let l2 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("l2.csproj")) + + // Check source files contain Class1.cs and generated files + Expect.isTrue + (l2.SourceFiles + |> List.exists (fun s -> s.EndsWith("Class1.cs"))) + "Should contain Class1.cs" + + Expect.isTrue + (l2.SourceFiles + |> List.exists (fun s -> s.EndsWith("l2.AssemblyInfo.cs"))) + "Should contain AssemblyInfo.cs" + + Expect.equal l2.SourceFiles.Length 3 "Should have 3 source files" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample6-Netsdk-Sparse-sln-2`` = { + ProjDir = ``sample6 Netsdk Sparse/sln``.ProjDir + EntryPoints = [ ``sample6 Netsdk Sparse/sln``.ProjectFile ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 3 "Should have 3 projects (c1, l1, l2)" + + // c1 - F# console + let c1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("c1.fsproj")) + + Expect.isTrue + (c1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Program.fs"))) + "c1 should contain Program.fs" + + Expect.equal c1.ReferencedProjects.Length 1 "c1 should have 1 project reference" + + // l1 - F# lib + let l1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("l1.fsproj")) + + Expect.isTrue + (l1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "l1 should contain Library.fs" + + Expect.equal l1.ReferencedProjects.Length 0 "l1 should have no project references" + + // l2 - F# lib + let l2 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("l2.fsproj")) + + Expect.isTrue + (l2.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "l2 should contain Library.fs" + + Expect.equal l2.ReferencedProjects.Length 0 "l2 should have no project references" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample7-legacy-framework-multi-project-2`` = { + ProjDir = ``sample7 legacy framework multi-project``.ProjDir + EntryPoints = [ ``sample7 legacy framework multi-project``.ProjectFile ] + + ExpectsProjectOptions = fun _ -> ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample8-NetSdk-Explorer-2`` = { + ProjDir = ``sample8 NetSdk Explorer``.ProjDir + EntryPoints = [ ``sample8 NetSdk Explorer``.ProjectFile ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 1 "Should have 1 project" + + let n1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("n1.fsproj")) + + // Check source files contain LibraryA.fs, LibraryB.fs, LibraryC.fs + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("LibraryA.fs"))) + "Should contain LibraryA.fs" + + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("LibraryB.fs"))) + "Should contain LibraryB.fs" + + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("LibraryC.fs"))) + "Should contain LibraryC.fs" + + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("n1.AssemblyInfo.fs"))) + "Should contain AssemblyInfo.fs" + + Expect.equal n1.SourceFiles.Length 5 "Should have 5 source files" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample9-NetSdk-library-2`` = { + ProjDir = ``sample9 NetSdk library``.ProjDir + EntryPoints = [ ``sample9 NetSdk library``.ProjectFile ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 1 "Should have 1 project" + + let n1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("n1.fsproj")) + + // Check source files contain Library.fs (this project uses custom obj folder "obj2") + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "Should contain Library.fs" + + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("n1.AssemblyInfo.fs"))) + "Should contain AssemblyInfo.fs" + + Expect.equal n1.SourceFiles.Length 3 "Should have 3 source files" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample10-NetSdk-library-with-custom-targets-2`` = { + ProjDir = ``sample10 NetSdk library with custom targets``.ProjDir + EntryPoints = [ ``sample10 NetSdk library with custom targets``.ProjectFile ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 1 "Should have 1 project" + + let n1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("n1.fsproj")) + + // Custom targets add BeforeBuild.fs and BeforeCompile.fs + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("BeforeBuild.fs"))) + "Should contain BeforeBuild.fs" + + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("BeforeCompile.fs"))) + "Should contain BeforeCompile.fs" + + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("n1.AssemblyInfo.fs"))) + "Should contain AssemblyInfo.fs" + + Expect.equal n1.SourceFiles.Length 4 "Should have 4 source files" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + + +let ``sample-referenced-csharp-project`` = { + ProjDir = "sample-referenced-csharp-project" + EntryPoints = [ + "fsharp-exe" + / "fsharp-exe.fsproj" + ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 2 "Should have 2 projects (fsharp-exe and csharp-lib)" + + // fsharp-exe - F# console referencing C# lib + let fsharpExe = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("fsharp-exe.fsproj")) + + Expect.isTrue + (fsharpExe.SourceFiles + |> List.exists (fun s -> s.EndsWith("Program.fs"))) + "fsharp-exe should contain Program.fs" + + Expect.equal fsharpExe.ReferencedProjects.Length 1 "fsharp-exe should have 1 project reference" + + // csharp-lib - C# library + let csharpLib = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("csharp-lib.csproj")) + + Expect.isTrue + (csharpLib.SourceFiles + |> List.exists (fun s -> s.EndsWith("Class1.cs"))) + "csharp-lib should contain Class1.cs" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample-workload`` = { + ProjDir = "sample-workload" + EntryPoints = [ "sample-workload.fsproj" ] + ExpectsProjectOptions = fun _ -> ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``traversal-project`` = { + ProjDir = "traversal-project" + EntryPoints = [ "dirs.proj" ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + // Traversal project (dirs.proj) references sample3-netsdk-projs/**/*.*proj + // Traversal projects are special - they don't produce a compilable output themselves + // They may return 0 projects (traversal-only) or include referenced projects + Expect.isGreaterThanOrEqual (Seq.length projectsAfterBuild) 0 "Should load successfully" + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample11-solution-with-other-projects`` = { + ProjDir = "sample11-solution-with-other-projects" + EntryPoints = [ "sample11-solution-with-other-projects.sln" ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + // Solution contains classlibf1.fsproj plus shared.shproj (which should be filtered out) + Expect.equal (Seq.length projectsAfterBuild) 1 "Should have 1 project (only classlibf1, shared.shproj filtered out)" + + let classlibf1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("classlibf1.fsproj")) + + Expect.isTrue + (classlibf1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "classlibf1 should contain Library.fs" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample12-solution-filter-with-one-project`` = { + ProjDir = "sample12-solution-filter-with-one-project" + EntryPoints = [ "sample12-solution-filter-with-one-project.slnf" ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + // Solution filter includes only classlibf2 (excludes classlibf1) + Expect.equal (Seq.length projectsAfterBuild) 1 "Should have 1 project (only classlibf2 from filter)" + + let classlibf2 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("classlibf2.fsproj")) + + Expect.isTrue + (classlibf2.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "classlibf2 should contain Library.fs" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample13-solution-with-solution-files`` = { + ProjDir = "sample13-solution-with-solution-files" + EntryPoints = [ "sample13-solution-with-solution-files.sln" ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + // Solution contains only Solution Items folder with README.md, no actual projects + Expect.equal (Seq.length projectsAfterBuild) 0 "Should have 0 projects (only solution items)" + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample-14-slnx-solution`` = { + ProjDir = "sample-14-slnx-solution" + EntryPoints = [ "sample-14-slnx-solution.slnx" ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 1 "Should have 1 project" + + let proj1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("proj1.fsproj")) + + Expect.isTrue + (proj1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "proj1 should contain Library.fs" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample15-nuget-analyzers`` = { + ProjDir = "sample15-nuget-analyzers" + EntryPoints = [ "sample15-nuget-analyzers.fsproj" ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 1 "Should have 1 project" + + let proj = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("sample15-nuget-analyzers.fsproj")) + + Expect.isTrue + (proj.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "Should contain Library.fs" + + // Verify analyzers are present (G-Research.FSharp.Analyzers and Ionide.Analyzers) + // Note: Analyzer detection requires the packages to be restored with the analyzers/dotnet/fs folder present + // The assertion is intentionally lenient as analyzers may not be available in all test environments + // Just verify the project loaded successfully with source files + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample16-solution-with-solution-folders`` = { + ProjDir = "sample16-solution-with-solution-folders" + EntryPoints = [ "sample16-solution-with-solution-folders.sln" ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + // Solution has: src/proj1, test/proj1.tests, and build.fsproj + Expect.equal (Seq.length projectsAfterBuild) 3 "Should have 3 projects (proj1, proj1.tests, build)" + + let proj1 = + projectsAfterBuild + |> Seq.find (fun x -> + x.ProjectFileName.EndsWith("proj1.fsproj") + && x.ProjectFileName.Contains("src") + ) + + Expect.isTrue + (proj1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "proj1 should contain Library.fs" + + let proj1Tests = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("proj1.tests.fsproj")) + + Expect.isTrue + (proj1Tests.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "proj1.tests should contain Library.fs" + + let build = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("build.fsproj")) + + Expect.isTrue (build.ProjectFileName.EndsWith("build.fsproj")) "build.fsproj should be loaded" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``missing-import`` = { + ProjDir = "missing-import" + EntryPoints = [ "missing-import.fsproj" ] + ExpectsProjectOptions = fun _ -> ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample-netsdk-prodref`` = { + ProjDir = "sample-netsdk-prodref" + EntryPoints = [ + "l2" + / "l2.fsproj" + ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + // l2 references l1 which has ProduceReferenceAssembly=true + Expect.equal (Seq.length projectsAfterBuild) 2 "Should have 2 projects (l1 and l2)" + + let l1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("l1.fsproj")) + + Expect.isTrue + (l1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "l1 should contain Library.fs" + + let l2 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("l2.fsproj")) + + Expect.isTrue + (l2.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "l2 should contain Library.fs" + + Expect.equal l2.ReferencedProjects.Length 1 "l2 should have 1 project reference to l1" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} + +let ``sample-netsdk-bad-cache-2`` = { + ProjDir = ``sample NetSdk library with a bad FSAC cache``.ProjDir + EntryPoints = [ ``sample NetSdk library with a bad FSAC cache``.ProjectFile ] + + ExpectsProjectOptions = + fun projectsAfterBuild -> + Expect.equal (Seq.length projectsAfterBuild) 1 "Should have 1 project" + + let n1 = + projectsAfterBuild + |> Seq.find (fun x -> x.ProjectFileName.EndsWith("n1.fsproj")) + + Expect.isTrue + (n1.SourceFiles + |> List.exists (fun s -> s.EndsWith("Library.fs"))) + "Should contain Library.fs" + + ValueTask.CompletedTask + ExpectsGraphResult = fun _ -> ValueTask.CompletedTask + ExpectsProjectResult = fun _ -> ValueTask.CompletedTask +} diff --git a/test/Ionide.ProjInfo.Tests/TestUtils.fs b/test/Ionide.ProjInfo.Tests/TestUtils.fs new file mode 100644 index 00000000..6f56dc0f --- /dev/null +++ b/test/Ionide.ProjInfo.Tests/TestUtils.fs @@ -0,0 +1,229 @@ +namespace Ionide.ProjInfo.Tests + +module TestUtils = + open DotnetProjInfo.TestAssets + open Expecto + open Expecto.Logging + open Expecto.Logging.Message + open FileUtils + open FSharp.Compiler.CodeAnalysis + open Ionide.ProjInfo + open Ionide.ProjInfo.Types + open Medallion.Shell + open System + open System.Collections.Generic + open System.IO + open System.Threading + open System.Xml.Linq + open System.Linq + + module Exception = + open System.Runtime.ExceptionServices + + let inline reraiseAny (e: exn) = + ExceptionDispatchInfo.Capture(e).Throw() + + + let RepoDir = + (__SOURCE_DIRECTORY__ + / ".." + / "..") + |> Path.GetFullPath + + + let ExamplesDir = + RepoDir + / "test" + / "examples" + + + let normalizeFileName (fileName: string) = + if String.IsNullOrEmpty fileName then + "" + else + let invalidChars = HashSet(Path.GetInvalidFileNameChars()) + let chars = fileName.AsSpan() + let mutable output = Span.Empty + let mutable outputIndex = 0 + let mutable lastWasUnderscore = false + + // Use a fixed-size buffer (stack-alloc if small enough) + let buffer = Span(Array.zeroCreate (min fileName.Length 255)) + output <- buffer + + for i = 0 to chars.Length + - 1 do + let c = chars.[i] + + if outputIndex < 255 then + if + invalidChars.Contains(c) + || Char.IsControl(c) + then + if + not lastWasUnderscore + && outputIndex > 0 + then + output.[outputIndex] <- '_' + + outputIndex <- + outputIndex + + 1 + + lastWasUnderscore <- true + else + output.[outputIndex] <- c + + outputIndex <- + outputIndex + + 1 + + lastWasUnderscore <- false + + // Trim leading/trailing underscores + let start = + if + outputIndex > 0 + && output.[0] = '_' + then + 1 + else + 0 + + let length = + if + outputIndex > 0 + && output.[outputIndex + - 1] = '_' + then + outputIndex + - start + - 1 + else + outputIndex + - start + + if + length + <= 0 + then + "" + else + output.Slice(start, length).ToString() + + let pathForTestAssets (test: TestAssetProjInfo) = + ExamplesDir + / test.ProjDir + + let pathForProject (test: TestAssetProjInfo) = + pathForTestAssets test + / test.ProjectFile + + let implAssemblyForProject (test: TestAssetProjInfo) = $"{test.AssemblyName}.dll" + + let refAssemblyForProject (test: TestAssetProjInfo) = + Path.Combine("ref", implAssemblyForProject test) + + let getResult (r: Result<_, _>) = + match r with + | Ok x -> x + | Result.Error e -> failwithf "%A" e + + let TestRunDir = + RepoDir + / "test" + / "testrun_ws" + + let TestRunInvariantDir = + TestRunDir + / "invariant" + + + let checkExitCodeZero (cmd: Command) = + Expect.equal 0 cmd.Result.ExitCode $"command {cmd.Result.StandardOutput} finished with exit code non-zero." + + let findByPath path parsed = + parsed + |> Array.tryPick (fun (kv: KeyValuePair) -> + if kv.Key = path then + Some kv + else + None + ) + |> function + | Some x -> x + | None -> + failwithf + "key '%s' not found in %A" + path + (parsed + |> Array.map (fun kv -> kv.Key)) + + let expectFind projPath msg (parsed: ProjectOptions list) = + let p = + parsed + |> List.tryFind (fun n -> n.ProjectFileName = projPath) + + Expect.isSome p msg + p.Value + + + let inDir (fs: FileUtils) dirName = + let outDir = + TestRunDir + / dirName + + fs.rm_rf outDir + fs.mkdir_p outDir + fs.cd outDir + outDir + + let copyDirFromAssets (fs: FileUtils) source outDir = + fs.mkdir_p outDir + + let path = + ExamplesDir + / source + + fs.cp_r path outDir + () + + let dotnet (fs: FileUtils) args = fs.shellExecRun "dotnet" args + + let withLog name f test = + test + name + (fun () -> + + let logger = Log.create (sprintf "Test '%s'" name) + let fs = FileUtils(logger) + f logger fs + ) + + let renderOf sampleProj sources = { + ProjectViewerTree.Name = + sampleProj.ProjectFile + |> Path.GetFileNameWithoutExtension + Items = + sources + |> List.map (fun (path, link) -> ProjectViewerItem.Compile(path, { ProjectViewerItemConfig.Link = link })) + } + + let createFCS () = + let checker = FSharpChecker.Create(projectCacheSize = 200, keepAllBackgroundResolutions = true, keepAssemblyContents = true) + checker + + let sleepABit () = + // CI has apparent occasional slowness + System.Threading.Thread.Sleep 5000 + + +module Expect = + open Expecto + + let isOk result msg = + Expecto.Expect.isOk result msg + + match result with + | Ok v -> v + | Error _ -> failwith "unreachable" diff --git a/test/Ionide.ProjInfo.Tests/Tests.fs b/test/Ionide.ProjInfo.Tests/Tests.fs index eeb16b36..2e9fbb18 100644 --- a/test/Ionide.ProjInfo.Tests/Tests.fs +++ b/test/Ionide.ProjInfo.Tests/Tests.fs @@ -1,5 +1,6 @@ module Tests + open DotnetProjInfo.TestAssets open Expecto open Expecto.Logging @@ -7,7 +8,6 @@ open Expecto.Logging.Message open FileUtils open FSharp.Compiler.CodeAnalysis open Ionide.ProjInfo -open Ionide.ProjInfo open Ionide.ProjInfo.Types open Medallion.Shell open System @@ -19,121 +19,13 @@ open System.Linq #nowarn "25" -let RepoDir = - (__SOURCE_DIRECTORY__ - / ".." - / "..") - |> Path.GetFullPath - -let ExamplesDir = - RepoDir - / "test" - / "examples" - -let pathForTestAssets (test: TestAssetProjInfo) = - ExamplesDir - / test.ProjDir - -let pathForProject (test: TestAssetProjInfo) = - pathForTestAssets test - / test.ProjectFile - -let implAssemblyForProject (test: TestAssetProjInfo) = $"{test.AssemblyName}.dll" - -let refAssemblyForProject (test: TestAssetProjInfo) = - Path.Combine("ref", implAssemblyForProject test) - -let getResult (r: Result<_, _>) = - match r with - | Ok x -> x - | Result.Error e -> failwithf "%A" e - -let TestRunDir = - RepoDir - / "test" - / "testrun_ws" - -let TestRunInvariantDir = - TestRunDir - / "invariant" - -let checkExitCodeZero (cmd: Command) = - Expect.equal 0 cmd.Result.ExitCode "command finished with exit code non-zero." - -let findByPath path parsed = - parsed - |> Array.tryPick (fun (kv: KeyValuePair) -> - if kv.Key = path then - Some kv - else - None - ) - |> function - | Some x -> x - | None -> - failwithf - "key '%s' not found in %A" - path - (parsed - |> Array.map (fun kv -> kv.Key)) - -let expectFind projPath msg (parsed: ProjectOptions list) = - let p = - parsed - |> List.tryFind (fun n -> n.ProjectFileName = projPath) - - Expect.isSome p msg - p.Value - - -let inDir (fs: FileUtils) dirName = - let outDir = - TestRunDir - / dirName - - fs.rm_rf outDir - fs.mkdir_p outDir - fs.cd outDir - outDir - -let copyDirFromAssets (fs: FileUtils) source outDir = - fs.mkdir_p outDir - - let path = - ExamplesDir - / source - - fs.cp_r path outDir - () - -let dotnet (fs: FileUtils) args = fs.shellExecRun "dotnet" args - -let withLog name f test = - test - name - (fun () -> - - let logger = Log.create (sprintf "Test '%s'" name) - let fs = FileUtils(logger) - f logger fs - ) - -let renderOf sampleProj sources = { - ProjectViewerTree.Name = - sampleProj.ProjectFile - |> Path.GetFileNameWithoutExtension - Items = - sources - |> List.map (fun (path, link) -> ProjectViewerItem.Compile(path, { ProjectViewerItemConfig.Link = link })) -} - -let createFCS () = - let checker = FSharpChecker.Create(projectCacheSize = 200, keepAllBackgroundResolutions = true, keepAssemblyContents = true) - checker +open Microsoft.Build.Execution +open Microsoft.Build.Graph +open System.Threading.Tasks +open Microsoft.Build.Evaluation +open Ionide.ProjInfo.ProjectLoader +open Ionide.ProjInfo.Tests.TestUtils -let sleepABit () = - // CI has apparent occasional slowness - System.Threading.Thread.Sleep 5000 [] module ExpectNotification = @@ -549,6 +441,7 @@ let testSample3 toolsPath workspaceLoader (workspaceFactory: ToolsPath -> IWorks Expect.equal c1Parsed c1Loaded "c1 notificaton and parsed should be the same" ) + let testSample4 toolsPath workspaceLoader (workspaceFactory: ToolsPath -> IWorkspaceLoader) = testCase |> withLog @@ -1378,6 +1271,61 @@ let testFCSmap toolsPath workspaceLoader (workspaceFactory: ToolsPath -> IWorksp ) +module Task = + let RunSynchronously (task: Task<'T>) = task.GetAwaiter().GetResult() + +module File = + + let combinePaths path1 (path2: string) = + Path.Combine( + path1, + path2.TrimStart [| + '\\' + '/' + |] + ) + + let () path1 path2 = combinePaths path1 path2 + + let rec copyDirectory (sourceDir: DirectoryInfo) destDir = + // Get the subdirectories for the specified directory. + // let dir = DirectoryInfo(sourceDir) + + if not sourceDir.Exists then + raise ( + DirectoryNotFoundException( + "Source directory does not exist or could not be found: " + + sourceDir.FullName + ) + ) + + let dirs = sourceDir.GetDirectories() + + // If the destination directory doesn't exist, create it. + Directory.CreateDirectory(destDir) + |> ignore + + // Get the files in the directory and copy them to the new location. + sourceDir.GetFiles() + |> Seq.iter (fun file -> + let tempPath = Path.Combine(destDir, file.Name) + + file.CopyTo(tempPath, false) + |> ignore + ) + + // If copying subdirectories, copy them and their contents to new location. + dirs + |> Seq.iter (fun dir -> + let tempPath = Path.Combine(destDir, dir.Name) + copyDirectory dir tempPath + ) + +open File +open Microsoft.Build.Framework +open Ionide.ProjInfo.Tests + + let testFCSmapManyProj toolsPath workspaceLoader (workspaceFactory: ToolsPath -> IWorkspaceLoader) = ptestCase |> withLog @@ -1509,6 +1457,7 @@ let testFCSmapManyProjCheckCaching = Analyzers = [] AllProperties = Map.empty AllItems = Map.empty + Imports = [] } let makeReference (options: ProjectOptions) = { @@ -1780,7 +1729,8 @@ let testLoadProject toolsPath = match ProjectLoader.getLoadedProjectInfo projPath [] proj with | Ok(ProjectLoader.LoadedProjectInfo.StandardProjectInfo proj) -> Expect.equal proj.ProjectFileName projPath "project file names" | Ok(ProjectLoader.LoadedProjectInfo.TraversalProjectInfo refs) -> failwith "expected standard project, not a traversal project" - | Result.Error err -> failwith $"{err}" + | Result.Error(ParseError.NotRestored exn) -> failwith $"Project Not Restored {exn}" + | otherwise -> failwith $"Unexpected result {otherwise}" ) let testProjectSystem toolsPath workspaceLoader workspaceFactory = @@ -2234,6 +2184,7 @@ let traversalProjectTest toolsPath loaderType workspaceFactory = $"can crack traversal projects - {loaderType}" (fun () -> let logger = Log.create "Test 'can crack traversal projects'" + let fs = FileUtils(logger) let projPath = pathForProject ``traversal project`` // // need to build the projects first so that there's something to latch on to @@ -2499,9 +2450,10 @@ let tests toolsPath = ExpectNotification.loaded "l1.fsproj" ] - testSequenced <| testList "Main tests" [ + + ProjectLoader2Tests.buildManagerSessionTests toolsPath testSample2 toolsPath "WorkspaceLoader" false (fun (tools, props) -> WorkspaceLoader.Create(tools, globalProperties = props)) testSample2 toolsPath "WorkspaceLoader" true (fun (tools, props) -> WorkspaceLoader.Create(tools, globalProperties = props)) testSample2 toolsPath "WorkspaceLoaderViaProjectGraph" false (fun (tools, props) -> WorkspaceLoaderViaProjectGraph.Create(tools, globalProperties = props)) diff --git a/test/examples/loader2-cancel-slow/classlibf1/Library.fs b/test/examples/loader2-cancel-slow/classlibf1/Library.fs new file mode 100644 index 00000000..7e962ecb --- /dev/null +++ b/test/examples/loader2-cancel-slow/classlibf1/Library.fs @@ -0,0 +1,5 @@ +namespace classlibf1 + +module Say = + let hello name = + printfn "Hello %s" name diff --git a/test/examples/loader2-cancel-slow/classlibf1/classlibf1.fsproj b/test/examples/loader2-cancel-slow/classlibf1/classlibf1.fsproj new file mode 100644 index 00000000..ea02ba78 --- /dev/null +++ b/test/examples/loader2-cancel-slow/classlibf1/classlibf1.fsproj @@ -0,0 +1,21 @@ + + + + net8.0 + true + + + + + + + + + + + + diff --git a/test/examples/loader2-concurrent/classlibf1/Library.fs b/test/examples/loader2-concurrent/classlibf1/Library.fs new file mode 100644 index 00000000..7e962ecb --- /dev/null +++ b/test/examples/loader2-concurrent/classlibf1/Library.fs @@ -0,0 +1,5 @@ +namespace classlibf1 + +module Say = + let hello name = + printfn "Hello %s" name diff --git a/test/examples/loader2-concurrent/classlibf1/classlibf1.fsproj b/test/examples/loader2-concurrent/classlibf1/classlibf1.fsproj new file mode 100644 index 00000000..516b0915 --- /dev/null +++ b/test/examples/loader2-concurrent/classlibf1/classlibf1.fsproj @@ -0,0 +1,21 @@ + + + + net8.0 + true + + + + + + + + + + + + diff --git a/test/examples/loader2-failure-case1/Program.fs b/test/examples/loader2-failure-case1/Program.fs new file mode 100644 index 00000000..d6818aba --- /dev/null +++ b/test/examples/loader2-failure-case1/Program.fs @@ -0,0 +1,2 @@ +// For more information see https://aka.ms/fsharp-console-apps +printfn "Hello from F#" diff --git a/test/examples/loader2-failure-case1/loader2-failure-case1.fsproj b/test/examples/loader2-failure-case1/loader2-failure-case1.fsproj new file mode 100644 index 00000000..75660036 --- /dev/null +++ b/test/examples/loader2-failure-case1/loader2-failure-case1.fsproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + loader2_failure_case1 + + + + + + + + + + + diff --git a/test/examples/loader2-no-solution-with-2-projects/src/classlibf1/Library.fs b/test/examples/loader2-no-solution-with-2-projects/src/classlibf1/Library.fs new file mode 100644 index 00000000..7e962ecb --- /dev/null +++ b/test/examples/loader2-no-solution-with-2-projects/src/classlibf1/Library.fs @@ -0,0 +1,5 @@ +namespace classlibf1 + +module Say = + let hello name = + printfn "Hello %s" name diff --git a/test/examples/loader2-no-solution-with-2-projects/src/classlibf1/classlibf1.fsproj b/test/examples/loader2-no-solution-with-2-projects/src/classlibf1/classlibf1.fsproj new file mode 100644 index 00000000..2e19bb13 --- /dev/null +++ b/test/examples/loader2-no-solution-with-2-projects/src/classlibf1/classlibf1.fsproj @@ -0,0 +1,16 @@ + + + + net8.0;netstandard2.1 + true + + + + + + + + + + + diff --git a/test/examples/loader2-no-solution-with-2-projects/src/classlibf2/Library.fs b/test/examples/loader2-no-solution-with-2-projects/src/classlibf2/Library.fs new file mode 100644 index 00000000..203ad113 --- /dev/null +++ b/test/examples/loader2-no-solution-with-2-projects/src/classlibf2/Library.fs @@ -0,0 +1,5 @@ +namespace classlibf2 + +module Say = + let hello name = + printfn "Hello %s" name diff --git a/test/examples/loader2-no-solution-with-2-projects/src/classlibf2/classlibf2.fsproj b/test/examples/loader2-no-solution-with-2-projects/src/classlibf2/classlibf2.fsproj new file mode 100644 index 00000000..c8d2ac82 --- /dev/null +++ b/test/examples/loader2-no-solution-with-2-projects/src/classlibf2/classlibf2.fsproj @@ -0,0 +1,12 @@ + + + + netstandard2.0 + true + + + + + + + diff --git a/test/examples/loader2-solution-with-2-projects/loader2-solution-with-2-projects.sln b/test/examples/loader2-solution-with-2-projects/loader2-solution-with-2-projects.sln new file mode 100644 index 00000000..d182f738 --- /dev/null +++ b/test/examples/loader2-solution-with-2-projects/loader2-solution-with-2-projects.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{36F9CEA7-6E82-4318-98EF-7505B2E1ECA4}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "classlibf1", "src\classlibf1\classlibf1.fsproj", "{A787595C-6E73-4E15-BEC5-C7366BED7777}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "classlibf2", "src\classlibf2\classlibf2.fsproj", "{B8C84575-E1A6-4D5B-9462-70F3064564DD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A787595C-6E73-4E15-BEC5-C7366BED7777}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A787595C-6E73-4E15-BEC5-C7366BED7777}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A787595C-6E73-4E15-BEC5-C7366BED7777}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A787595C-6E73-4E15-BEC5-C7366BED7777}.Release|Any CPU.Build.0 = Release|Any CPU + {B8C84575-E1A6-4D5B-9462-70F3064564DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8C84575-E1A6-4D5B-9462-70F3064564DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8C84575-E1A6-4D5B-9462-70F3064564DD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8C84575-E1A6-4D5B-9462-70F3064564DD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A787595C-6E73-4E15-BEC5-C7366BED7777} = {36F9CEA7-6E82-4318-98EF-7505B2E1ECA4} + {B8C84575-E1A6-4D5B-9462-70F3064564DD} = {36F9CEA7-6E82-4318-98EF-7505B2E1ECA4} + EndGlobalSection +EndGlobal diff --git a/test/examples/loader2-solution-with-2-projects/src/classlibf1/Library.fs b/test/examples/loader2-solution-with-2-projects/src/classlibf1/Library.fs new file mode 100644 index 00000000..7e962ecb --- /dev/null +++ b/test/examples/loader2-solution-with-2-projects/src/classlibf1/Library.fs @@ -0,0 +1,5 @@ +namespace classlibf1 + +module Say = + let hello name = + printfn "Hello %s" name diff --git a/test/examples/loader2-solution-with-2-projects/src/classlibf1/classlibf1.fsproj b/test/examples/loader2-solution-with-2-projects/src/classlibf1/classlibf1.fsproj new file mode 100644 index 00000000..2e19bb13 --- /dev/null +++ b/test/examples/loader2-solution-with-2-projects/src/classlibf1/classlibf1.fsproj @@ -0,0 +1,16 @@ + + + + net8.0;netstandard2.1 + true + + + + + + + + + + + diff --git a/test/examples/loader2-solution-with-2-projects/src/classlibf2/Library.fs b/test/examples/loader2-solution-with-2-projects/src/classlibf2/Library.fs new file mode 100644 index 00000000..203ad113 --- /dev/null +++ b/test/examples/loader2-solution-with-2-projects/src/classlibf2/Library.fs @@ -0,0 +1,5 @@ +namespace classlibf2 + +module Say = + let hello name = + printfn "Hello %s" name diff --git a/test/examples/loader2-solution-with-2-projects/src/classlibf2/classlibf2.fsproj b/test/examples/loader2-solution-with-2-projects/src/classlibf2/classlibf2.fsproj new file mode 100644 index 00000000..c8d2ac82 --- /dev/null +++ b/test/examples/loader2-solution-with-2-projects/src/classlibf2/classlibf2.fsproj @@ -0,0 +1,12 @@ + + + + netstandard2.0 + true + + + + + + +