Skip to content

Support discriminated unions of alternative argument sets - #568

Merged
Smaug123 merged 8 commits into
mainfrom
argparser-du-support
Jul 18, 2026
Merged

Support discriminated unions of alternative argument sets#568
Smaug123 merged 8 commits into
mainfrom
argparser-du-support

Conversation

@Smaug123

Copy link
Copy Markdown
Owner

Stacked on #567. The feature this whole stack was building toward.

What you can now write

type FooArgs = { Foo : int }
type BarArgs = { Bar : int ; Baz : int }

[<ArgParser>]
type Args =
    | FooCase of FooArgs
    | BarCase of BarArgs

--foo=3 parses to FooCase { Foo = 3 }; --bar=8 --baz=9 to BarCase { Bar = 8; Baz = 9 }. --foo=3 --bar=8 fails with Arguments select more than one alternative: FooCase (via --foo=3), BarCase (via --bar=8); --bar=8 alone fails with BarCase's own Required argument '--baz' received no value; supplying nothing fails with No arguments were supplied to select one of: FooCase, BarCase. Unions compose: a record field may be union-typed (with other fields alongside), payload records may contain nested records, defaults, flags, etc., and when no argument touches a union, the unique case which needs no arguments is selected as the fallback.

Semantics

Exactly the shape-first, exactly-one-interpretation model from the design discussion: selection depends only on which argument names appear, never on whether values convert, and argument names are globally unique across cases (the existing collision check already enforces this), so parsing stays linear and a malformed value can never flip the selected case. The erased kernel's select was already property-tested against the exhaustive expand-every-alternative reference semantics in #566; this PR wires the generator to it: union lowering into the parse tree, tree-shaped erased schemas, ParseOutcome.Success carrying the selection, and selection-aware assembly (the slots of unselected cases are never read).

Generation-time checks (all verified to fire, documented in ArgParserConflictTests.fs)

  • Two cases both satisfiable with no arguments are rejected (an empty command line could not choose).
  • Positional args may not appear inside union cases, nor alongside a union arg — ownership of an unrecognised token would become ambiguous.
  • The argument name help is reserved in any casing (it always meant help at runtime; generation now refuses it).
  • Union cases must hold exactly one field, a record defined alongside the union.

Deferred follow-ups

  • Help text for union schemas is the flat list of every case's arguments; grouping by case ("exactly one of:") is a follow-up.
  • Sharing an argument name between two cases of one union (with the exponential-fallback parser) is deliberately unsupported.

🤖 Generated with Claude Code

@Smaug123
Smaug123 force-pushed the argparser-cutover branch from 0d98b98 to 5fd4cf0 Compare July 15, 2026 08:07
@Smaug123
Smaug123 force-pushed the argparser-du-support branch from 6e3455d to e398701 Compare July 15, 2026 08:07
@Smaug123
Smaug123 force-pushed the argparser-cutover branch from 5fd4cf0 to e5740f0 Compare July 15, 2026 18:29
@Smaug123
Smaug123 force-pushed the argparser-du-support branch from e398701 to 9d2113e Compare July 15, 2026 18:29
@Smaug123
Smaug123 force-pushed the argparser-cutover branch from e5740f0 to 247fe1f Compare July 15, 2026 19:08
@Smaug123
Smaug123 force-pushed the argparser-du-support branch from 9d2113e to e6e7fe8 Compare July 15, 2026 19:08
@Smaug123
Smaug123 force-pushed the argparser-cutover branch from 247fe1f to 1ea032c Compare July 15, 2026 19:53
@Smaug123
Smaug123 force-pushed the argparser-du-support branch 4 times, most recently from 0e2f8eb to f77bb29 Compare July 16, 2026 07:39
Base automatically changed from argparser-cutover to main July 16, 2026 07:58
@Smaug123
Smaug123 force-pushed the argparser-du-support branch from f77bb29 to 550182c Compare July 16, 2026 08:01
Smaug123 and others added 6 commits July 18, 2026 18:35
An [<ArgParser>] type may now be a discriminated union, each of whose
cases holds one record of that case's arguments; and a record field may
be union-typed in the same way. Exactly one case's arguments must be
supplied: the case whose arguments appear is selected, arguments from
two cases are a conflict naming both witnesses, a selected-but-
incomplete case reports its own missing arguments, and when nothing
selects a case the unique case which needs no arguments (if there is
one) is chosen. Argument names must be globally unique across cases
(already enforced by the existing collision check), so selection never
depends on value conversion and parsing stays linear.

The erased runtime already understood Sum schemas (property-tested
against the exhaustive expand-every-alternative semantics); this wires
the generator up: union lowering in the parse tree, tree-shaped erased
schemas, selection-aware assembly (slots of unselected cases are never
read), and ParseOutcome.Success now carries the selection.

Generation-time checks, exercised in ArgParserConflictTests:
- at most one case of a union may be satisfiable with no arguments;
- positional args may not appear inside union cases, nor alongside a
  union arg;
- the argument name "help" is reserved (it always meant help at
  runtime; now generation refuses it too).

Help text for union schemas is currently the flat list of every case's
arguments; grouping it by case is a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cross-case name collisions (including case-insensitive ones, the
soundness hole for case selection), ambiguous empty cases, positionals
inside or alongside a union, and malformed case payloads are now
asserted by driving the generator over in-memory source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Help was flattening the parse tree, presenting e.g. --foo, --bar and
--baz as one undifferentiated list with nothing to say that the grammar
is `--foo` XOR (`--bar` AND `--baz`). Help is now a fold of the parse
tree: each union renders as an "exactly one of the following sets of
arguments:" header with its alternatives' arguments grouped beneath
their case names, indenting two spaces per nesting level. Parsers with
no union render byte-identically to before.

Also exercise [<ArgumentDefaultFunction>] on a union case's payload
record end to end: the generated call resolves against the payload
record (fixed one level down the stack), the defaulted case is
selectable by an empty command line, and the default does not influence
case selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FCS represents `of (FooArgs)` as SynType.Paren, so the by-name lookups
for a union case's payload record and for union- or record-typed
fields rejected declarations which were valid without the parentheses.
Strip optional parens before all three lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
toParseSpec and unionToParseSpec descend into ambient records and
unions by name with no memory of what they were already expanding, so a
schema which refers to itself (even indirectly, e.g. a union whose case
payload record holds a field of that union) recursed until the Myriad
subprocess died with a stack overflow. Thread the chain of types
currently being lowered through the recursion and fail with the cycle
path on re-entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The by-name lookups which resolve a field type, a union case payload,
or a flag DU to a type defined alongside the tagged type compared only
the last segment of the reference. A qualified reference to a foreign
type whose last segment matches a local type name - e.g. System.Uri
alongside a structural union named Uri - was therefore captured by the
local type, silently generating code that does not compile (Uri.Case
constructions assigned into a System.Uri field). Local types can only
be referred to by bare name, so require a single-segment (possibly
parenthesized) reference at all four sites; qualified references now
fall through to the built-in leaf parsers or to a comprehensible
generation error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Smaug123
Smaug123 force-pushed the argparser-du-support branch from 02c7870 to 0230eab Compare July 18, 2026 17:37
}
|> spec.Apply

if Option.isSome pos && hasSum then

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I think at some point we can relax this.)

@Smaug123
Smaug123 enabled auto-merge (squash) July 18, 2026 18:29
@Smaug123
Smaug123 merged commit 952c445 into main Jul 18, 2026
20 checks passed
@Smaug123
Smaug123 deleted the argparser-du-support branch July 18, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant