Skip to content

feat(spec): a flag that has to be given on its own - #941

Merged
jdx merged 17 commits into
agent/groups-derivefrom
agent/exclusive
Aug 17, 2026
Merged

feat(spec): a flag that has to be given on its own#941
jdx merged 17 commits into
agent/groups-derivefrom
agent/exclusive

Conversation

@jdx

@jdx jdx commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Stacked on #934. Next item down the clap-parity list in PLAN.md, and the last of the "sets of flags" group.

flag "--dump" exclusive=#true
#[usage(long, exclusive)]
dump: bool,

--version, --dump-config: asking for one means the rest of the command line has nothing to act on. The spec could say a flag conflicts with named other flags, and had no way to say it conflicts with everything — nor any way to put a positional on either side of a conflict, since a selector names a flag.

Why it is not a group of everything

Enforced against everything the command declares, positionals included. That is the part conflicts and group structurally cannot express: both name flags. ex --dump t is refused, and that is the case worth having.

Only what was supplied counts, the rule conflicts already follows. A flag with a default standing beside an exclusive one is nobody saying anything — counting it would make the exclusive flag unusable on any command that has a default, which is most of them.

The bridge carries this one

Unlike requires in #925, Arg::is_exclusive_set is public, so a CLI that already declares this in clap keeps it on the way through rather than silently losing it. Both gen-shadow dialects write it as well, since clap and the derive can each say it — no new entry in the dropped-properties report.

Tests

  • exclusive_round_trips_and_comes_across_from_clap — the KDL both ways, and the bridge
  • an_exclusive_flag_has_to_be_alone — usage-lib and the derive, each covering the other-flag case and the positional case
  • an_exclusive_flag_is_not_disturbed_by_a_default — the rule above, which is the one a future change is most likely to get wrong

cargo test --all --all-features, mise run lint, mise run render and mise run gen-shadow are clean.

🤖 Generated with Claude Code


Note

Medium Risk
Touches core post-parse validation in both the spec parser and derive-generated check, including subtle global-flag merge and env/default interactions; mistakes could break parsing or wrongly skip or enforce requirements.

