Skip to content

ArgParser: add [<ArgumentPrefix "foo">] to namespace a sub-record's args - #598

Merged
Smaug123 merged 4 commits into
mainfrom
argparser-argument-prefix
Jul 29, 2026
Merged

ArgParser: add [<ArgumentPrefix "foo">] to namespace a sub-record's args#598
Smaug123 merged 4 commits into
mainfrom
argparser-argument-prefix

Conversation

@Smaug123

Copy link
Copy Markdown
Owner

A field whose type is another argument record contributes that record's arguments directly, so embedding the same record twice made its arguments collide and was rejected outright. [<ArgumentPrefix "foo">] namespaces a field's whole subtree, so --blah becomes --foo-blah and this becomes expressible:

type Endpoint = { Host : string ; Port : int }

[<ArgParser>]
type Transfer =
    {
        [<ArgumentPrefix "src">] Source : Endpoint
        [<ArgumentPrefix "dst">] Dest : Endpoint
    }
./my-app --src-host=a.example.com --src-port=1 --dst-host=b.example.com --dst-port=2

Why it is this small

An argument's spellings are assembled in exactly one place (longForms), and help text, the --no- variant, the generation-time conflict checks and the erased schema handed to the runtime all read them from there. Applying the prefix at that point is the whole implementation, and needs no runtime change at all. The prefixed names are what the duplicate-name checks see, so a prefix can resolve a collision and equally create one; and negation wraps from outside as --no-foo-blah because the runtime builds the negated token as "--no-" + form.

Semantics

  • Applies to every argument in the subtree, including ones carrying an explicit [<ArgumentLongForm>] — otherwise two parents embedding one child under different prefixes would still collide on it.
  • The prefix accumulates rather than resetting as the recursion descends, so it covers the subtree however deep, whether or not intervening records carry prefixes of their own. Nested prefixes compose from the outside in (--outer-inner-blah).
  • A union case is an alternative rather than a nesting level, so the prefix passes through each case unchanged.
  • Must be a string literal (it is combined into names as the parser is generated, so it must be known then), non-empty, free of =, and without an edge dash since the separating - is inserted for you. Used verbatim: a hand-written string has none of the constraints an F# identifier does, so unlike the identifier-derived default there is nothing to normalise.
  • Misplacing it on a leaf, on a [<PositionalArgs>] field, or on a union case is an error with an explanation, not a silent drop.

Tests

Generation-time rejections and a property over prefix strings (accepted exactly when well-formed) in TestArgParserRejection.fs; behaviour in the new TestArgParserPrefix.fs, including:

  • the unprefixed parser used as an oracle — prefixing is a renaming and changes nothing else;
  • composition associativity"outer" wrapping "inner" gives exactly the names "outer-inner" gives;
  • help text ≡ accepted forms, which is what would catch a prefix applied to one consumer of a spelling but not another.

Checked by mutation: neutering the prefix application fails the build outright, because Transfer's two Endpoint copies collide without it.

Known adjacent defect, not fixed here

Emitting a spelling which needs escaping is a pre-existing bug: [<ArgumentLongForm "back\\tab">] is already emitted as "back\tab" with no prefix in sight, and is read back with a tab in it. The fix belongs with the emission sites and applies equally to spellings we do not rebuild, so it is a separate PR. This PR deliberately keeps ArgForm holding the semantic spelling, which is what the case-insensitive name checks must compare — there is a test pinning that, since escaping there would compare é against É where é and É collide.

🤖 Generated with Claude Code

Smaug123 and others added 2 commits July 29, 2026 07:56
A field whose type is another argument record contributes that record's
arguments directly, so embedding the same record twice made its arguments
collide and was rejected. [<ArgumentPrefix "foo">] namespaces a field's whole
subtree, so `--blah` becomes `--foo-blah` and

    { [<ArgumentPrefix "src">] Source : Endpoint
      [<ArgumentPrefix "dst">] Dest : Endpoint }

is expressible.

An argument's spellings are assembled in exactly one place, and help text, the
--no- variant, the generation-time conflict checks and the erased schema handed
to the runtime all read them from there. Applying the prefix at that point is
therefore the whole implementation, and needs no runtime change: the prefixed
names take part in conflict detection (so a prefix can resolve a collision and
equally create one), and negation wraps the prefix from outside as
`--no-foo-blah`.

The prefix accumulates rather than resetting as the recursion descends, so it
covers the subtree however deep, whether or not intervening records carry
prefixes of their own, and nested prefixes compose from the outside in. A union
case is an alternative rather than a nesting level, so the prefix passes through
each case unchanged.

It must be a string literal (it is combined into names as the parser is
generated, so it must be known then), non-empty, free of '=', and without an
edge dash, since the separating '-' is inserted for us. It is used verbatim: a
hand-written string has none of the constraints an F# identifier does, so unlike
the identifier-derived default there is nothing to normalise. Misplacing it on a
leaf, on a [<PositionalArgs>] field, or on a union case is an error rather than a
silent drop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he dispatch

Found in review. The structural branches run before any leaf machinery, so the
prefix-on-a-positional-field rejection never saw a field which was both prefixed
and record-typed: generation quietly prefixed the subtree and dropped the
[<PositionalArgs>]. Check the pairing ahead of the dispatch, which also lets the
leaf branch drop to a single message.

Also pin down the invariant that the prefixed name reaching the generation-time
name checks is the argument's *semantic* spelling and not a rendering of it. The
checks compare under the scanner's case-insensitive equality, so an escaped
rendering would compare `é` against `É` where `é` and `É` collide, and
a schema which fails at every parse would sail through generation.

(Emitting a spelling which needs escaping is a separate, pre-existing defect:
`[<ArgumentLongForm "back\\tab">]` is already emitted as `"back\tab"` with no
prefix in sight, and is read back with a tab in it. Fixing that belongs with the
emission sites, and applies equally to the spellings we do not rebuild.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Smaug123
Smaug123 force-pushed the argparser-argument-prefix branch from fcd09e6 to 4214ba8 Compare July 29, 2026 06:58
@Smaug123
Smaug123 enabled auto-merge (squash) July 29, 2026 07:04
@Smaug123
Smaug123 merged commit 2239805 into main Jul 29, 2026
20 checks passed
@Smaug123
Smaug123 deleted the argparser-argument-prefix branch July 29, 2026 07:08
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