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