Overview
Adds exclusive for flags (KDL exclusive=#true, derive #[usage(exclusive)]) so a flag like --dump or --version must be the only input on the invocation—other flags, positionals, and selecting a subcommand all count as company. Defaults do not; env-supplied values do.

Spec & bridges: FlagMeta / SpecFlag gain exclusive; KDL round-trips; clap import uses is_exclusive_set; conformance tables and gen-shadow emit it.

usage-lib parser: Post-parse validation tracks which spellings activated each flag, resolves child vs ancestor exclusivity on merged globals without merging the exclusive bool, diagnoses companions, and treats an active exclusive as a requiredness escape (like clap) while still rejecting invalid combinations.

usage-derive: Refactors post-binding into apply_defaults / apply_env plus new CommandArgs/Subcommands hooks (any_given, exclusive_given, …) so exclusivity works across flattened Args and subcommands; exclusive suppresses sibling required checks but not flattened defaults.

Docs and broad conformance tests cover flatten, globals, env, and alias redeclaration scenarios.

Reviewed by Cursor Bugbot for commit f119674. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b6bbf1cd-9bcd-46a8-9bd1-f716dc3333c9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread lib/src/parse.rs
@greptile-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds whole-invocation exclusive flags across KDL specifications, derive-generated parsers, usage-lib parsing, clap bridging, and shadow generation.

  • Preserves exclusivity through flattened argument groups, subcommands, environment fallbacks, defaults, and global-flag redeclarations.
  • Tracks supplied spellings to distinguish child-owned aliases from inherited aliases after global flags are merged.
  • Adds conformance and regression coverage for positional companions, requiredness escape, environment values, and alias collisions.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
derive/src/codegen.rs Generates presence summaries and cross-boundary exclusivity checks while retaining defaults and environment fallbacks.
lib/src/parse.rs Enforces exclusive occurrences in usage-lib and attributes merged global aliases according to the spelling or active child declaration.
argv/src/spec.rs Adds exclusive flag metadata, KDL emission, and composition hooks for generated parsers.
derive/src/model.rs Parses the exclusive derive attribute and rejects its use on positional fields.
lib/src/spec/flag.rs Adds exclusive to the specification model and its serialization and bridge behavior.
conformance/tests/post_binding.rs Covers direct, flattened, subcommand, requiredness, and redeclared-alias exclusivity behavior.
conformance/tests/post_binding_env.rs Covers environment-provided values across direct, flattened, and selected-child boundaries.
xtask/src/shadow.rs Emits exclusive declarations in generated usage and clap shadow implementations.

Reviews (20): Last reviewed commit: "fix(parse): one colliding alias does not..." | Re-trigger Greptile

Comment thread derive/src/codegen.rs
Comment thread lib/src/parse.rs Outdated
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

Nothing was compared, and so nothing was gated. No series appears on both sides: either the base has no measurements recorded, or the two were measured on different runner classes, which are deliberately not comparable — counts shift between machine types by more than a real regression does.

New, nothing to compare against: markdown on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.1, startup on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.1

Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run.

Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes.

Shadow comparison

Parsing mise use -g node@20 against a shadow of mise's committed spec.
Reported, not gated: the shadow grows as the derive learns to express more, so
what to watch is the ratio rather than either column.

framework instructions, cold parse vs usage
usage 4186
argh 6292 1.5x
clap 5895248 1408x
bpaf 21917778 5235x
                                              min       p01       p10    median
usage-rs: argv -> struct                      191       202       211       218  ns
argh: argv -> struct                          283       289       293       299  ns
clap: build tree + parse -> struct         487940    489931    493479    506456  ns
bpaf: build parser + parse -> struct      1604906   1604906   1614234   1638870  ns

usage: argv -> struct                             199 ns      0.20 µs
clap: build tree + parse -> struct             503665 ns    503.67 µs
clap: parse -> struct, tree reused              23369 ns     23.37 µs
clap: build tree only                          313292 ns    313.29 µs

f119674a943a vs c5d9ad605c95 · measured on the runner, not pushed to the history.

Comment thread derive/src/codegen.rs
Comment thread lib/src/parse.rs Outdated
Comment thread lib/src/parse.rs Outdated
Comment thread lib/src/parse.rs Outdated
Comment thread lib/src/parse.rs Outdated
Comment thread lib/src/parse.rs Outdated
@jdx
jdx force-pushed the agent/exclusive branch from c3f52c4 to 04241c4 Compare August 17, 2026 04:32
Comment thread lib/src/parse.rs Outdated
Comment thread lib/src/parse.rs Outdated
Comment thread lib/src/parse.rs Outdated
Comment thread lib/src/parse.rs
Comment thread derive/src/codegen.rs
@jdx
jdx force-pushed the agent/exclusive branch from 6bbc522 to fb6118f Compare August 17, 2026 11:56
Comment thread derive/src/model.rs
Comment thread derive/src/codegen.rs
Comment thread lib/src/parse.rs Outdated
@jdx
jdx force-pushed the agent/exclusive branch from 7216992 to ca79ffd Compare August 17, 2026 12:53
Comment thread lib/src/parse.rs Outdated
Comment thread derive/src/codegen.rs

jdx commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

Pushed fixes for the two findings that were still live against the head. Both were the same shape of mistake: a piece of behavior riding along with a carrier that was only meant to carry something else.

A child's exclusive was lost when its flag merged with an inherited global (@greptile). merge_subcommand_flags retains the parent's flag object so the merged aliases stay global, and exclusivity was coming along with it. A child that redeclares the same long form as local and exclusive had its declaration silently discarded — and so would a child that explicitly removed one. exclusive now comes from the child declaration, which is the one that owns behavior at the command it belongs to.

Exclusivity was suppressing flattened defaults, not just flattened requiredness (@cursor Bugbot). A parent exclusive flag skips a flattened group's check, which was also where that group's declared defaults were applied — so flattened fields kept type defaults while direct fields kept declared ones, and usage-lib disagreed with both. Declared defaults moved into their own CommandArgs::apply_defaults, next to the apply_env that exists for exactly this reason, and run before exclusivity is computed. Skipping check now skips only the missing-value reporting it was meant to skip.

Tests

  • a_local_child_redeclaration_keeps_its_exclusivity_when_merged — the app run --clean --verbose case from the report
  • an_exclusive_flag_does_not_skip_flattened_defaults--dump alone still leaves a flattened --jobs at its declared 4

Each fails without its fix. cargo test --all --all-features, mise run lint, mise run render and mise run gen-shadow are clean, with no regenerated output.

#961 has been rebased onto this head; its own five commits are unchanged apart from the one-line adaptation to the new helper.

This comment was generated by Claude Code.

Comment thread lib/src/parse.rs Outdated
@greptile-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Both fixes are correct and the reasoning tracks cleanly through the code.

Fix 1 — merge_subcommand_flags: Taking exclusive from flag (the child) rather than from the cloned global_flag is exactly right. The merge exists to keep orphaned parent aliases reachable; it was never meant to override behavioral attributes at the command the child belongs to. The new comment captures this distinction precisely.

Fix 2 — apply_defaults / apply_env separation: This is a genuine design clarification, not just a patch. The original check conflated two semantically distinct operations — "fill in what the CLI promised to provide" versus "report what the user failed to provide." An exclusive flag legitimately suppresses the latter; it never should have touched the former. Extracting declared_defaults and env_fallbacks as separate trait methods (apply_defaults, apply_env) that the parent calls unconditionally — before computing exclusive_present — resolves that conflation at the right level.

The updated flattened_checks guard:

if !__usage_exclusive_present
    || <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident)
        .is_some()
{
    <#ty as usage_argv::spec::CommandArgs>::check(&mut partial.#ident)?;
}

