diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebff189..af82b04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,25 +73,19 @@ jobs: git tag --list - uses: actions/setup-dotnet@v5 with: - dotnet-version: | - 10.0.x - - # Build the local Nullean.Make.Fs DLL (only needed while it isn't on NuGet yet; - # once published, replace with #r "nuget: Nullean.Make.Fs" in build.fsx) - - run: dotnet build src/Nullean.Make.Fs/Nullean.Make.Fs.fsproj -c Release - name: Build Nullean.Make.Fs (local reference bootstrap) + global-json-file: ./global.json - - run: ./build.fsx build -s + - run: dotnet run build.cs -- build -s name: Build - - run: ./build.fsx test -s + - run: dotnet run build.cs -- test -s name: Test - - run: ./build.fsx schema validate -s + - run: dotnet run build.cs -- schema validate -s name: Validate schema/argh-cli-schema.json is up to date - - run: ./build.fsx pkg generate -s + - run: dotnet run build.cs -- pkg generate -s name: Generate local nuget packages - - run: ./build.fsx pkg validate -s + - run: dotnet run build.cs -- pkg validate -s name: "Validate *.nupkg files that were created" - - run: ./build.fsx generate-api-changes -s + - run: dotnet run build.cs -- generate-api-changes -s name: "Inspect public API changes" - name: Publish to GitHub Package Repository @@ -100,10 +94,10 @@ jobs: run: | until dotnet nuget push 'build/output/*.nupkg' -s https://nuget.pkg.github.com/nullean/index.json -k ${{secrets.GITHUB_TOKEN}} --skip-duplicate --no-symbols; do echo "Retrying"; sleep 1; done; - - run: ./build.fsx generate-release-notes -s + - run: dotnet run build.cs -- generate-release-notes -s name: Generate release notes for tag if: github.event_name == 'push' && startswith(github.ref, 'refs/tags') - - run: ./build.fsx create-release-on-github -s --token ${{secrets.GITHUB_TOKEN}} + - run: dotnet run build.cs -- create-release-on-github -s --token ${{secrets.GITHUB_TOKEN}} if: github.event_name == 'push' && startswith(github.ref, 'refs/tags') name: Create or update release for tag on GitHub diff --git a/Argh.slnx b/Argh.slnx index 0bbf0c3..05b59cd 100644 --- a/Argh.slnx +++ b/Argh.slnx @@ -13,7 +13,6 @@ - @@ -22,7 +21,6 @@ - diff --git a/build.cs b/build.cs index 5cd342f..3abc25f 100755 --- a/build.cs +++ b/build.cs @@ -15,16 +15,6 @@ // ReSharper disable ArrangeTypeMemberModifiers // ReSharper disable ArrangeTypeModifiers -// ── constants ───────────────────────────────────────────────────────────────── - -Target Clean = _ => _ - .Description("Delete build output") - .Executes(() => - { - if (Output.Exists) Output.Delete(true); - Proc.Exec("dotnet", new[] { "clean" }); - }); - await MakeApp.Execute(args); // ── per-target DTOs ─────────────────────────────────────────────────────────── diff --git a/build.fsx b/build.fsx deleted file mode 100755 index cd58de4..0000000 --- a/build.fsx +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env -S dotnet fsi -- -// Local references — all transitive deps are co-located in the Nullean.Make.Fs output folder. -// Build first with: dotnet build -c Release -// Once Nullean.Make.Fs ships on NuGet, replace with: -// #r "nuget: Nullean.Make.Fs, " -#I ".artifacts/bin/Nullean.Make.Fs/release" -#r "Nullean.Make.Fs.dll" -#r "nuget: Proc.Fs, 0.14.0" - -// F# build pipeline for nullean/argh using Nullean.Make.Fs. -// -// Run via: dotnet fsi build.fsx -- -// -// The DUs encode structure statically: -// - Sub-DU payload on a case → namespace (Schema of SchemaTarget, Pkg of PkgTarget) -// - Record payload on a case → target with typed CLI args (Test of TestOptions) -// - No payload on a case → plain target or command - -open System -open System.IO -open Nullean.Make.Fs // MakeApp<'T>, Make module, FsContext, failBuild -open Proc.Fs - -// ── constants ───────────────────────────────────────────────────────────────── - -let [] Repository = "nullean/argh" -let [] MainTfm = "netstandard2.0" -let [] SignKey = "96c599bbe3e70f5d" -let [] IncludeGitHash = true - -let output () = DirectoryInfo(Path.Combine("build", "output")) - -let outputPath () = - Path.GetRelativePath(Directory.GetCurrentDirectory(), output().FullName) - -let schemaToolBin () = - let name = "Nullean.Argh.SchemaExport" - if Environment.OSVersion.Platform = PlatformID.Win32NT then - $".artifacts/bin/%s{name}/release/%s{name}.exe" - else - $".artifacts/bin/%s{name}/release/%s{name}" - -// ── version helpers (lazy, computed once) ───────────────────────────────────── - -let restoreTools = - lazy (exec { run "dotnet" ["tool"; "restore"] }) - -let currentVersion = - lazy ( - restoreTools.Value - let r = exec { binary "dotnet"; arguments ["minver"; "-p"; "canary.0"; "-m"; "0.1"]; output } - r.ConsoleOut |> Seq.find (fun l -> not (l.Line.StartsWith("MinVer:"))) |> fun l -> l.Line - ) - -let currentVersionInformational = - lazy ( - if IncludeGitHash then - let r = exec { binary "git"; arguments ["rev-parse"; "HEAD"]; output } - $"%s{currentVersion.Value}+%s{r.ConsoleOut |> Seq.head |> _.Line.Trim()}" - else - currentVersion.Value - ) - -let packageIdFromFile (path: string) = - Path.GetFileNameWithoutExtension(path).Replace("." + currentVersion.Value, "") - -// ── namespace sub-DUs ───────────────────────────────────────────────────────── - -type SchemaTarget = Update | Validate -type PkgTarget = Generate | Validate - -// ── target / command DU ─────────────────────────────────────────────────────── - -type Target = - // namespaces — payload being a union encodes the hierarchy - | Schema of SchemaTarget - | Pkg of PkgTarget - // atomic targets - | Clean - | Build - | PristineCheck - | Test of TestOptions - | GenerateReleaseNotes - | GenerateApiChanges - | CreateReleaseOnGithub - // commands - | Release - | Publish - -and TestOptions = { Filter: string option } - -let defaultTest = { Filter = None } - -// ── global options ──────────────────────────────────────────────────────────── - -let app = MakeApp(fsi.CommandLineArgs[0], Some "Build pipeline for nullean/argh") - -let cleanCheckout = app.Flag("--clean-checkout", short = "-c", desc = "Skip the clean-checkout guard") -let token = app.Option("--token", desc = "GitHub token for release/publish", defaultValue = None) - -// ── single exhaustive binding ───────────────────────────────────────────────── - -app.Bind <| function - - // ── schema namespace ─────────────────────────────────────────────────── - | Schema Update -> - Make.target [] "" <| fun _ -> - exec { run "dotnet" ["build"; "-c"; "Release"; "tools/Nullean.Argh.SchemaExport"] } - if not (Directory.Exists "schema") then Directory.CreateDirectory "schema" |> ignore - exec { run (schemaToolBin()) ["--out"; "schema/argh-cli-schema.json"] } - - | Schema SchemaTarget.Validate -> - Make.target [] "Fail if schema/argh-cli-schema.json is out of date" <| fun _ -> - exec { run "dotnet" ["build"; "-c"; "Release"; "tools/Nullean.Argh.SchemaExport"] } - let tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json") - try - exec { run (schemaToolBin()) ["--out"; tempPath] } - let generated = File.ReadAllText(tempPath).TrimEnd() - let existing = File.ReadAllText("schema/argh-cli-schema.json").TrimEnd() - if generated <> existing then - failBuild "schema/argh-cli-schema.json is out of date. Run: dotnet fsi build.fsx -- schema update" - finally - if File.Exists tempPath then File.Delete tempPath - - // ── pkg namespace ────────────────────────────────────────────────────── - | Pkg Generate -> - Make.target [] "" <| fun _ -> - let out = output () - if out.Exists then out.Delete(true) - exec { run "dotnet" ["pack"; "-c"; "Release"; "-o"; outputPath()] } - - | Pkg PkgTarget.Validate -> - Make.target [] "" <| fun _ -> - let baseArgs = [ "-v"; currentVersionInformational.Value; "-k"; SignKey; "-t"; outputPath() ] - output().GetFiles("*.nupkg") - |> Seq.sortByDescending _.CreationTimeUtc - |> Seq.map (fun f -> Path.GetRelativePath(Directory.GetCurrentDirectory(), f.FullName)) - |> Seq.filter (fun p -> packageIdFromFile p <> "Nullean.Argh") - |> Seq.iter (fun p -> exec { run "dotnet" (["nupkg-validator"; p] @ baseArgs) }) - - // ── clean ────────────────────────────────────────────────────────────── - | Clean -> - Make.target [] "clean ephemeral output files" <| fun _ -> - let out = output () - if out.Exists then out.Delete(true) - exec { run "dotnet" ["clean"] } - - // ── build ────────────────────────────────────────────────────────────── - | Build -> - Make.target [Clean] "build the solution" <| fun _ -> - exec { run "dotnet" ["build"; "-c"; "Release"] } - - // ── pristine-check ───────────────────────────────────────────────────── - | PristineCheck -> - Make.target [] "Verify no pending changes" <| fun ctx -> - if ctx.IsSet(cleanCheckout) then - printfn "Checkout is dirty but --clean-checkout was specified, skipping check" - else - let r = exec { binary "git"; arguments ["status"; "--porcelain"]; output } - if r.ConsoleOut |> Seq.isEmpty |> not then - failBuild "The checkout folder has pending changes, aborting" - printfn "The checkout folder does not have pending changes, proceeding" - - // ── test ─────────────────────────────────────────────────────────────── - | Test opts -> - Make.target [Build] "Run all tests" <| fun _ -> - exec { run "dotnet" - (["test"; "-c"; "RELEASE"; "--logger:GithubActions"; "--logger:pretty"] - @ (opts.Filter |> Option.map (sprintf "--filter:%s") |> Option.toList)) } - - // ── release notes ────────────────────────────────────────────────────── - | GenerateReleaseNotes -> - Make.target [] "" <| fun ctx -> - let ver = currentVersion.Value - let outputFile = Path.Combine(outputPath(), sprintf "release-notes-%s.md" ver) - let tokenArgs = ctx.Get(token) |> Option.map (fun t -> ["--token"; t]) |> Option.defaultValue [] - let repoArgs = Repository.Split('/') |> Array.toList - exec { run "dotnet" - (["release-notes"] @ repoArgs - @ ["--version"; ver - "--label"; "enhancement"; "New Features" - "--label"; "bug"; "Bug Fixes" - "--label"; "documentation";"Docs Improvements" - "--output"; outputFile] - @ tokenArgs) } - - // ── api changes ──────────────────────────────────────────────────────── - | GenerateApiChanges -> - Make.target [] "" <| fun _ -> - let ver = currentVersion.Value - let assembliesDir id = - match id with - | "Nullean.Argh.Hosting" | "Nullean.Argh.Interfaces" | "Nullean.Argh.Core" -> - $".artifacts/bin/%s{id}/release_%s{MainTfm}" - | _ -> $".artifacts/bin/%s{id}/release" - - output().GetFiles("*.nupkg") - |> Seq.sortByDescending _.CreationTimeUtc - |> Seq.map (fun f -> packageIdFromFile (Path.GetRelativePath(Directory.GetCurrentDirectory(), f.FullName))) - |> Seq.filter (fun p -> p <> "Nullean.Argh") - |> Seq.iter (fun pkg -> - exec { run "dotnet" - ["assembly-differ" - $"previous-nuget|%s{pkg}|%s{ver}|%s{MainTfm}" - $"directory|%s{assembliesDir pkg}" - "-a"; "true"; "--target"; pkg; "-f"; "github-comment" - "--output"; Path.Combine(outputPath(), $"breaking-changes-%s{pkg}.md")] }) - - // ── create GitHub release ────────────────────────────────────────────── - | CreateReleaseOnGithub -> - Make.target [] "" <| fun ctx -> - let ver = currentVersion.Value - let releaseNotes = Path.Combine(outputPath(), $"release-notes-%s{ver}.md") - let tokenArgs = ctx.Get(token) |> Option.map (fun t -> ["--token"; t]) |> Option.defaultValue [] - let bodyArgs = - output().GetFiles("breaking-changes-*.md") - |> Seq.collect (fun f -> ["--body"; Path.GetRelativePath(Directory.GetCurrentDirectory(), f.FullName)]) - |> Seq.toList - exec { run "dotnet" - (["release-notes"] @ (Repository.Split('/') |> Array.toList) - @ ["create-release"; "--version"; ver; "--body"; releaseNotes] - @ bodyArgs @ tokenArgs) } - - // ── commands ─────────────────────────────────────────────────────────── - | Release -> - Make.command - [ PristineCheck; Test defaultTest ] - [ Pkg Generate; Pkg Validate; GenerateReleaseNotes; GenerateApiChanges ] - - | Publish -> - Make.command - [ Release ] - [ CreateReleaseOnGithub ] - -let argv = fsi.CommandLineArgs |> Array.skip 1 -exit (app.RunAsync(argv).GetAwaiter().GetResult()) diff --git a/build/scripts/Targets.fs b/build/scripts/Targets.fs index 0d2eef8..194e773 100644 --- a/build/scripts/Targets.fs +++ b/build/scripts/Targets.fs @@ -73,7 +73,7 @@ let private generateApiChanges (arguments:ParseResults) = /// Unified artifacts layout (): `.artifacts/bin//release[_]/`. let assembliesDir (packageId: string) = match packageId with - | "Nullean.Argh.Hosting" | "Nullean.Argh.Interfaces" -> + | "Nullean.Argh.Hosting" | "Nullean.Argh.Interfaces" | "Nullean.Argh.Core" -> sprintf ".artifacts/bin/%s/release_%s" packageId Paths.MainTFM | _ -> sprintf ".artifacts/bin/%s/release" packageId let nugetPackages = diff --git a/build/scripts/scripts.fsproj b/build/scripts/scripts.fsproj index e463abb..740835e 100644 --- a/build/scripts/scripts.fsproj +++ b/build/scripts/scripts.fsproj @@ -1,7 +1,7 @@ Exe - net10.0 + net11.0 false false diff --git a/examples/ArghAotSmoketest/ArghAotSmoketest.csproj b/examples/ArghAotSmoketest/ArghAotSmoketest.csproj index 45df34a..d8abbd4 100644 --- a/examples/ArghAotSmoketest/ArghAotSmoketest.csproj +++ b/examples/ArghAotSmoketest/ArghAotSmoketest.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net11.0 enable enable ArghAotSmoketest diff --git a/examples/Basic/Basic.csproj b/examples/Basic/Basic.csproj index d1362db..6fbb2a1 100644 --- a/examples/Basic/Basic.csproj +++ b/examples/Basic/Basic.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net11.0 enable enable Basic diff --git a/examples/Hosted/Hosted.csproj b/examples/Hosted/Hosted.csproj index cae04fe..0ba2b57 100644 --- a/examples/Hosted/Hosted.csproj +++ b/examples/Hosted/Hosted.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net11.0 enable enable Hosted diff --git a/examples/HostedRoot/HostedRoot.csproj b/examples/HostedRoot/HostedRoot.csproj index 2a58f27..756677a 100644 --- a/examples/HostedRoot/HostedRoot.csproj +++ b/examples/HostedRoot/HostedRoot.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net11.0 enable enable HostedRoot diff --git a/examples/MakeBuild/MakeBuild.csproj b/examples/MakeBuild/MakeBuild.csproj index 335450c..32787c4 100644 --- a/examples/MakeBuild/MakeBuild.csproj +++ b/examples/MakeBuild/MakeBuild.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net11.0 enable enable false diff --git a/examples/XmlDocShowcase/XmlDocShowcase.csproj b/examples/XmlDocShowcase/XmlDocShowcase.csproj index 73c61f7..3825623 100644 --- a/examples/XmlDocShowcase/XmlDocShowcase.csproj +++ b/examples/XmlDocShowcase/XmlDocShowcase.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net11.0 enable enable XmlDocShowcase diff --git a/global.json b/global.json index d46d21e..8976695 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "10.0.100", + "version": "11.0.100-preview.7.26381.103", "rollForward": "latestFeature", - "allowPrerelease": false + "allowPrerelease": true } } diff --git a/src/Nullean.Argh.Core/Nullean.Argh.Core.csproj b/src/Nullean.Argh.Core/Nullean.Argh.Core.csproj index 25d89d5..333e0a9 100644 --- a/src/Nullean.Argh.Core/Nullean.Argh.Core.csproj +++ b/src/Nullean.Argh.Core/Nullean.Argh.Core.csproj @@ -3,7 +3,7 @@ Nullean.Argh.Core - netstandard2.0;net8.0;net9.0;net10.0 + netstandard2.0;net8.0;net9.0;net10.0;net11.0 Nullean.Argh true diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Runner.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Runner.cs index fbffb33..643fece 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Runner.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Runner.cs @@ -291,6 +291,12 @@ private static void EmitCommandRunner( continue; } + if (p.ScalarKind == CliScalarKind.Union && !p.UnionIsArgument) + { + EmitUnionFlagAssembly(sb, p, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + continue; + } + if (p.IsCollection && p.Kind == ParameterKind.Flag) { EmitBindCollectionParameter(sb, p, anyRepeatedCollection, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); @@ -326,6 +332,14 @@ private static void EmitCommandRunner( continue; } + // Union [Argument] mode: case name is the positional; case props come from flags + if (p.ScalarKind == CliScalarKind.Union && p.UnionIsArgument) + { + EmitUnionArgumentAssembly(sb, p, posIndex, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + posIndex++; + continue; + } + if (p.IsRequired) { sb.AppendLine($"\t\t\tif (positionals.Count <= {posIndex})"); @@ -509,6 +523,10 @@ private static void EmitCliValueDeclarations(StringBuilder sb, CommandModel cmd, if (p.Special == BoolSpecialKind.Bool || p.Special == BoolSpecialKind.NullableBool) continue; + // Union params are declared and assembled post-loop; skip here + if (p.ScalarKind == CliScalarKind.Union) + continue; + if (p.IsCollection && p.Kind == ParameterKind.Flag) { var elemFq = GetElementCSharpFq(p); @@ -736,7 +754,7 @@ private static void EmitBoolSwitchNames(StringBuilder sb, CommandModel cmd, bool var noNames = new List(); foreach (var p in cmd.Parameters) { - if (!IsEmittedFlagLike(p.Kind)) + if (!IsEmittedFlagLike(p.Kind) && p.Kind != ParameterKind.Positional) continue; if (p.Special == BoolSpecialKind.Bool) names.Add(p.CliLongName); @@ -745,6 +763,14 @@ private static void EmitBoolSwitchNames(StringBuilder sb, CommandModel cmd, bool names.Add(p.CliLongName); noNames.Add("no-" + p.CliLongName); } + // Union case bool props: flag mode → --{case}-{prop}; argument mode → --{prop} + if (p.ScalarKind == CliScalarKind.Union && !p.UnionCases.IsDefaultOrEmpty) + { + foreach (var c in p.UnionCases) + foreach (var prop in c.Properties) + if (prop.Special == BoolSpecialKind.Bool) + names.Add(p.UnionIsArgument ? prop.CliName : prop.FlagModeName); + } } if (names.Count == 0 && noNames.Count == 0) @@ -794,10 +820,31 @@ private static void EmitKnownNonBoolFlagNames(StringBuilder sb, CommandModel cmd var names = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var p in cmd.Parameters) { - if (!IsEmittedFlagLike(p.Kind)) + if (!IsEmittedFlagLike(p.Kind) && p.Kind != ParameterKind.Positional) continue; if (p.Special == BoolSpecialKind.Bool || p.Special == BoolSpecialKind.NullableBool) continue; + // Union flag mode: the selector flag + non-bool case props + if (p.ScalarKind == CliScalarKind.Union && !p.UnionIsArgument) + { + names.Add(p.CliLongName); // e.g. "format" + if (!p.UnionCases.IsDefaultOrEmpty) + foreach (var c in p.UnionCases) + foreach (var prop in c.Properties) + if (prop.Special != BoolSpecialKind.Bool) + names.Add(prop.FlagModeName); // e.g. "json-indent" + continue; + } + if (p.ScalarKind == CliScalarKind.Union && p.UnionIsArgument) + { + // Argument mode: case props are still flags + if (!p.UnionCases.IsDefaultOrEmpty) + foreach (var c in p.UnionCases) + foreach (var prop in c.Properties) + if (prop.Special != BoolSpecialKind.Bool) + names.Add(prop.CliName); // no prefix in argument mode + continue; + } names.Add(p.CliLongName); foreach (var al in p.Aliases) names.Add(al); @@ -1330,6 +1377,171 @@ private static void EmitPrimitiveScalarParseFromString(StringBuilder sb, Paramet } } + // ── Union flag-mode assembly ────────────────────────────────────────────── + + /// + /// Emits post-loop code that: + /// 1. Reads flags["format"] (or the param's CLI long name) to get the selected case name. + /// 2. Reads each case's property flags from flags. + /// 3. Switches on the case name and constructs the union value. + /// + /// Silently-unused case prop flags (e.g. --json-pretty when --format csv) are not errors. + /// + private static void EmitUnionFlagAssembly(StringBuilder sb, ParameterModel p, string failureExit, + string? helpMethodName, string? flagHelpStdErrMethodName, string? parseFailureRunHint) + { + if (p.UnionTypeFq is null || p.UnionCases.IsDefaultOrEmpty) return; + var flagKey = Escape(p.CliLongName); + var caseVar = "__union_case_" + p.LocalVarName; + + // Read the selector flag + sb.AppendLine($"\t\t\tflags.TryGetValue(\"{flagKey}\", out var {caseVar});"); + + // Emit per-case prop readers (always read all props regardless of which case is selected) + foreach (var c in p.UnionCases) + { + var safeCaseName = Naming.SanitizeIdentifier(c.CliName); + foreach (var prop in c.Properties) + { + var safePropName = Naming.SanitizeIdentifier(prop.CliName); + var propVar = "__up_" + p.LocalVarName + "_" + safeCaseName + "_" + safePropName; + if (prop.Special == BoolSpecialKind.Bool) + sb.AppendLine($"\t\t\tvar {propVar} = flags.ContainsKey(\"{Escape(prop.FlagModeName)}\");"); + else + { + sb.AppendLine($"\t\t\tflags.TryGetValue(\"{Escape(prop.FlagModeName)}\", out var {propVar}Raw);"); + var parsedVar = propVar + "_parsed"; + EmitUnionPropParse(sb, prop, propVar + "Raw", parsedVar, "\t\t\t", failureExit); + } + } + } + + // Switch on case name to assemble the union + sb.AppendLine($"\t\t\t{p.UnionTypeFq} {p.LocalVarName};"); + sb.AppendLine($"\t\t\tswitch (({caseVar} ?? \"\").ToLowerInvariant())"); + sb.AppendLine("\t\t\t{"); + foreach (var c in p.UnionCases) + { + var safeCaseName = Naming.SanitizeIdentifier(c.CliName); + sb.AppendLine($"\t\t\t\tcase \"{Escape(c.CliName)}\":"); + var propArgs = new System.Text.StringBuilder(); + foreach (var prop in c.Properties) + { + if (propArgs.Length > 0) propArgs.Append(", "); + var safePropName = Naming.SanitizeIdentifier(prop.CliName); + var propVar = "__up_" + p.LocalVarName + "_" + safeCaseName + "_" + safePropName; + var valueExpr = prop.Special == BoolSpecialKind.Bool ? propVar : propVar + "_parsed"; + propArgs.Append(valueExpr); + } + sb.AppendLine($"\t\t\t\t\t{p.LocalVarName} = new {p.UnionTypeFq}(new {c.TypeFq}({propArgs}));"); + sb.AppendLine("\t\t\t\t\tbreak;"); + } + sb.AppendLine("\t\t\t\tdefault:"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: invalid value for --{flagKey}: '{{{caseVar}}}'.\");"); + sb.AppendLine($"\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t\tbreak;"); + sb.AppendLine("\t\t\t}"); + } + + /// + /// Emits post-loop code for union [Argument] mode: + /// 1. Reads positionals[posIndex] as the case name. + /// 2. Reads each case's property flags from flags (no prefix — props use their bare CLI names). + /// 3. Switches on the case name to construct the union value. + /// + private static void EmitUnionArgumentAssembly(StringBuilder sb, ParameterModel p, int posIndex, + string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName, string? parseFailureRunHint) + { + if (p.UnionTypeFq is null || p.UnionCases.IsDefaultOrEmpty) return; + var caseVar = "__union_case_" + p.LocalVarName; + + // Read the case name from positionals + if (p.IsRequired) + { + sb.AppendLine($"\t\t\tif (positionals.Count <= {posIndex})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: missing required argument <{Escape(p.CliLongName)}>.\");"); + if (helpMethodName is not null) + sb.AppendLine($"\t\t\t\t{helpMethodName}();"); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine($"\t\t\tvar {caseVar} = positionals[{posIndex}];"); + } + else + { + sb.AppendLine($"\t\t\tvar {caseVar} = positionals.Count > {posIndex} ? positionals[{posIndex}] : null;"); + } + + // Read all case prop flags (bare name, no prefix) + foreach (var c in p.UnionCases) + { + var safeCaseName = Naming.SanitizeIdentifier(c.CliName); + foreach (var prop in c.Properties) + { + var safePropName = Naming.SanitizeIdentifier(prop.CliName); + var propVar = "__up_" + p.LocalVarName + "_" + safeCaseName + "_" + safePropName; + if (prop.Special == BoolSpecialKind.Bool) + sb.AppendLine($"\t\t\tvar {propVar} = flags.ContainsKey(\"{Escape(prop.CliName)}\");"); + else + { + sb.AppendLine($"\t\t\tflags.TryGetValue(\"{Escape(prop.CliName)}\", out var {propVar}Raw);"); + var parsedVar = propVar + "_parsed"; + EmitUnionPropParse(sb, prop, propVar + "Raw", parsedVar, "\t\t\t", failureExit); + } + } + } + + // Switch on case name + sb.AppendLine($"\t\t\t{p.UnionTypeFq} {p.LocalVarName};"); + sb.AppendLine($"\t\t\tswitch (({caseVar} ?? \"\").ToLowerInvariant())"); + sb.AppendLine("\t\t\t{"); + foreach (var c in p.UnionCases) + { + var safeCaseName = Naming.SanitizeIdentifier(c.CliName); + sb.AppendLine($"\t\t\t\tcase \"{Escape(c.CliName)}\":"); + var propArgs = new System.Text.StringBuilder(); + foreach (var prop in c.Properties) + { + if (propArgs.Length > 0) propArgs.Append(", "); + var safePropName = Naming.SanitizeIdentifier(prop.CliName); + var propVar = "__up_" + p.LocalVarName + "_" + safeCaseName + "_" + safePropName; + var valueExpr = prop.Special == BoolSpecialKind.Bool ? propVar : propVar + "_parsed"; + propArgs.Append(valueExpr); + } + sb.AppendLine($"\t\t\t\t\t{p.LocalVarName} = new {p.UnionTypeFq}(new {c.TypeFq}({propArgs}));"); + sb.AppendLine("\t\t\t\t\tbreak;"); + } + sb.AppendLine("\t\t\t\tdefault:"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: invalid value for <{Escape(p.CliLongName)}>: '{{{caseVar}}}'.\");"); + sb.AppendLine($"\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t\tbreak;"); + sb.AppendLine("\t\t\t}"); + } + + /// Emits parse code for a single union case property from a raw string variable into a parsed variable. + private static void EmitUnionPropParse(StringBuilder sb, UnionCasePropInfo prop, string rawVar, string parsedVar, string ind, string failureExit) + { + switch (prop.ScalarKind) + { + case CliScalarKind.Primitive when prop.TypeName == "int": + sb.AppendLine($"{ind}if (!int.TryParse({rawVar}, out var {parsedVar}))"); + sb.AppendLine($"{ind}\t{parsedVar} = {prop.DefaultValueLiteral ?? "default"};"); + break; + case CliScalarKind.Primitive when prop.TypeName == "long": + sb.AppendLine($"{ind}if (!long.TryParse({rawVar}, out var {parsedVar}))"); + sb.AppendLine($"{ind}\t{parsedVar} = {prop.DefaultValueLiteral ?? "default"};"); + break; + case CliScalarKind.Primitive when prop.TypeName == "double": + sb.AppendLine($"{ind}if (!double.TryParse({rawVar}, global::System.Globalization.NumberStyles.Any, global::System.Globalization.CultureInfo.InvariantCulture, out var {parsedVar}))"); + sb.AppendLine($"{ind}\t{parsedVar} = {prop.DefaultValueLiteral ?? "default"};"); + break; + default: + // String or unrecognized: just use the raw value, falling back to null/default + sb.AppendLine($"{ind}var {parsedVar} = {rawVar};"); + break; + } + } + private static void EmitNullableNumericParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) { diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Models.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Models.cs index cd2dfdb..3b83d00 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.Models.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Models.cs @@ -302,7 +302,8 @@ private enum CliScalarKind DirectoryInfo, Uri, CustomParser, - Collection + Collection, + Union } private enum BoolSpecialKind @@ -312,4 +313,27 @@ private enum BoolSpecialKind NullableBool } + // ── Union model records ───────────────────────────────────────────────── + /// A single case of a C# 15 union type (e.g. Json in union OutputFormat(Table, Json, Csv)). + private sealed record UnionCaseInfo( + /// Pascal case name of the record/class case type, e.g. "Json". + string Name, + /// Fully-qualified name for use in generated code, e.g. "global::My.Ns.Json". + string TypeFq, + /// Kebab-case CLI name, e.g. "json". + string CliName, + /// Properties of the case record that become namespaced flags (e.g. --json-pretty). + ImmutableArray Properties); + + /// A property of a union case record that becomes a CLI flag when in flag mode. + private sealed record UnionCasePropInfo( + string Name, + string CliName, + CliScalarKind ScalarKind, + BoolSpecialKind Special, + string TypeName, + string? DefaultValueLiteral, + /// Fully-qualified CLI flag name in flag mode, e.g. "json-pretty". + string FlagModeName); + } diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.ParameterModel.cs b/src/Nullean.Argh.Generator/CliParserGenerator.ParameterModel.cs index c830183..aa6591d 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.ParameterModel.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.ParameterModel.cs @@ -77,7 +77,21 @@ private sealed record ParameterModel( bool IsCommandOutput = false, ImmutableArray CommandOutputExplicitFormats = default, bool IsDeprecated = false, - string? DeprecationMessage = null) + string? DeprecationMessage = null, + // ── Union support ─────────────────────────────────────────────────────── + /// Fully-qualified name of the union type (e.g. My.Ns.OutputFormat). Null when not a union. + string? UnionTypeFq = null, + /// Ordered list of cases in the union (each case may have zero or more properties). + ImmutableArray UnionCases = default, + /// When true the union case is selected positionally (like [Argument]); when false (default) selected via --format <case>. + bool UnionIsArgument = false, + /// + /// True when this parameter is a complex class type (e.g. global-options DTO) that the generator + /// will later promote to . At the per-invocation + /// analysis step the kind is still ; this flag lets the argument- + /// order validator skip it so it does not trigger AGH0003. + /// + bool IsOptionsInjectionCandidate = false) { // ── shared helpers ────────────────────────────────────────────────────── @@ -252,6 +266,16 @@ public static ParameterModel From(IParameterSymbol p, DiagnosticAccumulator? rep var expandProf = TryReadExpandUserProfileBeforeBind(p, sk); var (isOutputP, outputFormatsP) = TryGetCommandOutputAttribute(p); var (isDeprecatedP, deprecationMsgP) = TryGetObsoleteAttribute(p); + // Union fields + var unionTypeFq = sk == CliScalarKind.Union && p.Type is INamedTypeSymbol unionT + ? unionT.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) : null; + var unionCases = sk == CliScalarKind.Union && p.Type is INamedTypeSymbol unionT2 + ? GetUnionCasesFromSymbol(unionT2) : default; + var unionIsArg = sk == CliScalarKind.Union && isArg; + // Complex class types (e.g. global-options DTOs) fall through to Primitive/string; mark them + // so the argument-order validator can skip them (they become OptionsInjected in the collect step). + var isOIC = sk == CliScalarKind.Primitive && bs == BoolSpecialKind.None + && p.Type is { TypeKind: TypeKind.Class, SpecialType: SpecialType.None }; return new ParameterModel( p.Name, SafeLocalName(p.Name), @@ -279,7 +303,11 @@ public static ParameterModel From(IParameterSymbol p, DiagnosticAccumulator? rep IsCommandOutput: isOutputP, CommandOutputExplicitFormats: outputFormatsP, IsDeprecated: isDeprecatedP, - DeprecationMessage: deprecationMsgP); + DeprecationMessage: deprecationMsgP, + UnionTypeFq: unionTypeFq, + UnionCases: unionCases, + UnionIsArgument: unionIsArg, + IsOptionsInjectionCandidate: isOIC); } public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilation? compilation = null, string? defaultValueLiteral = null) @@ -310,6 +338,10 @@ public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilati var validations = ReadValidationConstraints(prop, sk, typeName); var defLit = QualifyOptionsEnumDefaultLiteral(defaultValueLiteral, sk, enumFq, enumMembers); var expandProf = TryReadExpandUserProfileBeforeBind(prop, sk); + var unionTypeFqProp = sk == CliScalarKind.Union && prop.Type is INamedTypeSymbol unionTp + ? unionTp.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) : null; + var unionCasesProp = sk == CliScalarKind.Union && prop.Type is INamedTypeSymbol unionTp2 + ? GetUnionCasesFromSymbol(unionTp2) : default; return new ParameterModel( prop.Name, SafeLocalName(prop.Name), @@ -340,7 +372,9 @@ public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilati IsCommandOutput: TryGetCommandOutputAttribute(prop).IsOutput, CommandOutputExplicitFormats: TryGetCommandOutputAttribute(prop).ExplicitFormats, IsDeprecated: TryGetObsoleteAttribute(prop).IsDeprecated, - DeprecationMessage: TryGetObsoleteAttribute(prop).Message); + DeprecationMessage: TryGetObsoleteAttribute(prop).Message, + UnionTypeFq: unionTypeFqProp, + UnionCases: unionCasesProp); } public static ParameterModel FromOptionsField(IFieldSymbol field, Compilation? compilation = null, string? defaultValueLiteral = null) @@ -706,6 +740,13 @@ private static void ClassifyScalarForType( primitiveName = "Uri"; return; } + + if (IsUnionSymbol(named)) + { + kind = CliScalarKind.Union; + primitiveName = "union"; + return; + } } kind = CliScalarKind.Primitive; @@ -794,6 +835,13 @@ private static void ClassifyScalar( primitiveName = "Uri"; return; } + + if (IsUnionSymbol(named)) + { + kind = CliScalarKind.Union; + primitiveName = "union"; + return; + } } kind = CliScalarKind.Primitive; @@ -845,6 +893,87 @@ private static ImmutableArray TryGetEnumCliNames(ITypeSymbol type) return t is INamedTypeSymbol { TypeKind: TypeKind.Enum } en ? GetEnumMemberCliNames(en) : default; } + // ── Union helpers ──────────────────────────────────────────────────────── + + /// True when is a C# 15 union type (implements IUnion, has [Union] attribute, or matches structural pattern). + private static bool IsUnionSymbol(INamedTypeSymbol t) + { + // IUnion interface (runtime marker from lowered union keyword) + foreach (var iface in t.AllInterfaces) + { + if (iface.Name == "IUnion") return true; + } + // [Union] attribute + foreach (var attr in t.GetAttributes()) + { + var name = attr.AttributeClass?.Name; + if (name is "UnionAttribute" or "Union") return true; + } + // Structural: struct with object? Value property + ≥1 single-param public ctor + if (t.TypeKind == TypeKind.Struct) + { + var hasValueProp = false; + foreach (var m in t.GetMembers("Value")) + { + if (m is IPropertySymbol vp && vp.Type.SpecialType == SpecialType.System_Object) + { hasValueProp = true; break; } + } + if (hasValueProp) + { + foreach (var ctor in t.Constructors) + { + if (ctor.Parameters.Length == 1 && ctor.DeclaredAccessibility == Accessibility.Public) + return true; + } + } + } + return false; + } + + /// Builds the array for a union type symbol. + private static ImmutableArray GetUnionCasesFromSymbol(INamedTypeSymbol unionType) + { + var b = ImmutableArray.CreateBuilder(); + foreach (var ctor in unionType.Constructors) + { + if (ctor.Parameters.Length != 1 || ctor.DeclaredAccessibility != Accessibility.Public) continue; + if (ctor.Parameters[0].Type is not INamedTypeSymbol caseNamed) continue; + var caseName = caseNamed.Name; + var caseTypeFq = caseNamed.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var caseCli = Naming.ToCliLongName(caseName); + var props = GetUnionCasePropsFromSymbol(caseNamed, caseCli); + b.Add(new UnionCaseInfo(caseName, caseTypeFq, caseCli, props)); + } + return b.ToImmutable(); + } + + /// Returns all primary-ctor parameters of a union case record that become namespaced flags. + private static ImmutableArray GetUnionCasePropsFromSymbol(INamedTypeSymbol caseType, string caseCli) + { + var b = ImmutableArray.CreateBuilder(); + // Find the primary constructor (highest param count public ctor) + IMethodSymbol? primaryCtor = null; + foreach (var ctor in caseType.Constructors) + { + if (ctor.DeclaredAccessibility != Accessibility.Public) continue; + if (primaryCtor is null || ctor.Parameters.Length > primaryCtor.Parameters.Length) + primaryCtor = ctor; + } + if (primaryCtor is null) return b.ToImmutable(); + foreach (var param in primaryCtor.Parameters) + { + var bs = ClassifyBool(param.Type); + // Only support primitives and enums; skip nested unions / collections + if (param.Type is not INamedTypeSymbol) continue; + ClassifyScalar(param, bs, out var sk, out var typeName, out _, out _, out _, out _); + if (sk is CliScalarKind.Union or CliScalarKind.Collection or CliScalarKind.CustomParser) continue; + var defLit = TryGetDefaultLiteral(param, bs); + var flagName = $"{caseCli}-{Naming.ToCliLongName(param.Name)}"; + b.Add(new UnionCasePropInfo(param.Name, Naming.ToCliLongName(param.Name), sk, bs, typeName, defLit, flagName)); + } + return b.ToImmutable(); + } + private static ImmutableDictionary GetEnumMemberDocs(INamedTypeSymbol enumType) { var b = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Validation.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Validation.cs index 2954b38..a167245 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.Validation.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Validation.cs @@ -149,7 +149,13 @@ private static void ValidateExpandedParameterLayoutAcc(DiagnosticAccumulator acc foreach (var p in expanded) { if (p.Kind == ParameterKind.Injected) continue; - if (p.Kind == ParameterKind.Flag) { seenFlag = true; continue; } + if (p.Kind == ParameterKind.Flag) + { + // Complex class types (e.g. global-options objects) appear as Flag here but will be + // promoted to OptionsInjected in the collect step — don't treat them as CLI flags. + if (!p.IsOptionsInjectionCandidate) seenFlag = true; + continue; + } // A variadic positional is allowed after flags — C# requires params to be last. if (p.Kind == ParameterKind.Positional && seenFlag && !p.IsVariadic) { @@ -159,6 +165,7 @@ private static void ValidateExpandedParameterLayoutAcc(DiagnosticAccumulator acc } } + private static void ValidateVariadicPositionalIsLastAcc(DiagnosticAccumulator acc, Location location, ImmutableArray parameters) { var sawVariadic = false; diff --git a/src/Nullean.Argh.Hosting/Nullean.Argh.Hosting.csproj b/src/Nullean.Argh.Hosting/Nullean.Argh.Hosting.csproj index a11bc85..b2dbe73 100644 --- a/src/Nullean.Argh.Hosting/Nullean.Argh.Hosting.csproj +++ b/src/Nullean.Argh.Hosting/Nullean.Argh.Hosting.csproj @@ -3,7 +3,7 @@ Nullean.Argh.Hosting - netstandard2.0;net8.0;net9.0;net10.0 + netstandard2.0;net8.0;net9.0;net10.0;net11.0 Nullean.Argh.Hosting true diff --git a/src/Nullean.Argh.Interfaces/Nullean.Argh.Interfaces.csproj b/src/Nullean.Argh.Interfaces/Nullean.Argh.Interfaces.csproj index fa5625a..befcb49 100644 --- a/src/Nullean.Argh.Interfaces/Nullean.Argh.Interfaces.csproj +++ b/src/Nullean.Argh.Interfaces/Nullean.Argh.Interfaces.csproj @@ -2,7 +2,7 @@ Nullean.Argh.Interfaces - netstandard2.0;net8.0 + netstandard2.0;net8.0;net11.0 Nullean.Argh enable enable diff --git a/src/Nullean.Make.Fs/FsDtoBinder.fs b/src/Nullean.Make.Fs/FsDtoBinder.fs deleted file mode 100644 index 4b733fe..0000000 --- a/src/Nullean.Make.Fs/FsDtoBinder.fs +++ /dev/null @@ -1,112 +0,0 @@ -#nowarn "3261" // nullable reference type warnings -module internal Nullean.Make.Fs.FsDtoBinder - -open System -open Microsoft.FSharp.Reflection - -let private isOption (t: Type) = - t.IsGenericType && t.GetGenericTypeDefinition() = typedefof> - -let private noneValue (optType: Type) = - let noneCase = FSharpType.GetUnionCases(optType) |> Array.find (fun c -> c.Name = "None") - FSharpValue.MakeUnion(noneCase, [||]) - -let private someValue (optType: Type) (inner: obj) = - let someCase = FSharpType.GetUnionCases(optType) |> Array.find (fun c -> c.Name = "Some") - FSharpValue.MakeUnion(someCase, [| inner |]) - -let private toKebabCase (name: string) = - let sb = Text.StringBuilder() - for i in 0 .. name.Length - 1 do - let c = name.[i] - if Char.IsUpper(c) && i > 0 then sb.Append('-') |> ignore - sb.Append(Char.ToLowerInvariant(c)) |> ignore - sb.ToString() - -let private parseScalar (t: Type) (raw: string) (paramName: string) : obj = - let innerType = if isOption t then t.GetGenericArguments().[0] else t - let parsed = - try - if innerType = typeof then raw :> obj - elif innerType = typeof then int raw :> obj - elif innerType = typeof then int64 raw :> obj - elif innerType = typeof then float raw :> obj - elif innerType = typeof then Boolean.Parse(raw) :> obj - elif innerType = typeof then IO.FileInfo(raw) :> obj - elif innerType = typeof then IO.DirectoryInfo(raw) :> obj - elif innerType = typeof then Uri(raw) :> obj - elif innerType = typeof then TimeSpan.Parse(raw) :> obj - elif innerType.IsEnum then Enum.Parse(innerType, raw, true) - else failwith $"Unsupported type '{innerType.Name}' for '--%s{paramName}'" - with - | :? Nullean.Make.MakeException -> reraise () - | ex -> - raise (Nullean.Make.MakeException( - $"Cannot parse '{raw}' as {innerType.Name} for '--%s{paramName}': {ex.Message}", 2)) - if isOption t then someValue t parsed else parsed - -/// Bind args into a value of the given type. -/// Handles F# records (with option fields) and falls back to the C# DtoBinder for anything else. -let bind (dtoType: Type) (args: string[]) : obj = - if not (FSharpType.IsRecord(dtoType)) then - Nullean.Make.Parsing.DtoBinder.Bind(dtoType, args) - else - - let fields = FSharpType.GetRecordFields(dtoType) - - let isPositional (f: Reflection.PropertyInfo) = - f.GetCustomAttributes(typeof, false).Length > 0 - - let positionals = fields |> Array.filter isPositional - - let flagMap = - fields - |> Array.filter (isPositional >> not) - |> Array.map (fun f -> toKebabCase f.Name, f) - |> dict - - let values = - fields |> Array.map (fun f -> - let t = f.PropertyType - if isOption t then noneValue t - elif t = typeof then false :> obj - elif t.IsValueType then Activator.CreateInstance(t) - else null) - - let fieldIdx (f: Reflection.PropertyInfo) = - fields |> Array.findIndex (fun ff -> ff.Name = f.Name) - - let mutable argIdx = 0 - let mutable posIdx = 0 - - while argIdx < args.Length do - let arg = args.[argIdx] - if arg.StartsWith("--") || (arg.StartsWith("-") && arg.Length = 2) then - let flagName = arg.TrimStart('-') - let negated = flagName.StartsWith("no-") - let lookup = if negated then flagName.Substring(3) else flagName - - match flagMap.TryGetValue(lookup) with - | false, _ -> - raise (Nullean.Make.MakeException($"Unknown flag '{arg}'.", 2)) - | true, field -> - let idx = fieldIdx field - let ft = field.PropertyType - let isBool = ft = typeof || (isOption ft && ft.GetGenericArguments().[0] = typeof) - if isBool then - values.[idx] <- (not negated) :> obj - argIdx <- argIdx + 1 - else - argIdx <- argIdx + 1 - if argIdx >= args.Length then - raise (Nullean.Make.MakeException($"Flag '--{lookup}' requires a value.", 2)) - values.[idx] <- parseScalar ft args.[argIdx] field.Name - argIdx <- argIdx + 1 - else - if posIdx < positionals.Length then - let field = positionals.[posIdx] - values.[fieldIdx field] <- parseScalar field.PropertyType arg field.Name - posIdx <- posIdx + 1 - argIdx <- argIdx + 1 - - FSharpValue.MakeRecord(dtoType, values) diff --git a/src/Nullean.Make.Fs/FsGraphBuilder.fs b/src/Nullean.Make.Fs/FsGraphBuilder.fs deleted file mode 100644 index 87bf73b..0000000 --- a/src/Nullean.Make.Fs/FsGraphBuilder.fs +++ /dev/null @@ -1,182 +0,0 @@ -#nowarn "3261" // nullable reference type warnings — F# DU values are never null -module internal Nullean.Make.Fs.FsGraphBuilder - -open System -open Microsoft.FSharp.Reflection -open Nullean.Make -open Nullean.Make.Discovery - -let private toKebabCase = BuildScanner.ToKebabCase - -let private isOption (t: Type) = - t.IsGenericType && t.GetGenericTypeDefinition() = typedefof> - -let private noneValue (optType: Type) = - let noneCase = FSharpType.GetUnionCases(optType) |> Array.find (fun c -> c.Name = "None") - FSharpValue.MakeUnion(noneCase, [||]) - -let private someValue (optType: Type) (inner: obj) = - let someCase = FSharpType.GetUnionCases(optType) |> Array.find (fun c -> c.Name = "Some") - FSharpValue.MakeUnion(someCase, [| inner |]) - -let rec private makeDefault (t: Type) : obj = - if isOption t then noneValue t - elif t.IsValueType then Activator.CreateInstance(t) - elif FSharpType.IsRecord(t) then - let fields = FSharpType.GetRecordFields(t) - FSharpValue.MakeRecord(t, fields |> Array.map (fun f -> makeDefault f.PropertyType)) - else null - -let private defaultCaseValue (caseInfo: UnionCaseInfo) : obj = - let defaults = caseInfo.GetFields() |> Array.map (fun f -> makeDefault f.PropertyType) - FSharpValue.MakeUnion(caseInfo, defaults) - -// Shared stateless context instance — FsContext has no mutable state. -let private sharedCtx = FsContext() - -// A DU case is a structural namespace if its single field is itself a union type (not a record). -let private isStructuralNs (ci: UnionCaseInfo) = - let fs = ci.GetFields() - fs.Length = 1 && FSharpType.IsUnion(fs.[0].PropertyType) - -let buildGraph<'TCase when 'TCase : comparison and 'TCase : not null> - (appName: string) - (appDescription: string option) - (bind: 'TCase -> Definition<'TCase>) - (optionDecls: (string * string option * string option * bool) list) - : BuildGraph = - - let graph = BuildGraph() - graph.AppName <- appName - graph.AppDescription <- match appDescription with Some d -> d | None -> null - - // Register global options for help rendering (Property is null for F# option refs). - for (long, short, desc, isFlag) in optionDecls do - graph.GlobalOptions.Add( - GlobalOptionNode( - Long = long, - Short = (match short with Some s -> s | None -> null), - Description = (match desc with Some d -> d | None -> null), - IsFlag = isFlag)) - - let cases = FSharpType.GetUnionCases(typeof<'TCase>) - - // First pass: collect namespace markers — either structural (union-payload case) or explicit Make.ns. - let namespaces = - cases - |> Array.choose (fun ci -> - if isStructuralNs ci then - Some (ci.Name, toKebabCase ci.Name) - else - let v = defaultCaseValue ci :?> 'TCase - match bind v with - | FsNamespace(segment, _) -> Some (ci.Name, segment) - | _ -> None) - - // Derive CLI route for non-structural cases using namespace prefix matching (backward compat). - let deriveRoute (caseName: string) = - namespaces - |> Array.tryPick (fun (nsName, segment) -> - if caseName.StartsWith(nsName, StringComparison.Ordinal) && caseName.Length > nsName.Length then - Some [| segment; toKebabCase (caseName.Substring(nsName.Length)) |] - else None) - |> Option.defaultValue [| toKebabCase caseName |] - - // Map 'TCase value → TargetNode for dep resolution. - let caseToNode = Collections.Generic.Dictionary<'TCase, TargetNode>() - - // Register one TargetNode. caseInfoForBody drives the SyncBody closure shape. - // For structural namespace sub-cases, caseInfoForBody is the sub-DU's UnionCaseInfo. - // NOTE: namespace sub-cases must be parameterless; payload sub-DUs are not supported. - let registerNode (route: string[]) (def: Definition<'TCase>) (caseInfoForBody: UnionCaseInfo) (key: 'TCase) = - let kind = match def with | FsCommand _ -> TargetKind.Command | _ -> TargetKind.Target - let rawDesc = match def with | FsTarget(d,_,_) | FsCommand(d,_,_,_) -> d | _ -> "" - // Commands: leave description empty so MakeHelpPrinter can auto-generate it from the graph. - // Targets: fall back to the kebab-case name when no description is provided. - let desc = - if not (String.IsNullOrEmpty(rawDesc)) then rawDesc - elif kind = TargetKind.Command then "" - else toKebabCase (Array.last route) - let fields = caseInfoForBody.GetFields() - - let plainBody () = - match def with - | FsTarget(_, _, b) -> b sharedCtx - | FsCommand(_, _, _, Some b) -> b sharedCtx - | _ -> () - - // Re-binds CLI args at execution time and re-invokes bind for typed-payload cases. - let payloadBody () = - let payloadType = fields.[0].PropertyType - let targetArgs = - let ctx = MakeContext.Current - if obj.ReferenceEquals(ctx, null) then [||] else ctx.TargetArgs - let payload = FsDtoBinder.bind payloadType targetArgs - let boundCase : 'TCase = FSharpValue.MakeUnion(caseInfoForBody, [| payload |]) :?> 'TCase - match bind boundCase with - | FsTarget(_, _, b) -> b sharedCtx - | FsCommand(_, _, _, Some b) -> b sharedCtx - | _ -> () - - let syncBody = Action(if fields.Length = 0 then plainBody else payloadBody) - let dtoType = if fields.Length = 1 && FSharpType.IsRecord(fields.[0].PropertyType) - then fields.[0].PropertyType else null - let node = - TargetNode( - Route = route, - ConfigureMethod = null, - Kind = kind, - Description = desc, - DtoType = dtoType, - SyncBody = syncBody) - - graph.Targets.Add(node) - graph.ByRoute.[String.concat "/" route] <- node - caseToNode.[key] <- node - - // Second pass: create TargetNode for each non-namespace case. - for caseInfo in cases do - if isStructuralNs caseInfo then - // Structural namespace: register each sub-case with route [segment, sub-name]. - let subDuType = caseInfo.GetFields().[0].PropertyType - let segment = - namespaces |> Array.tryPick (fun (n, s) -> if n = caseInfo.Name then Some s else None) - |> Option.defaultValue (toKebabCase caseInfo.Name) - for subCaseInfo in FSharpType.GetUnionCases(subDuType) do - let subDefault = defaultCaseValue subCaseInfo - let fullVal : 'TCase = FSharpValue.MakeUnion(caseInfo, [| subDefault |]) :?> 'TCase - let def = bind fullVal - match def with - | FsNamespace _ -> () - | _ -> - let route = [| segment; toKebabCase subCaseInfo.Name |] - registerNode route def subCaseInfo fullVal - else - let defaultVal : 'TCase = defaultCaseValue caseInfo :?> 'TCase - let def = bind defaultVal - match def with - | FsNamespace _ -> () - | _ -> - let route = deriveRoute caseInfo.Name - registerNode route def caseInfo defaultVal - - // Third pass: resolve deps/requires/composes by iterating over all registered nodes. - for KeyValue(tcase, node) in caseToNode do - match bind tcase with - | FsNamespace _ -> () - | FsTarget(_, deps, _) -> - for dep in deps do - match caseToNode.TryGetValue(dep) with - | true, depNode -> node.RequiresResolved.Add(depNode) - | _ -> () - | FsCommand(_, requires, composes, _) -> - for dep in requires do - match caseToNode.TryGetValue(dep) with - | true, depNode -> node.RequiresResolved.Add(depNode) - | _ -> () - for dep in composes do - match caseToNode.TryGetValue(dep) with - | true, depNode -> node.ComposesResolved.Add(depNode) - | _ -> () - - graph diff --git a/src/Nullean.Make.Fs/MakeApp.fs b/src/Nullean.Make.Fs/MakeApp.fs deleted file mode 100644 index e9f460e..0000000 --- a/src/Nullean.Make.Fs/MakeApp.fs +++ /dev/null @@ -1,178 +0,0 @@ -#nowarn "3261" // nullable reference type warnings -namespace Nullean.Make.Fs - -open System -open System.Threading.Tasks -open Nullean.Make -open Nullean.Make.Discovery -open Nullean.Make.Execution -open Nullean.Make.Help -open Nullean.Make.Parsing - -[] -module private MakeAppHelpers = - - let parseAs (t: Type) (raw: string) : obj = - if t = typeof then raw :> obj - elif t = typeof then int raw :> obj - elif t = typeof then int64 raw :> obj - elif t = typeof then float raw :> obj - elif t = typeof then String.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) :> obj - elif t = typeof then (Some raw : string option) :> obj - elif t = typeof then (Some (int raw) : int option) :> obj - elif t = typeof then - (Some (String.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) : bool option) :> obj - else raw :> obj - - type FsOptionDecl = - { Long: string; Short: string option; Description: string option; IsFlag: bool; Set: string -> unit } - - let inline tryExec (f: unit -> 'a) = - try Ok (f ()) - with :? MakeException as ex -> Error (ex.ExitCode, ex.Message) - -/// Entry point for F# build scripts using a DU as the target identity. -type MakeApp<'TCase when 'TCase : comparison and 'TCase : not null> - (appName: string, description: string option) = - - let _options = Collections.Generic.List() - let mutable _bindFn : ('TCase -> Definition<'TCase>) option = None - - let extractGlobals (argv: string[]) = - let remaining = Collections.Generic.List() - let mutable singleTarget = false - let mutable showHelp = false - let mutable showVersion = false - let mutable i = 0 - while i < argv.Length do - let arg = argv.[i] - if arg = "-h" || arg = "--help" then showHelp <- true; i <- i + 1 - elif arg = "--version" then showVersion <- true; i <- i + 1 - elif arg = "-s" || arg = "--single-target" then singleTarget <- true; i <- i + 1 - else - let norm = arg.TrimStart('-') - let matched = - _options |> Seq.tryFind (fun o -> - let longNorm = o.Long.TrimStart('-') - let shortNorm = o.Short |> Option.map (fun s -> s.TrimStart('-')) - norm = longNorm || (shortNorm |> Option.exists (fun s -> norm = s))) - match matched with - | Some opt -> - if opt.IsFlag then opt.Set "true"; i <- i + 1 - else - i <- i + 1 - if i < argv.Length then opt.Set argv.[i]; i <- i + 1 - | None -> remaining.Add(arg); i <- i + 1 - remaining.ToArray(), singleTarget, showHelp, showVersion - - let resolveRoute (remaining: string[]) (byRoute: Collections.Generic.Dictionary) = - let routeTokens = Collections.Generic.List() - let rest = Collections.Generic.List() - let mutable routeDone = false - for token in remaining do - if not routeDone && not (token.StartsWith("-")) then - let candidate = String.concat "/" [| yield! routeTokens; token.ToLowerInvariant() |] - if byRoute.ContainsKey(candidate) then - routeTokens.Add(token.ToLowerInvariant()) - else - let isPrefix = - byRoute.Keys - |> Seq.exists (fun k -> k.StartsWith(candidate + "/", StringComparison.OrdinalIgnoreCase)) - if isPrefix then routeTokens.Add(token.ToLowerInvariant()) - else routeDone <- true; rest.Add(token) - else rest.Add(token) - String.concat "/" routeTokens, rest.ToArray() - - /// Register a boolean flag. Returns a mutable ref; read .Value or use ctx.IsSet() in target bodies. - member _.Flag(long: string, ?short: string, ?desc: string) : OptionRef = - let r = OptionRef(long, short, desc, false, fun s -> - String.Equals(s, "true", StringComparison.OrdinalIgnoreCase) || s = "1") - _options.Add({ Long = long; Short = short; Description = desc; IsFlag = true; Set = r.Set }) - r - - /// Register a global option of type 'T. Returns a mutable ref; read .Value or use ctx.Get() in target bodies. - member _.Option<'T>(long: string, ?short: string, ?desc: string, ?defaultValue: 'T) : OptionRef<'T> = - let dv = defaultValue |> Option.defaultValue Unchecked.defaultof<'T> - let t = typeof<'T> - let parser (raw: string) : 'T = parseAs t raw :?> 'T - let r = OptionRef<'T>(long, short, desc, dv, parser) - _options.Add({ Long = long; Short = short; Description = desc; IsFlag = false; Set = r.Set }) - r - - /// Provide the single exhaustive binding function: one match arm per DU case. - member _.Bind(fn: 'TCase -> Definition<'TCase>) = - _bindFn <- Some fn - - /// Parse argv, build the execution plan, and run it. Returns an exit code. - member _.RunAsync(argv: string[]) : Task = - match _bindFn with - | None -> - eprintfn "[make] app.Bind(...) was not called" - Task.FromResult(1) - | Some bind -> - - let optDecls = - _options - |> Seq.map (fun o -> o.Long, o.Short, o.Description, o.IsFlag) - |> Seq.toList - - task { - match tryExec (fun () -> FsGraphBuilder.buildGraph<'TCase> appName description bind optDecls) with - | Error (code, msg) -> - eprintfn "%s" msg - return code - | Ok graph -> - - match tryExec (fun () -> GraphValidator.Validate(graph)) with - | Error (code, msg) -> - eprintfn "%s" msg - return code - | Ok () -> - - if argv.Length = 0 then - MakeHelpPrinter.PrintRoot(graph, appName) - return 0 - else - - let remaining, singleTarget, showHelp, showVersion = extractGlobals argv - - if showVersion then - printfn "0.0.0" - return 0 - else - - let routeKey, targetArgs = resolveRoute remaining graph.ByRoute - - if showHelp then - match graph.ByRoute.TryGetValue(routeKey) with - | true, node when node.Kind = TargetKind.Command -> - MakeHelpPrinter.PrintCommand(node, graph, appName) - | true, node -> - MakeHelpPrinter.PrintTarget(node, graph, appName) - | _ -> - MakeHelpPrinter.PrintRoot(graph, appName) - return 0 - else - - if String.IsNullOrEmpty(routeKey) then - match remaining |> Array.tryFind (fun t -> not (t.StartsWith("-"))) with - | Some t -> - eprintfn "Unknown target '%s'." t - return 2 - | None -> - MakeHelpPrinter.PrintRoot(graph, appName) - return 0 - else - - match graph.ByRoute.TryGetValue(routeKey) with - | false, _ -> - eprintfn "Unknown target '%s'." routeKey - return 2 - | true, node -> - let parsed = - ParsedArgs( - Target = node, - TargetArgs = targetArgs, - SingleTarget = singleTarget) - return! DepGraphExecutor.ExecuteAsync(node, parsed, graph) - } diff --git a/src/Nullean.Make.Fs/Nullean.Make.Fs.fsproj b/src/Nullean.Make.Fs/Nullean.Make.Fs.fsproj deleted file mode 100644 index 14f63bb..0000000 --- a/src/Nullean.Make.Fs/Nullean.Make.Fs.fsproj +++ /dev/null @@ -1,39 +0,0 @@ - - - - Nullean.Make.Fs - net8.0 - Nullean.Make.Fs - enable - preview - - nuget-icon.png - MIT - https://github.com/nullean/argh - https://github.com/nullean/argh - https://github.com/nullean/argh/releases - - Nullean.Make.Fs - Nullean.Make.Fs - F# DU-based build target DSL - F# DU/match binding over Nullean.Make — exhaustive, typed, namespaced build targets. - README.md - - false - - - - - - - - - - - - - - - - - - diff --git a/src/Nullean.Make.Fs/README.md b/src/Nullean.Make.Fs/README.md deleted file mode 100644 index af15885..0000000 --- a/src/Nullean.Make.Fs/README.md +++ /dev/null @@ -1,432 +0,0 @@ -# Nullean.Make.Fs - -Typed, namespaced, exhaustive F# build target DSL. Your targets are DU cases. -Adding a case without handling it is a **compile error**. - -```fsharp -#!/usr/bin/env -S dotnet fsi -- -#r "nuget: Nullean.Make.Fs" -#r "nuget: Proc.Fs" - -open Nullean.Make.Fs -open Proc.Fs - -type Target = Clean | Build | Test of TestOptions | Release -and TestOptions = { Filter: string option } - -let app = MakeApp("my-app", Some "My build pipeline") - -app.Bind <| function - | Clean -> Make.target [] "" <| fun _ -> exec { run "dotnet" ["clean"] } - | Build -> Make.target [Clean] "" <| fun _ -> exec { run "dotnet" ["build"; "-c"; "Release"] } - | Test opts -> Make.target [Build] "Run tests" <| fun _ -> - exec { run "dotnet" (["test"] @ (opts.Filter |> Option.map (sprintf "--filter:%s") |> Option.toList)) } - | Release -> Make.command [Test { Filter = None }] [] - -exit (app.RunAsync(fsi.CommandLineArgs.[1..]).GetAwaiter().GetResult()) -``` - -``` -./build.fsx --help -./build.fsx test -./build.fsx test --filter MyClass -./build.fsx release -./build.fsx release -s # skip prerequisite tests, run only composes -``` - ---- - -## Core concepts - -### Targets vs Commands - -| | Declared with | Has a body | Has deps | Skippable under `-s` | -|---|---|---|---|---| -| **Target** | `Make.target` | Yes | Yes (`DependsOn`) | Deps skipped | -| **Command** | `Make.command` | No (pure composer) | `requires` + `composes` | `requires` skipped | - -**Targets** are atomic steps — they do the work. **Commands** are pipeline entry points — they sequence targets. Run either directly from the command line; commands are what you'd normally call in CI. - -### The exhaustive match guarantee - -`app.Bind` takes `'TCase -> Definition<'TCase>`. The F# compiler enforces exhaustiveness: - -```fsharp -type Target = Clean | Build | Test | Release - -app.Bind <| function - | Clean -> Make.target [] "" <| fun _ -> ... - | Build -> Make.target [Clean] "" <| fun _ -> ... - | Test -> Make.target [Build] "" <| fun _ -> ... - // ⚠ warning FS0025: incomplete pattern matches — Release not handled -``` - -Add a case to the DU, handle it in `Bind`, ship. The framework refuses to run with an incomplete graph. - -### Namespaces via nested DUs - -Nest a plain DU inside another case to create CLI namespace groups. The payload type being a union **is** the namespace declaration — no `Make.ns` call needed: - -```fsharp -type SchemaTarget = Update | Validate // sub-DU → becomes CLI namespace -type PkgTarget = Generate | Validate - -type Target = - | Schema of SchemaTarget // routes: schema update, schema validate - | Pkg of PkgTarget // routes: pkg generate, pkg validate - | Clean - | Build - | Release -``` - -Adding `SchemaTarget.Lint` is a compile error until `| Schema Lint ->` is handled. - -When two sub-DUs share a case name (both have `Validate`), qualify the ambiguous one: - -```fsharp -app.Bind <| function - | Schema Update -> Make.target [] "" <| fun _ -> ... - | Schema SchemaTarget.Validate -> Make.target [] "" <| fun _ -> ... - | Pkg Generate -> Make.target [] "" <| fun _ -> ... - | Pkg PkgTarget.Validate -> Make.target [] "" <| fun _ -> ... -``` - ---- - -## API reference - -### `MakeApp<'TCase>` - -```fsharp -let app = MakeApp("app-name", Some "Optional description shown in --help") -``` - -#### Global options - -Returned handles are mutable refs populated during arg parsing. Close over them in target bodies. - -```fsharp -let verbose = app.Flag("--verbose", short = "-v", desc = "Enable verbose output") -let token = app.Option("--token", desc = "API token", defaultValue = None) -``` - -Read in target bodies via `ctx.IsSet(verbose)` / `ctx.Get(token)`, or directly via `.Value`. - -#### `app.Bind` - -```fsharp -app.Bind <| function - | MyTarget -> Make.target [...] "description" <| fun ctx -> ... - | MyCmd -> Make.command [...] [...] -``` - -#### `app.RunAsync` - -```fsharp -exit (app.RunAsync(fsi.CommandLineArgs.[1..]).GetAwaiter().GetResult()) -``` - ---- - -### `Make.target` - -```fsharp -Make.target (deps: 'T list) (desc: string) (body: FsContext -> unit) : Definition<'T> -``` - -- `deps` — targets that must complete first (skipped under `-s`) -- `desc` — shown in `--help`; pass `""` to derive from the case name -- `body` — the work; use `ctx.IsSet` / `ctx.Get` to read global options - -```fsharp -| Build -> - Make.target [Clean] "dotnet build -c Release" <| fun _ -> - exec { run "dotnet" ["build"; "-c"; "Release"] } - -| PristineCheck -> - Make.target [] "Verify no pending changes" <| fun ctx -> - if ctx.IsSet(skipCheck) then () - else - let r = exec { binary "git"; arguments ["status"; "--porcelain"]; output } - if r.ConsoleOut |> Seq.isEmpty |> not then - failBuild "Checkout has pending changes" -``` - -### `Make.command` - -```fsharp -Make.command (requires: 'T list) (composes: 'T list) : Definition<'T> -``` - -- `requires` — gate steps, skipped under `-s` (verification, tests) -- `composes` — the actual work, always runs - -The `--help` for a command auto-generates its description from the graph: - -``` -release — pristine-check, test → pkg generate, pkg validate, release-notes -``` - -```fsharp -| Release -> - Make.command - [ PristineCheck; Test defaultTest ] // requires (gates) - [ PkgGenerate; PkgValidate; ReleaseNotes ] // composes (work) -``` - -### `Make.composer` - -Like `Make.command` but with an optional trailing body that runs after all `composes`: - -```fsharp -| Publish -> - Make.composer - [ Release ] - [ CreateGithubRelease ] - (fun ctx -> - printfn "Published at %s" (DateTime.UtcNow.ToString("o"))) -``` - -### `failBuild` - -```fsharp -failBuild (message: string) : 'a -``` - -Aborts the run with exit code 1. Prefer over raising `MakeException` directly. - ---- - -## Per-target typed arguments - -A DU case with a **record payload** binds CLI flags to that record at execution time. The record fields become `--flag` options; `string option` fields are optional. - -```fsharp -type TestOptions = { Filter: string option; NoBuild: bool } -let defaultTest = { Filter = None; NoBuild = false } - -type Target = - | Test of TestOptions - | ... - -app.Bind <| function - | Test opts -> - Make.target [Build] "Run all tests" <| fun _ -> - exec { run "dotnet" - (["test"] - @ (if opts.NoBuild then ["--no-build"] else []) - @ (opts.Filter |> Option.map (sprintf "--filter:%s") |> Option.toList)) } -``` - -``` -./build.fsx test --help - - test — Run all tests - - Usage: - my-app test [options] - - Options: - --filter - --no-build - - Depends on: - build -``` - -``` -./build.fsx test -./build.fsx test --filter "MyNamespace.MyClass" -./build.fsx test --no-build --filter "MyClass" -``` - ---- - -## The `-s` / `--single-target` flag - -Built-in. Skips `DependsOn` deps on targets and `Requires` gates on commands: - -``` -./build.fsx release -s # skip pristine-check + test, run only pkg generate, pkg validate, ... -./build.fsx build -s # skip clean, run build body only -``` - ---- - -## Global options via `FsContext` - -Target bodies receive an `FsContext` (or `_` if unused): - -```fsharp -type FsContext with - member _.Get(optRef: OptionRef<'T>) : 'T // returns current value - member _.IsSet(optRef: OptionRef): bool // true if flag was passed -``` - ---- - -## Worked example — full pipeline - -```fsharp -#!/usr/bin/env -S dotnet fsi -- -#r "nuget: Nullean.Make.Fs" -#r "nuget: Proc.Fs" - -open System -open System.IO -open Nullean.Make.Fs -open Proc.Fs - -// ── namespace sub-DUs ───────────────────────────────────────────────────────── - -type SchemaTarget = Update | Validate -type PkgTarget = Generate | Validate - -// ── target / command DU ─────────────────────────────────────────────────────── - -type Target = - | Schema of SchemaTarget // routes: schema update, schema validate - | Pkg of PkgTarget // routes: pkg generate, pkg validate - | Clean - | Build - | PristineCheck - | Test of TestOptions - | GenerateReleaseNotes - | Release - | Publish - -and TestOptions = { Filter: string option } - -let defaultTest = { Filter = None } - -// ── app + global options ────────────────────────────────────────────────────── - -let app = MakeApp(fsi.CommandLineArgs.[0], Some "My build pipeline") -let skipCheck = app.Flag("--clean-checkout", short = "-c", desc = "Skip pristine-checkout guard") -let token = app.Option("--token", desc = "GitHub token", defaultValue = None) - -// ── binding ─────────────────────────────────────────────────────────────────── - -app.Bind <| function - - | Schema Update -> - Make.target [] "" <| fun _ -> - exec { run "my-schema-tool" ["--out"; "schema/my-schema.json"] } - - | Schema SchemaTarget.Validate -> - Make.target [] "Fail if schema is out of date" <| fun _ -> - // diff current schema against committed file … - failBuild "schema is out of date — run: ./build.fsx schema update" - - | Pkg Generate -> - Make.target [] "" <| fun _ -> - exec { run "dotnet" ["pack"; "-c"; "Release"; "-o"; "build/output"] } - - | Pkg PkgTarget.Validate -> - Make.target [] "" <| fun _ -> - exec { run "dotnet" ["nupkg-validator"; "build/output/*.nupkg"] } - - | Clean -> - Make.target [] "" <| fun _ -> - exec { run "dotnet" ["clean"] } - - | Build -> - Make.target [Clean] "" <| fun _ -> - exec { run "dotnet" ["build"; "-c"; "Release"] } - - | PristineCheck -> - Make.target [] "Verify no pending changes" <| fun ctx -> - if ctx.IsSet(skipCheck) then () - else - let r = exec { binary "git"; arguments ["status"; "--porcelain"]; output } - if r.ConsoleOut |> Seq.isEmpty |> not then - failBuild "Checkout has pending changes" - - | Test opts -> - Make.target [Build] "Run all tests" <| fun _ -> - exec { run "dotnet" - (["test"; "-c"; "Release"] - @ (opts.Filter |> Option.map (sprintf "--filter:%s") |> Option.toList)) } - - | GenerateReleaseNotes -> - Make.target [] "" <| fun ctx -> - let tokenArgs = ctx.Get(token) |> Option.map (fun t -> ["--token"; t]) |> Option.defaultValue [] - exec { run "dotnet" (["release-notes"; "my-org"; "my-repo"] @ tokenArgs) } - - | Release -> - Make.command - [ PristineCheck; Test defaultTest ] - [ Pkg Generate; Pkg PkgTarget.Validate; GenerateReleaseNotes ] - - | Publish -> - Make.command [ Release ] [] - -exit (app.RunAsync(fsi.CommandLineArgs.[1..]).GetAwaiter().GetResult()) -``` - -### Help output - -``` -./build.fsx --help - -my-app - My build pipeline - - Usage: - my-app [options] - my-app [options] - - Commands: (pipeline entry points — compose and sequence targets) - release pristine-check, test → pkg generate, pkg validate, generate-release-notes - publish release - - Targets: (atomic steps — can also be run directly) - clean clean - build build (depends on: clean) - pristine-check Verify no pending changes - test Run all tests (depends on: build) - generate-release-notes generate-release-notes - - Namespaces: - pkg - schema - - Global options: - -s, --single-target Skip prerequisite deps; run only the body / Composes - -h, --help Show this help - -c, --clean-checkout Skip pristine-checkout guard - --token GitHub token -``` - ---- - -## Process execution with Proc.Fs - -`Nullean.Make.Fs` has no process abstraction of its own. The examples use [`Proc.Fs`](https://github.com/nullean/proc) which provides an idiomatic F# CE: - -```fsharp -#r "nuget: Proc.Fs" -open Proc.Fs - -// Fire and forget -exec { run "dotnet" ["build"; "-c"; "Release"] } - -// Capture output -let r = exec { binary "dotnet"; arguments ["minver"]; output } -let version = r.ConsoleOut |> Seq.head |> fun l -> l.Line - -// Dynamic argument list -exec { run "dotnet" (["test"] @ filterArgs @ loggerArgs) } -``` - -Any other process library works equally well — `Proc.Fs` is not a requirement. - ---- - -## Installation - -``` -#r "nuget: Nullean.Make.Fs" -``` - -Requires .NET 8+. The package pulls in `Nullean.Make` (the underlying C# engine) transitively. diff --git a/src/Nullean.Make.Fs/Types.fs b/src/Nullean.Make.Fs/Types.fs deleted file mode 100644 index 09f9f8b..0000000 --- a/src/Nullean.Make.Fs/Types.fs +++ /dev/null @@ -1,58 +0,0 @@ -namespace Nullean.Make.Fs - -open System - -[] -module MakeScriptHelpers = - /// Abort the build with a message and exit code 1. - /// Prefer this over raising MakeException directly — keeps Nullean.Make out of script references. - let failBuild (message: string) : 'a = - raise (Nullean.Make.MakeException(message)) - -/// Mutable handle to a global option. Populated during argv parsing; read by target bodies via FsContext. -type OptionRef<'T>(long: string, short: string option, desc: string option, defaultValue: 'T, parser: string -> 'T) = - let mutable _value = defaultValue - member _.Long = long - member _.Short = short - member _.Description = desc - member _.DefaultValue = defaultValue - /// Current parsed value — valid after MakeApp.RunAsync starts argv extraction. - member _.Value = _value - member internal _.Set(raw: string) = _value <- parser raw - member internal _.Reset() = _value <- defaultValue - -/// Passed to target bodies. Provides typed reads of global option values. -type FsContext internal () = - /// Returns the current value of a global option. - member _.Get(optRef: OptionRef<'T>) : 'T = optRef.Value - /// Returns true if the flag was passed on the command line. - member _.IsSet(optRef: OptionRef) : bool = optRef.Value - -/// Returned by app.Bind for each DU case. -[] -type Definition<'TCase> = - internal - | FsTarget of desc: string * deps: 'TCase list * body: (FsContext -> unit) - | FsCommand of desc: string * requires: 'TCase list * composes: 'TCase list * body: (FsContext -> unit) option - | FsNamespace of segment: string * desc: string option - -/// Helpers for building Definition values inside app.Bind. -module Make = - - /// Defines an atomic target. Pass "" for desc to use the kebab-case case name. - let target (deps: 'TCase list) (desc: string) (body: FsContext -> 'r) : Definition<'TCase> = - FsTarget(desc, deps, fun ctx -> body ctx |> ignore) - - /// Marks a DU case as a CLI namespace segment. Not needed when using nested sub-DUs. - let ns (segment: string) (desc: string option) : Definition<'TCase> = - FsNamespace(segment, desc) - - /// Defines a command that composes other targets/commands. - /// `requires` entries are skipped under -s; `composes` entries always run. - /// Description is auto-generated from the requires/composes graph in help output. - let command (requires: 'TCase list) (composes: 'TCase list) : Definition<'TCase> = - FsCommand("", requires, composes, None) - - /// Like `command` but with a trailing body that runs after all `composes` entries. - let composer (requires: 'TCase list) (composes: 'TCase list) (body: FsContext -> 'r) : Definition<'TCase> = - FsCommand("", requires, composes, Some (fun ctx -> body ctx |> ignore)) diff --git a/src/Nullean.Make/Discovery/TargetNode.cs b/src/Nullean.Make/Discovery/TargetNode.cs index 4dae3d4..bcb32bb 100644 --- a/src/Nullean.Make/Discovery/TargetNode.cs +++ b/src/Nullean.Make/Discovery/TargetNode.cs @@ -35,6 +35,10 @@ internal sealed class TargetNode /// Typed body delegate: Action<T> or Func<T,Task>. Null when no DTO. public Delegate? TypedBody { get; set; } + /// Route-based dep type names used by before node resolution. + public List RouteRequires { get; } = new(); + public List RouteComposes { get; } = new(); + /// Resolved dep nodes after the graph is built. public List RequiresResolved { get; } = new(); public List ComposesResolved { get; } = new(); diff --git a/src/Nullean.Make/Nullean.Make.csproj b/src/Nullean.Make/Nullean.Make.csproj index 85739bf..3d1cf27 100644 --- a/src/Nullean.Make/Nullean.Make.csproj +++ b/src/Nullean.Make/Nullean.Make.csproj @@ -2,11 +2,14 @@ Nullean.Make - net8.0 + net11.0 Nullean.Make enable enable - latest + preview + + true + ..\..\build\keys\keypair.snk nuget-icon.png MIT @@ -17,17 +20,23 @@ Nullean.Make Nullean.Make - typed namespaced build target DSL Property-dispatch build target DSL with Argh-quality help and per-target typed CLI arguments. - - false + + + nuget-icon.png + True + nuget-icon.png + + + - <_Parameter1>Nullean.Make.Fs + <_Parameter1>Nullean.Make.Fs, PublicKey=002400000480000094000000060200000024000052534131000400000100010025d3a22bf3781ba85067374ad832dfcba3c4fa8dd89227e36121ba17b2c33ad6b6ce03e45e562050a031e2ff7fe12cff9060a50acbc6a0eef9ef32dc258d90f874b2e76b581938071ccc4b4d98204d1d6ca7a1988d7a211f9fc98efd808cf85f61675b11007d0eb0461dc86a968d6af8ebba7e6b540303b54f1c1f5325c252be diff --git a/src/Nullean.Make/UnionGraph/IUnionTargetBuilder.cs b/src/Nullean.Make/UnionGraph/IUnionTargetBuilder.cs new file mode 100644 index 0000000..c4737ae --- /dev/null +++ b/src/Nullean.Make/UnionGraph/IUnionTargetBuilder.cs @@ -0,0 +1,53 @@ +namespace Nullean.Make.UnionGraph; + +/// +/// Fluent builder returned from and +/// inside the app.Bind(...) lambda. +/// +/// The lambda is called twice per case: once at graph-build time with default values (for +/// metadata), and once at execution time with real CLI-parsed values (for the Executes body). +/// +/// +public interface IUnionTargetBuilder +{ + /// Sets the human-readable description shown in help output. + IUnionTargetBuilder Description(string text); + + /// Hides this target from default help output. + IUnionTargetBuilder Hidden(); + + /// + /// Declares dependencies by passing union values. + /// Implicit union conversion applies: DependsOn(new Clean(), new Build()) + /// when Clean/Build are case types of . + /// + IUnionTargetBuilder DependsOn(params TUnion[] deps); + + /// Declares a single dependency by case type. The case type must have a parameterless (or all-default) constructor. + IUnionTargetBuilder DependsOn() where T1 : new(); + + /// Declares two dependencies by case types. + IUnionTargetBuilder DependsOn() where T1 : new() where T2 : new(); + + /// Declares three dependencies by case types. + IUnionTargetBuilder DependsOn() + where T1 : new() where T2 : new() where T3 : new(); + + /// Declares four dependencies by case types. + IUnionTargetBuilder DependsOn() + where T1 : new() where T2 : new() where T3 : new() where T4 : new(); + + /// + /// Marks this target as a command: it composes other targets that always run (not skippable with -s). + /// + IUnionTargetBuilder Composes(params TUnion[] targets); + + /// Composes targets by case type. + IUnionTargetBuilder Composes() where T1 : new(); + + /// Registers a synchronous execution body. Closed-over case values are the real CLI-parsed ones at execution time. + IUnionTargetBuilder Executes(Action body); + + /// Registers an asynchronous execution body. + IUnionTargetBuilder Executes(Func body); +} diff --git a/src/Nullean.Make/UnionGraph/UnionOptionRef.cs b/src/Nullean.Make/UnionGraph/UnionOptionRef.cs new file mode 100644 index 0000000..f160250 --- /dev/null +++ b/src/Nullean.Make/UnionGraph/UnionOptionRef.cs @@ -0,0 +1,47 @@ +namespace Nullean.Make.UnionGraph; + +/// +/// Mutable handle to a global option declared on . +/// Obtain via or . +/// The value is pre-populated during argv extraction, before any target body runs. +/// +public sealed class UnionOptionRef +{ + private readonly Func _parser; + private T _value; + + internal UnionOptionRef(string longName, string? shortName, string? description, T defaultValue, Func parser) + { + Long = longName; + Short = shortName; + Description = description; + DefaultValue = defaultValue; + _value = defaultValue; + _parser = parser; + } + + /// Long flag name, e.g. --token. + public string Long { get; } + + /// Short flag name, e.g. -t. Null when not declared. + public string? Short { get; } + + /// Description shown in help output. + public string? Description { get; } + + /// Default value used when the flag is not supplied. + public T DefaultValue { get; } + + /// Current parsed value — valid after RunAsync begins argv extraction. + public T Value => _value; + + internal void Set(string raw) => _value = _parser(raw); + internal void Reset() => _value = DefaultValue; +} + +internal sealed record UnionOptionDecl( + string Long, + string? Short, + string? Description, + bool IsFlag, + Action Set); diff --git a/src/Nullean.Make/UnionGraph/UnionReflector.cs b/src/Nullean.Make/UnionGraph/UnionReflector.cs new file mode 100644 index 0000000..005bd51 --- /dev/null +++ b/src/Nullean.Make/UnionGraph/UnionReflector.cs @@ -0,0 +1,115 @@ +using System.Reflection; + +namespace Nullean.Make.UnionGraph; + +/// Runtime reflection helpers for C# 15 union types in Make pipelines. +internal static class UnionReflector +{ + /// True when is a C# 15 union type (implements IUnion, carries [Union], or matches the structural pattern). + internal static bool IsUnionType(Type t) + { + if (t.GetInterfaces().Any(i => i.Name == "IUnion")) + return true; + if (t.GetCustomAttributesData().Any(a => a.AttributeType.Name == "UnionAttribute")) + return true; + // Structural fallback: has object? Value property + ≥1 single-param public constructor + var valueProp = t.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); + return valueProp is not null + && (valueProp.PropertyType == typeof(object) || valueProp.PropertyType == typeof(object)) + && GetUnionCtors(t).Length > 0; + } + + /// Returns all single-parameter public constructors; each parameter type is a union case type. + internal static ConstructorInfo[] GetUnionCtors(Type t) + => t.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .Where(c => c.GetParameters().Length == 1) + .ToArray(); + + /// + /// Recursively enumerates all leaf case paths in the union hierarchy. + /// Nested union case types become namespace prefix segments in the route. + /// + internal static List GetCasePaths(Type unionType) + { + var result = new List(); + Collect(unionType, [], result); + return result; + } + + private static void Collect(Type unionType, string[] prefix, List result) + { + foreach (var ctor in GetUnionCtors(unionType)) + { + var caseType = ctor.GetParameters()[0].ParameterType; + var segment = ToKebabCase(caseType.Name); + if (IsUnionType(caseType)) + Collect(caseType, [..prefix, segment], result); + else + result.Add(new CasePath([..prefix, segment], caseType, ctor)); + } + } + + /// Constructs a union value wrapping a default instance of . + internal static TUnion ConstructDefault(Type caseType, ConstructorInfo unionCtor) + => (TUnion)unionCtor.Invoke([CreateDefaultInstance(caseType)]); + + /// + /// Constructs the outermost by walking up the nested ctor chain + /// from to the top of the union hierarchy. + /// + internal static TUnion ConstructUnion(Type outerUnionType, Type caseType, object caseInstance, string[] route) + => (TUnion)WrapValue(outerUnionType, caseType, caseInstance, route, 0); + + private static object WrapValue(Type unionType, Type leafCaseType, object leafInstance, string[] route, int depth) + { + foreach (var ctor in GetUnionCtors(unionType)) + { + var paramType = ctor.GetParameters()[0].ParameterType; + if (paramType == leafCaseType) + return ctor.Invoke([leafInstance]); + if (IsUnionType(paramType) && depth < route.Length - 1 + && ToKebabCase(paramType.Name) == route[depth]) + { + var inner = WrapValue(paramType, leafCaseType, leafInstance, route, depth + 1); + return ctor.Invoke([inner]); + } + } + throw new InvalidOperationException($"Cannot wrap {leafCaseType.Name} into {unionType.Name}."); + } + + /// Builds a map of case-type simple name → full route key for fast dep resolution. + internal static Dictionary BuildTypeNameToRouteMap(Type unionType) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var path in GetCasePaths(unionType)) + map[path.CaseType.Name] = string.Join("/", path.Route); + return map; + } + + private static object CreateDefaultInstance(Type t) + { + if (t.IsValueType) return Activator.CreateInstance(t)!; + var ctor = t.GetConstructors() + .OrderByDescending(c => c.GetParameters().Length) + .FirstOrDefault(c => c.GetParameters().All(p => p.HasDefaultValue)); + if (ctor is not null) + return ctor.Invoke(ctor.GetParameters().Select(p => p.DefaultValue).ToArray()); + return Activator.CreateInstance(t)!; + } + + internal static string ToKebabCase(string name) + { + if (string.IsNullOrEmpty(name)) return name; + var sb = new System.Text.StringBuilder(); + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (char.IsUpper(c) && i > 0) sb.Append('-'); + sb.Append(char.ToLowerInvariant(c)); + } + return sb.ToString(); + } +} + +/// A leaf path in the union case hierarchy: the CLI route, the record case type, and the wrapping constructor. +internal sealed record CasePath(string[] Route, Type CaseType, ConstructorInfo UnionCtor); diff --git a/src/Nullean.Make/UnionGraph/UnionTargetBuilderImpl.cs b/src/Nullean.Make/UnionGraph/UnionTargetBuilderImpl.cs new file mode 100644 index 0000000..1155757 --- /dev/null +++ b/src/Nullean.Make/UnionGraph/UnionTargetBuilderImpl.cs @@ -0,0 +1,105 @@ +using Nullean.Make.Discovery; + +namespace Nullean.Make.UnionGraph; + +/// +/// Internal implementation of . +/// A fresh instance is returned by every call to app.Target() / app.Command(). +/// calls app.Bind twice: at graph-build time +/// (reads Kind/Description/DepTypeNames) and at execution time (reads SyncBody/AsyncBody). +/// +internal sealed class UnionTargetBuilderImpl : IUnionTargetBuilder +{ + private string? _description; + private bool _hidden; + private Action? _syncBody; + private Func? _asyncBody; + private readonly TargetKind _kind; + private readonly List _depTypeNames = new(); + private readonly List _compTypeNames = new(); + + internal UnionTargetBuilderImpl(TargetKind kind = TargetKind.Target) => _kind = kind; + + // ── IUnionTargetBuilder ────────────────────────────────────────── + + public IUnionTargetBuilder Description(string text) { _description = text; return this; } + public IUnionTargetBuilder Hidden() { _hidden = true; return this; } + public IUnionTargetBuilder Executes(Action body) { _syncBody = body; return this; } + public IUnionTargetBuilder Executes(Func body) { _asyncBody = body; return this; } + + public IUnionTargetBuilder DependsOn(params TUnion[] deps) + { + foreach (var dep in deps) _depTypeNames.Add(GetLeafCaseName(dep)); + return this; + } + + public IUnionTargetBuilder DependsOn() where T1 : new() + { _depTypeNames.Add(typeof(T1).Name); return this; } + + public IUnionTargetBuilder DependsOn() where T1 : new() where T2 : new() + { _depTypeNames.Add(typeof(T1).Name); _depTypeNames.Add(typeof(T2).Name); return this; } + + public IUnionTargetBuilder DependsOn() + where T1 : new() where T2 : new() where T3 : new() + { + _depTypeNames.Add(typeof(T1).Name); + _depTypeNames.Add(typeof(T2).Name); + _depTypeNames.Add(typeof(T3).Name); + return this; + } + + public IUnionTargetBuilder DependsOn() + where T1 : new() where T2 : new() where T3 : new() where T4 : new() + { + _depTypeNames.Add(typeof(T1).Name); + _depTypeNames.Add(typeof(T2).Name); + _depTypeNames.Add(typeof(T3).Name); + _depTypeNames.Add(typeof(T4).Name); + return this; + } + + public IUnionTargetBuilder Composes(params TUnion[] targets) + { + foreach (var t in targets) _compTypeNames.Add(GetLeafCaseName(t)); + return this; + } + + public IUnionTargetBuilder Composes() where T1 : new() + { _compTypeNames.Add(typeof(T1).Name); return this; } + + // ── Internal accessors ──────────────────────────────────────────────────── + + internal TargetKind Kind => _kind; + internal string? DescriptionValue => _description; + internal bool IsHidden => _hidden; + internal Action? SyncBody => _syncBody; + internal Func? AsyncBody => _asyncBody; + internal IReadOnlyList DepTypeNames => _depTypeNames; + internal IReadOnlyList CompTypeNames => _compTypeNames; + + internal async Task ExecuteAsync() + { + if (_asyncBody is not null) await _asyncBody(); + else _syncBody?.Invoke(); + } + + /// + /// Extracts the leaf (innermost non-union) case type name from a union value. + /// Works for both flat and nested unions. + /// + private static string GetLeafCaseName(TUnion dep) + { + var valueProp = typeof(TUnion).GetProperty("Value", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + var inner = valueProp?.GetValue(dep); + if (inner is null) return ""; + var current = inner; + while (UnionReflector.IsUnionType(current.GetType())) + { + var vp = current.GetType().GetProperty("Value", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + current = vp?.GetValue(current) ?? current; + } + return current.GetType().Name; + } +} diff --git a/src/Nullean.Make/UnionMakeApp.cs b/src/Nullean.Make/UnionMakeApp.cs new file mode 100644 index 0000000..916b38b --- /dev/null +++ b/src/Nullean.Make/UnionMakeApp.cs @@ -0,0 +1,284 @@ +using Nullean.Make.Discovery; +using Nullean.Make.Execution; +using Nullean.Make.Help; +using Nullean.Make.Parsing; +using Nullean.Make.UnionGraph; + +namespace Nullean.Make; + +/// +/// Entry point for Make-based build scripts that use a C# 15 union type as the target identity. +/// Each case type of maps to a CLI target; nested union case types become +/// namespace segments (e.g. pkg/generate). +/// +/// Usage: +/// +/// var app = new UnionMakeApp<BuildTarget>("my-build"); +/// var token = app.Option<string?>("--token"); +/// app.Bind(t => t switch { +/// Clean => app.Target().Executes(() => ...), +/// Build b => app.Target().DependsOn<Clean>().Executes(() => ...), +/// Test t => app.Target().DependsOn<Build>().Executes(() => Exec(t.Filter)), +/// }); +/// return await app.RunAsync(args); +/// +/// +/// +/// A C# 15 union type whose case types are the build targets. +public sealed class UnionMakeApp where TUnion : struct +{ + private readonly BuildGraph _graph = new(); + private readonly List _options = new(); + private Func>? _bind; + private Dictionary? _typeNameToRoute; + + public UnionMakeApp(string name, string? description = null) + { + _graph.AppName = name; + _graph.AppDescription = description; + } + + // ── Global option registration ──────────────────────────────────────────── + + /// Registers a boolean flag. Read the returned ref's Value inside target bodies. + public UnionOptionRef Flag(string longName, string? shortName = null, string? description = null) + { + var r = new UnionOptionRef(longName, shortName, description, false, + s => string.Equals(s, "true", StringComparison.OrdinalIgnoreCase) || s == "1"); + _options.Add(new UnionOptionDecl(longName, shortName, description, IsFlag: true, Set: r.Set)); + _graph.GlobalOptions.Add(new GlobalOptionNode { Long = longName, Short = shortName, Description = description, IsFlag = true }); + return r; + } + + /// Registers a typed option. Read the returned ref's Value inside target bodies. + public UnionOptionRef Option(string longName, string? description = null, T defaultValue = default!) + { + var r = new UnionOptionRef(longName, null, description, defaultValue, + raw => (T)ParseRaw(typeof(T), raw, longName)); + _options.Add(new UnionOptionDecl(longName, null, description, IsFlag: false, Set: r.Set)); + _graph.GlobalOptions.Add(new GlobalOptionNode { Long = longName, Description = description, IsFlag = false }); + return r; + } + + // ── Target / Command factories (used inside Bind) ───────────────────────── + + /// Creates a target builder for use inside . + public IUnionTargetBuilder Target() => new UnionTargetBuilderImpl(TargetKind.Target); + + /// Creates a command builder for use inside . Commands compose other targets. + public IUnionTargetBuilder Command() => new UnionTargetBuilderImpl(TargetKind.Command); + + // ── Bind ────────────────────────────────────────────────────────────────── + + /// + /// Provides the exhaustive mapping from union case to target definition. + /// + /// The lambda is called twice per relevant case: once at graph-build time with default case + /// values (for metadata: deps, description, kind) and once at execution time with real + /// CLI-parsed case values (for the Executes closure). Side effects in switch arms + /// before Executes will fire at graph-build time with default values — put side + /// effects inside Executes. + /// + /// + public void Bind(Func> fn) => _bind = fn; + + // ── RunAsync ────────────────────────────────────────────────────────────── + + /// + /// Discovers union cases, validates the dependency graph, parses argv, and executes the + /// requested target. Returns an exit code suitable for returning from top-level statements. + /// + public async Task RunAsync(string[] args) + { + if (_bind is null) { Console.Error.WriteLine("[make] Bind() was not called."); return 1; } + + try + { + BuildGraph(); + ResolveRouteDeps(); + GraphValidator.Validate(_graph); + } + catch (MakeException ex) { Console.Error.WriteLine(ex.Message); return ex.ExitCode; } + + var scriptName = _graph.AppName; + + if (args.Length == 0) { MakeHelpPrinter.PrintRoot(_graph, scriptName); return 0; } + + var (remaining, singleTarget, showHelp, showVersion) = ExtractGlobals(args); + + if (showVersion) { Console.WriteLine("0.0.0"); return 0; } + + var (routeKey, targetArgs) = ResolveRoute(remaining); + + if (showHelp) + { + if (_graph.ByRoute.TryGetValue(routeKey, out var helpNode)) + { + if (helpNode.Kind == TargetKind.Command) MakeHelpPrinter.PrintCommand(helpNode, _graph, scriptName); + else MakeHelpPrinter.PrintTarget(helpNode, _graph, scriptName); + } + else MakeHelpPrinter.PrintRoot(_graph, scriptName); + return 0; + } + + if (string.IsNullOrEmpty(routeKey)) + { + var unknown = remaining.FirstOrDefault(t => !t.StartsWith("-")); + if (unknown is not null) { Console.Error.WriteLine($"Unknown target '{unknown}'."); return 2; } + MakeHelpPrinter.PrintRoot(_graph, scriptName); + return 0; + } + + if (!_graph.ByRoute.TryGetValue(routeKey, out var targetNode)) + { + Console.Error.WriteLine($"Unknown target '{routeKey}'."); + return 2; + } + + // Wire up execution lambdas for every node (root gets real args, deps get empty args) + WireExecutionBodies(targetNode, targetArgs); + + var parsed = new ParsedArgs + { + Target = targetNode, + TargetArgs = targetArgs, + SingleTarget = singleTarget, + }; + + return await DepGraphExecutor.ExecuteAsync(targetNode, parsed, _graph); + } + + // ── Graph building ──────────────────────────────────────────────────────── + + private void BuildGraph() + { + _typeNameToRoute = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var path in UnionReflector.GetCasePaths(typeof(TUnion))) + { + var defaultUnion = UnionReflector.ConstructDefault(path.CaseType, path.UnionCtor); + var builder = (UnionTargetBuilderImpl)_bind!(defaultUnion); + + var node = new TargetNode + { + Route = path.Route, + Kind = builder.Kind, + DtoType = path.CaseType, + Description = builder.DescriptionValue, + }; + node.Hidden = builder.IsHidden; + node.RouteRequires.AddRange(builder.DepTypeNames); + node.RouteComposes.AddRange(builder.CompTypeNames); + + _graph.Targets.Add(node); + _graph.ByRoute[string.Join("/", path.Route)] = node; + _typeNameToRoute[path.CaseType.Name] = string.Join("/", path.Route); + } + } + + private void ResolveRouteDeps() + { + var map = _typeNameToRoute!; + foreach (var node in _graph.Targets) + { + foreach (var depName in node.RouteRequires) + { + if (map.TryGetValue(depName, out var r) && _graph.ByRoute.TryGetValue(r, out var dep)) + node.RequiresResolved.Add(dep); + } + foreach (var compName in node.RouteComposes) + { + if (map.TryGetValue(compName, out var r) && _graph.ByRoute.TryGetValue(r, out var comp)) + node.ComposesResolved.Add(comp); + } + } + } + + /// + /// Sets AsyncBody on every node in the dependency plan. + /// The root gets real ; dep nodes get empty args (their + /// case DTOs are constructed with defaults, so their Executes closures see default values). + /// + private void WireExecutionBodies(TargetNode root, string[] targetArgs) + { + foreach (var node in _graph.Targets) + { + var n = node; + var args = ReferenceEquals(n, root) ? targetArgs : Array.Empty(); + n.AsyncBody = async () => + { + if (_bind is null || n.DtoType is null) return; + var caseInstance = Parsing.DtoBinder.Bind(n.DtoType, args); + var realUnion = UnionReflector.ConstructUnion(typeof(TUnion), n.DtoType, caseInstance, n.Route); + var builder2 = (UnionTargetBuilderImpl)_bind!(realUnion); + await builder2.ExecuteAsync(); + }; + } + } + + // ── Argv helpers ────────────────────────────────────────────────────────── + + private (string[] remaining, bool single, bool help, bool version) ExtractGlobals(string[] argv) + { + var remaining = new List(); + var single = false; var help = false; var version = false; + var i = 0; + while (i < argv.Length) + { + var arg = argv[i]; + if (arg is "-h" or "--help") { help = true; i++; continue; } + if (arg == "--version") { version = true; i++; continue; } + if (arg is "-s" or "--single-target") { single = true; i++; continue; } + + var matched = false; + foreach (var opt in _options) + { + var longN = opt.Long.TrimStart('-'); + var shortN = opt.Short?.TrimStart('-'); + var argN = arg.TrimStart('-'); + if (argN != longN && argN != shortN) continue; + if (opt.IsFlag) { opt.Set("true"); i++; } + else { i++; if (i < argv.Length) opt.Set(argv[i++]); } + matched = true; + break; + } + if (!matched) { remaining.Add(arg); i++; } + } + return (remaining.ToArray(), single, help, version); + } + + private (string routeKey, string[] targetArgs) ResolveRoute(string[] remaining) + { + var tokens = new List(); var rest = new List(); var done = false; + foreach (var token in remaining) + { + if (!done && !token.StartsWith("-")) + { + var candidate = string.Join("/", [..tokens, token.ToLowerInvariant()]); + if (_graph.ByRoute.ContainsKey(candidate)) + tokens.Add(token.ToLowerInvariant()); + else if (_graph.ByRoute.Keys.Any(k => k.StartsWith(candidate + "/", StringComparison.OrdinalIgnoreCase))) + tokens.Add(token.ToLowerInvariant()); + else { done = true; rest.Add(token); } + } + else rest.Add(token); + } + return (string.Join("/", tokens), rest.ToArray()); + } + + private static object ParseRaw(Type t, string raw, string flagName) + { + var target = Nullable.GetUnderlyingType(t) ?? t; + try + { + if (target == typeof(string)) return raw; + if (target == typeof(int)) return int.Parse(raw); + if (target == typeof(long)) return long.Parse(raw); + if (target == typeof(double)) return double.Parse(raw, System.Globalization.CultureInfo.InvariantCulture); + if (target == typeof(bool)) return bool.Parse(raw); + if (target.IsEnum) return Enum.Parse(target, raw, ignoreCase: true); + return raw; + } + catch { throw new MakeException($"Cannot parse '{raw}' for '{flagName}'.", 2); } + } +} diff --git a/tests/Argh.ExternalNs.GlobalOptions.Repro/CliApp/Argh.ExternalNs.GlobalOptions.Repro.CliApp.csproj b/tests/Argh.ExternalNs.GlobalOptions.Repro/CliApp/Argh.ExternalNs.GlobalOptions.Repro.CliApp.csproj index 3d81657..80e419c 100644 --- a/tests/Argh.ExternalNs.GlobalOptions.Repro/CliApp/Argh.ExternalNs.GlobalOptions.Repro.CliApp.csproj +++ b/tests/Argh.ExternalNs.GlobalOptions.Repro/CliApp/Argh.ExternalNs.GlobalOptions.Repro.CliApp.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net11.0 enable enable diff --git a/tests/Argh.ExternalNs.GlobalOptions.Repro/Tests/Tests.csproj b/tests/Argh.ExternalNs.GlobalOptions.Repro/Tests/Tests.csproj index d74ef20..7aacf3f 100644 --- a/tests/Argh.ExternalNs.GlobalOptions.Repro/Tests/Tests.csproj +++ b/tests/Argh.ExternalNs.GlobalOptions.Repro/Tests/Tests.csproj @@ -1,7 +1,7 @@ - net10.0 + net11.0 Argh.ExternalNs.GlobalOptions.Repro.Tests Argh.ExternalNs.GlobalOptions.Repro.Tests enable diff --git a/tests/Argh.InternalsVisibleTo.Repro/Tests/Argh.InternalsVisibleTo.Repro.Tests.csproj b/tests/Argh.InternalsVisibleTo.Repro/Tests/Argh.InternalsVisibleTo.Repro.Tests.csproj index 7d962de..b38e03e 100644 --- a/tests/Argh.InternalsVisibleTo.Repro/Tests/Argh.InternalsVisibleTo.Repro.Tests.csproj +++ b/tests/Argh.InternalsVisibleTo.Repro/Tests/Argh.InternalsVisibleTo.Repro.Tests.csproj @@ -1,7 +1,7 @@ - net10.0 + net11.0 Argh.InternalsVisibleTo.Repro.Tests Argh.InternalsVisibleTo.Repro.Tests enable diff --git a/tests/Nullean.Argh.Generator.Tests/Nullean.Argh.Generator.Tests.csproj b/tests/Nullean.Argh.Generator.Tests/Nullean.Argh.Generator.Tests.csproj index 262f62f..cc1e3a3 100644 --- a/tests/Nullean.Argh.Generator.Tests/Nullean.Argh.Generator.Tests.csproj +++ b/tests/Nullean.Argh.Generator.Tests/Nullean.Argh.Generator.Tests.csproj @@ -1,7 +1,7 @@ - net10.0 + net11.0 Nullean.Argh.Generator.Tests Nullean.Argh.Generator.Tests false diff --git a/tests/Nullean.Argh.IntegrationTests/Binding/UnionBindingTests.cs b/tests/Nullean.Argh.IntegrationTests/Binding/UnionBindingTests.cs new file mode 100644 index 0000000..ba5459e --- /dev/null +++ b/tests/Nullean.Argh.IntegrationTests/Binding/UnionBindingTests.cs @@ -0,0 +1,112 @@ +using FluentAssertions; +using Nullean.Argh.IntegrationTests.Infrastructure; +using Xunit; + +namespace Nullean.Argh.IntegrationTests.Binding; + +public class UnionBindingTests +{ + // ── Flag mode ────────────────────────────────────────────────────────────── + + [Fact] + public void Flag_mode_json_with_pretty() + { + var result = CliHostRunner.Run("union-format-flag", "--format", "json", "--json-pretty"); + result.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(result).Trim().Should().Be("json:pretty=True:indent=2"); + } + + [Fact] + public void Flag_mode_json_with_indent() + { + var result = CliHostRunner.Run("union-format-flag", "--format", "json", "--json-indent", "4"); + result.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(result).Trim().Should().Be("json:pretty=False:indent=4"); + } + + [Fact] + public void Flag_mode_table_with_pretty() + { + var result = CliHostRunner.Run("union-format-flag", "--format", "table", "--table-pretty"); + result.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(result).Trim().Should().Be("table:pretty=True"); + } + + [Fact] + public void Flag_mode_csv_no_props() + { + var result = CliHostRunner.Run("union-format-flag", "--format", "csv"); + result.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(result).Trim().Should().Be("csv"); + } + + [Fact] + public void Flag_mode_silently_ignores_unused_case_prop_flags() + { + // --json-pretty is for json; when --format is table it's silently unused (not an error) + var result = CliHostRunner.Run("union-format-flag", "--format", "table", "--json-pretty"); + result.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(result).Trim().Should().Be("table:pretty=False"); + } + + [Fact] + public void Flag_mode_both_table_and_json_have_pretty_flag_no_collision() + { + // table has --table-pretty; json has --json-pretty; no collision + var tableResult = CliHostRunner.Run("union-format-flag", "--format", "table", "--table-pretty"); + tableResult.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(tableResult).Trim().Should().Be("table:pretty=True"); + + var jsonResult = CliHostRunner.Run("union-format-flag", "--format", "json", "--json-pretty"); + jsonResult.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(jsonResult).Trim().Should().Be("json:pretty=True:indent=2"); + } + + [Fact] + public void Flag_mode_invalid_format_exits_nonzero() + { + var result = CliHostRunner.Run("union-format-flag", "--format", "xml"); + result.ExitCode.Should().Be(2); + } + + // ── Argument mode ────────────────────────────────────────────────────────── + + [Fact] + public void Argument_mode_json_with_pretty() + { + var result = CliHostRunner.Run("union-format-arg", "json", "--pretty"); + result.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(result).Trim().Should().Be("json:pretty=True:indent=2"); + } + + [Fact] + public void Argument_mode_json_with_indent() + { + var result = CliHostRunner.Run("union-format-arg", "json", "--indent", "4"); + result.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(result).Trim().Should().Be("json:pretty=False:indent=4"); + } + + [Fact] + public void Argument_mode_table_with_pretty() + { + var result = CliHostRunner.Run("union-format-arg", "table", "--pretty"); + result.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(result).Trim().Should().Be("table:pretty=True"); + } + + [Fact] + public void Argument_mode_csv_no_props() + { + var result = CliHostRunner.Run("union-format-arg", "csv"); + result.ExitCode.Should().Be(0); + CliHostRunner.StdoutText(result).Trim().Should().Be("csv"); + } + + [Fact] + public void Argument_mode_invalid_case_exits_nonzero() + { + var result = CliHostRunner.Run("union-format-arg", "xml"); + result.ExitCode.Should().Be(2); + } +} diff --git a/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs b/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs index 71244ec..1c2ad29 100644 --- a/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs @@ -126,6 +126,11 @@ hidden parameter. tag-set-parser-opt tags temporal-cmd + union-format-arg Output in a specific format + (argument mode: + [--pretty] ...). + union-format-flag Output in a specific format + (flag mode: --format ). uri-cmd validate-allowed Validate allowed values on --env. diff --git a/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs b/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs index 8b2bd2e..28418ab 100644 --- a/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs @@ -127,6 +127,11 @@ hidden parameter. tag-set-parser-opt tags temporal-cmd + union-format-arg Output in a specific format + (argument mode: + [--pretty] ...). + union-format-flag Output in a specific format + (flag mode: --format ). uri-cmd validate-allowed Validate allowed values on --env. diff --git a/tests/Nullean.Argh.IntegrationTests/Nullean.Argh.IntegrationTests.csproj b/tests/Nullean.Argh.IntegrationTests/Nullean.Argh.IntegrationTests.csproj index 9d2b19a..0cbda0a 100644 --- a/tests/Nullean.Argh.IntegrationTests/Nullean.Argh.IntegrationTests.csproj +++ b/tests/Nullean.Argh.IntegrationTests/Nullean.Argh.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net10.0 + net11.0 Nullean.Argh.IntegrationTests Nullean.Argh.IntegrationTests false diff --git a/tests/Nullean.Argh.Tests.CliHost/Nullean.Argh.Tests.CliHost.csproj b/tests/Nullean.Argh.Tests.CliHost/Nullean.Argh.Tests.CliHost.csproj index e67abc4..b5af7be 100644 --- a/tests/Nullean.Argh.Tests.CliHost/Nullean.Argh.Tests.CliHost.csproj +++ b/tests/Nullean.Argh.Tests.CliHost/Nullean.Argh.Tests.CliHost.csproj @@ -2,8 +2,8 @@ Exe - net10.0 -Nullean.Argh.Tests.CliHost + net11.0 + Nullean.Argh.Tests.CliHost enable enable false @@ -27,6 +27,7 @@ + diff --git a/tests/Nullean.Argh.Tests.ReferencedDtos/Nullean.Argh.Tests.ReferencedDtos.csproj b/tests/Nullean.Argh.Tests.ReferencedDtos/Nullean.Argh.Tests.ReferencedDtos.csproj index 2a7f9ef..b6fdc23 100644 --- a/tests/Nullean.Argh.Tests.ReferencedDtos/Nullean.Argh.Tests.ReferencedDtos.csproj +++ b/tests/Nullean.Argh.Tests.ReferencedDtos/Nullean.Argh.Tests.ReferencedDtos.csproj @@ -1,7 +1,7 @@ - net10.0 + net11.0 Nullean.Argh.Tests.ReferencedDtos Nullean.Argh.Tests.ReferencedDtos enable diff --git a/tests/Nullean.Argh.Tests/CliRegistrationModule.cs b/tests/Nullean.Argh.Tests/CliRegistrationModule.cs index 136aa9d..80422ff 100644 --- a/tests/Nullean.Argh.Tests/CliRegistrationModule.cs +++ b/tests/Nullean.Argh.Tests/CliRegistrationModule.cs @@ -112,6 +112,8 @@ internal static void RegisterCommands() g.Map(); g.Map(); }); + app.Map("union-format-flag", UnionFormatHandlers.FormatFlag); + app.Map("union-format-arg", UnionFormatHandlers.FormatArg); } /// Documented handler for lambda-style Map (XML appears in help). diff --git a/tests/Nullean.Argh.Tests/Fixtures/UnionFormatFixtures.cs b/tests/Nullean.Argh.Tests/Fixtures/UnionFormatFixtures.cs new file mode 100644 index 0000000..ea9df89 --- /dev/null +++ b/tests/Nullean.Argh.Tests/Fixtures/UnionFormatFixtures.cs @@ -0,0 +1,38 @@ +using Nullean.Argh; + +namespace Nullean.Argh.Tests.Fixtures; + +public record class Json(bool Pretty = false, int Indent = 2); +public record class Table(bool Pretty = false); +public record class Csv; + +public union Format(Table, Json, Csv); + +internal static class UnionFormatHandlers +{ + /// Output in a specific format (flag mode: --format <case>). + internal static void FormatFlag(TestGlobalCliOptions g, Format format) + { + var result = format.Value switch + { + Json j => $"json:pretty={j.Pretty}:indent={j.Indent}", + Table t => $"table:pretty={t.Pretty}", + Csv => "csv", + _ => "unknown" + }; + Console.WriteLine(result); + } + + /// Output in a specific format (argument mode: <format-case> [--pretty] ...). + internal static void FormatArg(TestGlobalCliOptions g, [Argument] Format format) + { + var result = format.Value switch + { + Json j => $"json:pretty={j.Pretty}:indent={j.Indent}", + Table t => $"table:pretty={t.Pretty}", + Csv => "csv", + _ => "unknown" + }; + Console.WriteLine(result); + } +} diff --git a/tests/Nullean.Argh.Tests/Nullean.Argh.Tests.csproj b/tests/Nullean.Argh.Tests/Nullean.Argh.Tests.csproj index 40eb5ca..ef7987c 100644 --- a/tests/Nullean.Argh.Tests/Nullean.Argh.Tests.csproj +++ b/tests/Nullean.Argh.Tests/Nullean.Argh.Tests.csproj @@ -1,7 +1,7 @@ - net10.0 + net11.0 Nullean.Argh.Tests Nullean.Argh.Tests false diff --git a/tools/Nullean.Argh.SchemaExport/Nullean.Argh.SchemaExport.csproj b/tools/Nullean.Argh.SchemaExport/Nullean.Argh.SchemaExport.csproj index 304b3de..fe7783d 100644 --- a/tools/Nullean.Argh.SchemaExport/Nullean.Argh.SchemaExport.csproj +++ b/tools/Nullean.Argh.SchemaExport/Nullean.Argh.SchemaExport.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net11.0 enable enable Nullean.Argh.SchemaExport