also handles the symmetric case: if the exclusive flag lives inside the flattened group, its own companion-conflict errors should still surface, and this guard ensures they do.

The two regression tests each fail without their respective fix and pass with it — the minimum useful proof. The rest of the conformance suite (flattening_does_not_hide_either_side_of_exclusivity, an_exclusive_flag_does_not_skip_flattened_defaults, selecting_a_subcommand_counts_as_company_for_a_parent_exclusive_flag) extends coverage to the cross-boundary cases that the fixes touch. The implementation looks correct.

jdx commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

Pushed a fix for @cursor Bugbot's follow-up, which was right and was my own doing: the previous commit traded one direction of the bug for the other.

merged.exclusive cannot be assigned correctly from either side. When a child re-declares an inherited global, one object ends up answering to two alias sets whose declarations disagree, and a single bool holds one answer. Taking the child's discarded the ancestor's for the spellings only the ancestor declared — so with a parent -c --clean global exclusive and a child re-declaring just --clean locally, ex run -c was accepted. That also disagreed with a global child re-declaration, where no merge happens and those spellings stay exclusive.

So it is no longer assigned. The merged flag keeps the ancestor's exclusive, and exclusivity is resolved per occurrence through the same spelling ledger that already decides whether selecting the child is company. Both questions now go through one owned_by_selected_command, so they cannot drift apart again, and exclusive_present consults it too — without that, dropping the assignment would have quietly taken the requiredness escape away from a merged child exclusive flag.

The derive never had this to reconcile: it keeps the two declarations as separate fields, so the ancestor's short alias stays bound to the ancestor's. It was already giving the right answer, and this brings usage-lib to it rather than the reverse.

Tests — each fails without the change:

  • an_orphan_ancestor_alias_keeps_its_exclusivity_past_a_plain_child_redeclaration — the reported case
  • a_merged_child_exclusive_flag_still_escapes_requiredness — the regression dropping the assignment would otherwise have caused
  • an_orphan_ancestor_alias_keeps_its_exclusivity_past_a_child_redeclaration (conformance) — holds the derive and usage-lib to one answer

a_local_child_redeclaration_keeps_its_exclusivity_when_merged from the previous round fails without the new helper too, so both directions are carried by it rather than by the assignment it replaces.

cargo test --all --all-features, mise run lint, mise run render and mise run gen-shadow are clean, with no regenerated output. #961 has been rebased onto this head.

This comment was generated by Claude Code.

@jdx
jdx force-pushed the agent/exclusive branch 2 times, most recently from a37ce6b to 9a5338d Compare August 17, 2026 19:35

jdx commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto the updated #934, itself now on main.

Two things on main met code this branch wrote, and neither showed up as a conflict — the rebase replayed cleanly and then failed to build, which is worth knowing if you rebase this yourself:

  • conformance/src/tables.rs builds a FlagMeta field by field, so the exclusive this branch adds left it one short.
  • perf(derive): hold one subcommand's partial, not every subcommand's #981 changed a subcommand partial from a struct with one field per variant to an enum holding only the selected one. any_given, exclusive_given and apply_env — the three this branch added to cross the subcommand boundary — were still reaching for partial.v{i}. Matched to the settings_given beside them, which already asks the variant.

The behaviour is unchanged by that second one: each was already gated on selected, so only the selected arm was ever consulted, and an unselected arm has nothing to have been given. The exclusivity-across-subcommands tests cover it.

mise run ci passes end to end.

This comment was generated by Claude Code.

jdx and others added 16 commits August 17, 2026 19:39
`--version`, `--dump-config`: asking for one means the rest of the command line
has nothing to act on. The spec could say that a flag conflicts with named other
flags and had no way to say it conflicts with *everything* — and no way at all to
put a positional on either side of a conflict, since a selector names a flag.

    flag "--dump" exclusive=#true

Enforced in both parsers against everything the command declares, positionals
included, which is what makes it more than being in a group with every other
flag. Only what was supplied counts, the rule `conflicts` already follows: a
flag with a default standing beside an exclusive one is nobody saying anything,
and counting it would make the exclusive flag unusable on any command that has a
default.

The bridge carries it, unlike `requires` — `Arg::is_exclusive_set` is public — so
a CLI that already declares this in clap keeps it on the way through. Both
`gen-shadow` dialects write it too, since clap and the derive can each say it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A child re-declaring an inherited global merges into one flag object carrying
both alias sets. Assigning the child's `exclusive` onto that object answered for
the ancestor's aliases too, so a local child that omitted `exclusive` let an
orphan ancestor alias through beside a selected subcommand — the opposite
asymmetry from the one the previous commit fixed, and inconsistent with a global
child re-declaration, which leaves those spellings exclusive.

A single bool cannot hold two declarations' answers. The merged flag keeps the
ancestor's, and exclusivity is resolved per occurrence through the same spelling
ledger that already decides whether selecting the child is company — so the two
questions cannot drift apart. The derive never had this to reconcile, keeping
the declarations as separate fields; a conformance test now holds both to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ownership was still one boolean for the whole occurrence, and two cases need
finer grain than that. An ancestor-only alias typed beside a child-exclusive
spelling attributed everything to the ancestor, so the child's exclusivity never
fired — `run -c --clean --verbose` was accepted. And an environment value, which
has no spelling to attribute it by, was matched against the merged flag's whole
alias set, which a child re-declaring one alias can never equal; the ancestor
answered for it in both directions.

`exclusivity_in_play` now returns the two sides separately: the child's, for the
spellings the child declared, and the ancestor's, for the ones only it declared.
Both can be in play at once, which is exactly the mixed-alias case. An
environment value takes the declaration the selected command has in scope. The
subcommand-as-company question asks which exclusivity is being enforced rather
than who owns the flag, which is the thing it actually meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things arrived on main while this branch was open, and both meet it in code
this branch wrote. `conformance/src/tables.rs` builds a `FlagMeta` field by
field, so the `exclusive` added here left it one short. And a subcommand partial
is now an enum holding only the selected variant rather than a struct with one
field per variant, so `any_given`, `exclusive_given` and `apply_env` — the three
this branch added to cross the subcommand boundary — were still reaching for
`partial.v{i}`.

Matched to the `settings_given` beside them, which already asks the variant. The
behavior is unchanged: every one of these was already gated on `selected`, so
only the selected arm was ever consulted, and an arm that is not selected has
nothing to have been given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx
jdx force-pushed the agent/exclusive branch from 9a5338d to 4e2b571 Compare August 17, 2026 19:43

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4e2b571. Configure here.

Comment thread lib/src/parse.rs
exclusive_occurrence(flag)
&& !overridden_flags.contains(&flag.name)
&& (flag_was_parsed(flag) || flag_has_env(flag, custom_env))
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pre-subcommand merge loses exclusivity

High Severity

When a child re-declares an inherited global as local and exclusive, typing that shared spelling before the subcommand word attributes the occurrence to the pre-merge parent Arc stored in prefix_bindings. Exclusivity later looks up the post-merge Arc via flag_was_parsed, so the child's exclusivity never activates: companions are accepted and requiredness is not escaped, even though the same spelling after the subcommand works.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4e2b571. Configure here.

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 checked this one and I think it is the rule working rather than a bug — with a test added so it stops being re-reported either way.

The child's --clean here is local. A local declaration describes the flag at the command that declares it, so ahead of the subcommand word it is not in scope at all and the flag can only be the ancestor's. The ancestor's exclusivity is therefore the one that answers — and in your example the ancestor is not exclusive, so nothing fires.

The check that convinced me is the same spec with the ancestor's exclusive flipped and nothing else changed:

ancestor ex --clean run
global accepted
global exclusive refused

One rule, read off the ancestor both times. The second row is a_child_local_exclusive_redeclaration_belongs_to_the_child, already in the suite, whose message is "the ancestor spelling still conflicts with selecting the child". Attributing the pre-subcommand occurrence to the child instead would make that row legal and break that test — so the asymmetry you spotted is not an oversight, it is what decides that case.

Now pinned by a_local_child_declaration_is_not_in_scope_before_the_subcommand, which asserts both rows together so the rule is visible in one place.

Worth saying plainly: whether a global flag ought to change meaning by position is a spec-level question, not a parser one, and if @jdx wants both spellings to reach the child's declaration then that existing test is the thing to revisit first. I have not made that change.

I did fix the greptile finding alongside this, which was a real ownership bug in the same code.

This comment was generated by Claude Code.

Comment thread lib/src/parse.rs Outdated
…flag

Ownership asked whether *every* spelling the child declared resolves to this
flag. A child may declare one that another inherited global already owns — `-c
--clean` beside an inherited `-c --config` — and that collision is settled in the
other global's favor, so the child's `-c` resolves elsewhere. Requiring all of
them then disowned the child from the `--clean` it plainly does own, and its
`exclusive` stopped applying: `run --clean --verbose` was accepted.

Any of them is enough, and still tells apart the case the check exists for: when
a child re-declares a global as global there are two flags, and the child's own
spellings resolve to the child's, so none of them lands on the ancestor's.

Also pins the rule a local re-declaration follows before the subcommand word,
which was read as a bug and is the same rule working: such a declaration
describes the flag at the child, so ahead of the subcommand the flag can only be
the ancestor's, and the ancestor's exclusivity answers whichever way it is set.
The two halves of the new test differ in nothing but that setting.

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

jdx commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

Two findings this round. One was real and is fixed; the other I believe is the rule working, and I have left that thread open for you rather than closing it myself.

Fixed — one colliding alias disowned a child from its own flag (@greptile). Ownership asked whether every spelling the child declared resolves to this flag, and a single unrelated collision defeats that: with an inherited -c --config in scope, a child declaring -c --clean loses -c to the other global, so the child was disowned from the --clean it plainly owns and its exclusive stopped applying. any instead of all, which still tells apart the case the check exists for — a child re-declaring a global as global makes two flag objects, and the child's own spellings resolve to the child's, so none of them lands on the ancestor's.

Not changed — a local declaration before the subcommand word (@cursor Bugbot). The child's --clean there is local, so ahead of the subcommand it is not in scope and the flag can only be the ancestor's. Flipping only the ancestor's exclusive and changing nothing else gives:

ancestor ex --clean run
global accepted
global exclusive refused

One rule, read off the ancestor both times — and the second row is a_child_local_exclusive_redeclaration_belongs_to_the_child, already in the suite. Attributing that occurrence to the child would make it legal and break that test, so the asymmetry is what decides the case rather than an oversight. Whether a global flag should change meaning by position is a spec-level call; if you want it to, that test is the thing to revisit first, and I have not touched it.

Testsa_colliding_alias_does_not_disown_the_child_from_the_rest (fails with all restored) and a_local_child_declaration_is_not_in_scope_before_the_subcommand, which asserts both rows of the table together so the rule reads in one place.

mise run ci passes end to end. #961 rebased on top.

This comment was generated by Claude Code.

@greptile-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

The reasoning is sound. The allany fix correctly narrows the disown condition to the case it was written for: a child re-declaring a global flag creates two flag objects, and the child's spellings all resolve to the child's object, so none of them can hit the ancestor's. A single colliding alias (like -c absorbed by an inherited --config) doesn't falsify ownership of the flag the child plainly does own.

The local-flag case follows from the same scope rule that governs every local flag: it doesn't exist before the subcommand token, so ex --clean run can only be parsed against the ancestor's flags. The table in your message spells out both rows clearly, a_child_local_exclusive_redeclaration_belongs_to_the_child pins the accepted row, and the new a_local_child_declaration_is_not_in_scope_before_the_subcommand pins both together. My original comment was wrong to call it an asymmetry needing explanation — it is just scoping, and you've got a test proving it.

@jdx
jdx merged commit 3559384 into main Aug 17, 2026
11 checks passed
@jdx
jdx deleted the agent/exclusive branch August 17, 2026 22:41
jdx added a commit that referenced this pull request Aug 17, 2026
Stacked on #941. Third item on the "changes what a CLI does" list in
PLAN.md.

```kdl
flag "--tags <tag>" var=#true delimiter=","
```

```rust
#[usage(long, delimiter = ',')]
tags: Vec<String>,
```

`--tags a,b,c` as three values. The spec could already record a clap
**default** split by a delimiter — `lib/src/spec/arg.rs` has done that
since the `--fs-events` fix — and had no way to say the same thing about
a value someone *types*. An adopter using `value_delimiter` was losing
behaviour rather than spelling.

### Split before anything judges the result

This is the part that decides whether the feature is worth having.
`choices` is asked about each value rather than about the word that
carried them, and `var_min`/`var_max` count the values the user meant
rather than the words they typed:

```
--env dev,prod   # two values, both on the choices list — accepted
--env dev,nope   # reports `nope`, not `dev,nope`
--env a,b,c      # three values against var_max=2 — refused
```

Judging first would reject `dev,prod` against a list that both halves
are on, which is what the first draft of this did.

### A delimiter needs somewhere to put what it splits

So it goes with `var`. On a single-value flag everything after the first
separator would be dropped silently — refused where it is written
instead, in all three places a spec can declare one (root `arg`, `cmd`
block `arg`, and `flag`), and a compile error in the derive, where the
field has to be a `Vec`. A non-ASCII delimiter is refused too, since
values are split by byte.

### Where the split runs

The derive splits on the **cold path**, first in `check` and after the
environment has filled what argv left out — `TAGS=a,b` is one word too.
The binder is untouched: it collects words and knows nothing about what
a value *is*, which is what keeps the hot path free of all of this. A
split costs one allocation per value, only for a field that asked for
one.

### The bridge carries it

`Arg::get_value_delimiter` is public — the same getter the default
splitting already used. Only where several values can land, because clap
refuses `value_delimiter` with `num_args(1)` itself, and a spec
recording one on a single-value argument would be a spec this crate then
declines to parse.

`cargo test --all --all-features`, `mise run lint`, `mise run render`
and `mise run gen-shadow` are clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches core argv binding and both derive and spec parsers, so
miscounting or split order could change CLI behavior; mitigated by
extensive tests and alignment with clap semantics.
> 
> **Overview**
> Adds **`delimiter`** (spec KDL, `#[usage(delimiter = ',')]`, metadata)
so one argv word can represent several values, matching clap’s
`value_delimiter`.
> 
> **Parsing behavior:** Values are **split on the delimiter before**
`choices` and `var_min`/`var_max` run, so `--env dev,prod` is judged per
value and limits count intended values, not raw words. The **derive**
path splits in generated `check` after env fallback; **`usage-lib`**
does the same for spec-driven parse; the **argv binder** stays
word-oriented but uses `values_in` and can return **`VarTooMany`** when
a delimited word exceeds `var_max` in one step.
> 
> **Validation & bridge:** Delimiter requires a **`Vec`** field
(compile-time in derive) and **`var`** in the spec; only **ASCII**
separators are stored in binding tables. Clap import sets `var` when a
delimiter is present and round-trips ASCII delimiters in KDL/docs/shadow
codegen.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
abc6187. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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