Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,9 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and
requirement declared by its parent while conflicts and the child's own requirements
still apply. KDL, usage-lib, typed Rust, generated Go, and the clap bridge agree.
- [ ] **Remaining command parsing policy** —
`args_conflicts_with_subcommands`, `subcommand_precedence_over_arg`, and
`allow_missing_positional`.
`args_conflicts_with_subcommands` is now portable across KDL, typed Rust,
usage-lib, generated Go, and the clap bridge. Remaining:
`subcommand_precedence_over_arg` and `allow_missing_positional`.
- [x] **`args_override_self`.** Repeated scalar flags are corrections by default;
the later value wins. `args_override_self=false` opts a command into duplicate
errors. KDL, usage-lib, the typed derive/static metadata, generated Go, and the
Expand Down
9 changes: 9 additions & 0 deletions argv/src/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,15 @@ pub fn render(
));
}
}
Error::SubcommandConflict { subcommand } => {
with_usage = true;
let _ = writeln!(
out,
"{} the subcommand '{}' cannot be used with arguments on its parent command",
style.error("error:"),
style.invalid(subcommand.name)
);
}
Error::MissingRequired { name } => {
with_usage = true;
let _ = writeln!(
Expand Down
34 changes: 34 additions & 0 deletions argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ pub struct Command<'a> {
pub arg_required_else_help: bool,
/// Selecting a subcommand suppresses this command's required arguments.
pub subcommand_negates_reqs: bool,
/// Once this command binds a flag or positional, selecting one of its
/// subcommands is an error.
pub args_conflicts_with_subcommands: bool,
/// Disable delimiter splitting for positional values after `--` or on an
/// automatic trailing argument. Inherited by subcommands.
pub dont_delimit_trailing_values: bool,
Expand Down Expand Up @@ -237,6 +240,7 @@ impl Command<'_> {
external_subcommand: false,
arg_required_else_help: false,
subcommand_negates_reqs: false,
args_conflicts_with_subcommands: false,
dont_delimit_trailing_values: false,
unknown_flags: ::core::option::Option::None,
version: false,
Expand Down Expand Up @@ -517,6 +521,8 @@ pub enum Error<'t, 'v> {
/// A word was offered to a `double_dash = "required"` argument before any
/// `--` had been seen.
ArgRequiresDoubleDash { arg: &'t Arg<'t> },
/// A subcommand was selected after this command had already bound an argument.
SubcommandConflict { subcommand: &'t Command<'t> },
/// The command tree is deeper than [`MAX_DEPTH`].
TooDeep,

Expand Down Expand Up @@ -1060,6 +1066,10 @@ pub struct Parser<'t, 'a, 'v> {
/// Whether any word has been bound to a positional of `cmd`. Once one has,
/// no further word can select a subcommand.
arg_filled: bool,
/// Whether this command has bound any flag or positional. Unlike
/// `arg_filled`, flags count because clap's command policy treats both as
/// arguments that exclude a later subcommand.
command_arg_found: bool,
/// Whether flag interpretation has stopped. A `--` does this, and so does an
/// `automatic` argument taking a value.
flags_stopped: bool,
Expand Down Expand Up @@ -1114,6 +1124,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
arg_pos: 0,
arg_taken: 0,
arg_filled: false,
command_arg_found: false,
flags_stopped: false,
separator_seen: false,
default_taken: false,
Expand Down Expand Up @@ -1223,6 +1234,9 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
return None;
}
let event = self.step();
if matches!(event, Some(Ok(Event::Flag { .. } | Event::Arg { .. }))) {
self.command_arg_found = true;
}
if let Some(Err(_)) = event {
self.done = true;
}
Expand Down Expand Up @@ -1524,6 +1538,9 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
// to equal a subcommand name is just a value.
if !self.arg_filled && !self.flags_stopped {
if let Some(sub) = self.find_subcommand(token) {
if self.cmd.args_conflicts_with_subcommands && self.command_arg_found {
return Err(Error::SubcommandConflict { subcommand: sub });
}
Comment thread
cursor[bot] marked this conversation as resolved.
self.descend(sub)?;
return Ok(Event::Command(sub));
}
Expand Down Expand Up @@ -1678,6 +1695,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
self.arg_pos = 0;
self.arg_taken = 0;
self.arg_filled = false;
self.command_arg_found = false;
Ok(())
}

Expand Down Expand Up @@ -1912,6 +1930,13 @@ mod tests {
subcommands: &[&INSTALL],
..Command::EMPTY
};
static ARGUMENT_CONFLICT: Command = Command {
name: "ex",
flags: &[&FORCE],
subcommands: &[&INSTALL],
args_conflicts_with_subcommands: true,
..Command::EMPTY
};

// A CLI shaped exactly like mise's root: a default subcommand, a positional of its own,
// and a subcommand under the default — which is the arrangement that tells routing from
Expand Down Expand Up @@ -2256,6 +2281,15 @@ mod tests {
}
}

#[test]
fn a_parent_argument_can_exclude_a_later_subcommand() {
let a = argv(["--force", "install"]);
assert!(matches!(
parse(&ARGUMENT_CONFLICT, &a),
Err(Error::SubcommandConflict { subcommand }) if subcommand.name == "install"
));
}

#[test]
fn subcommand_only_routes_before_a_positional_is_filled() {
let a = argv(["other", "install"]);
Expand Down
6 changes: 6 additions & 0 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,9 @@ impl Spec<'_> {
if self.root.cmd.subcommand_negates_reqs {
writeln!(out, "subcommand_negates_reqs #true")?;
}
if self.root.cmd.args_conflicts_with_subcommands {
writeln!(out, "args_conflicts_with_subcommands #true")?;
}
// A `complete` block for every completer this CLI declares, naming the command that
// asks the binary itself. Written rather than declared, so there is one place a
// completer is said to exist: the Rust function. Everything that reads a spec — the
Expand Down Expand Up @@ -1389,6 +1392,9 @@ fn write_command<'a>(
if meta.cmd.subcommand_negates_reqs {
out.push_str(" subcommand_negates_reqs=#true");
}
if meta.cmd.args_conflicts_with_subcommands {
out.push_str(" args_conflicts_with_subcommands=#true");
}
out.push_str(" {\n");

let inner = depth + 1;
Expand Down
6 changes: 0 additions & 6 deletions clap_usage/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ pub enum FidelityFeature {
DistinctValueNames,
GranularHide,
AllowMissingPositional,
ArgsConflictWithSubcommands,
SubcommandPrecedenceOverArg,
FlattenHelp,
NextLineHelp,
Expand Down Expand Up @@ -86,11 +85,6 @@ fn visit(cmd: &Command, ancestors: &[String], losses: &mut BTreeSet<FidelityLoss
FidelityFeature::AllowMissingPositional,
"allow_missing_positional",
);
command_loss(
cmd.is_args_conflicts_with_subcommands_set(),
FidelityFeature::ArgsConflictWithSubcommands,
"args_conflicts_with_subcommands",
);
command_loss(
cmd.is_subcommand_precedence_over_arg_set(),
FidelityFeature::SubcommandPrecedenceOverArg,
Expand Down
1 change: 1 addition & 0 deletions conformance/src/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ pub fn build(
external_subcommand: cmd.external_subcommand,
arg_required_else_help: cmd.arg_required_else_help,
subcommand_negates_reqs: cmd.subcommand_negates_reqs,
args_conflicts_with_subcommands: cmd.args_conflicts_with_subcommands,
dont_delimit_trailing_values: cmd.dont_delimit_trailing_values,
key: 0,
}));
Expand Down
4 changes: 4 additions & 0 deletions derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ pub fn emit(cli: &Cli) -> TokenStream {
let dont_delimit_trailing_values = cli.dont_delimit_trailing_values;
let args_override_self = cli.args_override_self;
let subcommand_negates_reqs = cli.subcommand_negates_reqs;
let args_conflicts_with_subcommands = cli.args_conflicts_with_subcommands;
let usage = option_str(cli.usage.as_deref());
let restart_token = option_str(cli.restart_token.as_deref());
let mount = option_str(cli.mount.as_deref());
Expand Down Expand Up @@ -424,6 +425,7 @@ pub fn emit(cli: &Cli) -> TokenStream {
unknown_flags: #unknown_flags,
arg_required_else_help: #arg_required_else_help,
subcommand_negates_reqs: #subcommand_negates_reqs,
args_conflicts_with_subcommands: #args_conflicts_with_subcommands,
dont_delimit_trailing_values: #dont_delimit_trailing_values,
name: #name,
key: #root_key,
Expand Down Expand Up @@ -3448,6 +3450,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream {
let dont_delimit_trailing_values = cli.dont_delimit_trailing_values;
let args_override_self = cli.args_override_self;
let subcommand_negates_reqs = cli.subcommand_negates_reqs;
let args_conflicts_with_subcommands = cli.args_conflicts_with_subcommands;
let before_help = option_expr(cli.before_help.as_ref());
let before_long_help = option_expr(cli.before_long_help.as_ref());
let after_help = option_expr(cli.after_help.as_ref());
Expand Down Expand Up @@ -3559,6 +3562,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream {
unknown_flags: #unknown_flags,
arg_required_else_help: #arg_required_else_help,
subcommand_negates_reqs: #subcommand_negates_reqs,
args_conflicts_with_subcommands: #args_conflicts_with_subcommands,
dont_delimit_trailing_values: #dont_delimit_trailing_values,
flags: #flag_table_ref,
args: #arg_table_ref,
Expand Down
7 changes: 6 additions & 1 deletion derive/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ pub struct Cli {
pub dont_delimit_trailing_values: bool,
pub args_override_self: bool,
pub subcommand_negates_reqs: bool,
pub args_conflicts_with_subcommands: bool,
/// Declared descriptions, for the case a doc comment cannot express: a long form that does
/// not contain the short one.
pub about_attr: Option<proc_macro2::TokenStream>,
Expand Down Expand Up @@ -558,6 +559,7 @@ impl Cli {
dont_delimit_trailing_values: false,
args_override_self: true,
subcommand_negates_reqs: false,
args_conflicts_with_subcommands: false,
about_attr: None,
long_about_attr: None,
before_help: None,
Expand Down Expand Up @@ -701,6 +703,9 @@ impl Cli {
}
"args_override_self" => cli.args_override_self = flag_value(&meta)?,
"subcommand_negates_reqs" => cli.subcommand_negates_reqs = flag_value(&meta)?,
"args_conflicts_with_subcommands" => {
cli.args_conflicts_with_subcommands = flag_value(&meta)?
}
"restart_token" => cli.restart_token = Some(string_value(&meta)?),
"mount" => cli.mount = Some(string_value(&meta)?),
"group" => cli.groups.push(group_decl(&meta)?),
Expand All @@ -712,7 +717,7 @@ impl Cli {
format!(
"unknown option `{other}` on a struct; usage::Cli takes \
`name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `usage`, `verbatim_doc_comment`, `unknown_flags`, \
`default_subcommand`, `multicall`, `no_binary_name`, `arg_required_else_help`, `dont_delimit_trailing_values`, `args_override_self`, `subcommand_negates_reqs`, \
`default_subcommand`, `multicall`, `no_binary_name`, `arg_required_else_help`, `dont_delimit_trailing_values`, `args_override_self`, `subcommand_negates_reqs`, `args_conflicts_with_subcommands`, \
`next_help_heading`, `restart_token`, `mount` and \
`group` here, and the description comes from the doc \
comment"
Expand Down
3 changes: 2 additions & 1 deletion docs/rust/clap-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ the Rust declaration, not only from generated KDL, wherever the bridge column sa
| `arg_required_else_help` | yes | yes | yes | yes | yes | yes | Bare selected commands request short help; defaults and environment values do not count as argv. |
| `args_override_self` | yes | yes | yes | yes | yes | yes | Usage defaults to permissive last-one-wins behavior; set false for strict duplicate checking. |
| `subcommand_negates_reqs` | yes | yes | yes | yes | yes | yes | A selected child suppresses its parent's positive requirements, not conflicts or the child's requirements. |
| remaining subcommand/argument policies | no | no | no | no | no | no | `args_conflicts_with_subcommands`, precedence, and missing positionals are not represented. |
| `args_conflicts_with_subcommands` | yes | yes | yes | yes | yes | yes | Parent flags or positionals exclude a later child subcommand. |
| remaining subcommand/argument policies | no | no | no | no | no | no | Subcommand precedence and missing positionals are not represented. |
| unknown flags | different | different | different | different | yes | different | usage is permissive by default; `unknown_flags = "error"` opts into strict parsing. |

## Help, version, and generated artifacts
Expand Down
3 changes: 3 additions & 0 deletions docs/rust/migrating-from-clap.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ command received an argv token; environment and default fallbacks do not count.
`#[command(subcommand_negates_reqs)]` also migrates in place. Selecting a child suppresses
the parent's positive requirements while leaving conflicts and the child's requirements active.

`#[command(args_conflicts_with_subcommands)]` migrates in place as well. A flag
or positional bound on the parent prevents selecting a later child command.

Container casing also migrates in place. `#[command(rename_all = "snake_case")]` controls
inferred field or subcommand names, and `rename_all_env` controls names generated by bare
`#[arg(env)]`; an explicit `long`, `name`, or `env = "NAME"` still wins.
Expand Down
11 changes: 11 additions & 0 deletions docs/spec/reference/cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ cmd "inspect"
`ex inspect` is valid, while bare `ex` still requires `input`. Conflicts continue to apply,
and the selected child must still satisfy its own requirements.

### Make parent arguments conflict with subcommands

`args_conflicts_with_subcommands` makes arguments on a command and its child
subcommands mutually exclusive. Once that command binds a flag or positional, a
later child name is rejected; arguments after a selected child belong to the
child as usual:

```kdl
args_conflicts_with_subcommands #true
```

### Preserve delimiters in trailing values

`dont_delimit_trailing_values` keeps a positional token whole after `--`, or once a
Expand Down
8 changes: 8 additions & 0 deletions go/argv/argv.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ type Command struct {
ArgRequiredElseHelp bool
// SubcommandNegatesReqs lets a selected child satisfy this command's requirements.
SubcommandNegatesReqs bool
// ArgsConflictWithSubcommands rejects selecting a child after this command
// has already bound a flag or positional.
ArgsConflictWithSubcommands bool
// DontDelimitTrailingValues disables delimiter splitting after -- and for
// automatic trailing arguments. It is inherited by subcommands.
DontDelimitTrailingValues bool
Expand Down Expand Up @@ -316,6 +319,8 @@ const (
CodeMissingFlagValue
// CodeUnexpectedArg means a word arrived with no argument left to hold it.
CodeUnexpectedArg
// CodeSubcommandConflict means an argument was bound before a subcommand.
CodeSubcommandConflict
// CodeArgRequiresDoubleDash means a double_dash="required" argument was
// offered a word before any -- had been seen.
CodeArgRequiresDoubleDash
Expand Down Expand Up @@ -355,6 +360,7 @@ var codeNames = [...]string{
CodeUnknownFlag: "unknown_flag",
CodeMissingFlagValue: "missing_flag_value",
CodeUnexpectedArg: "unexpected_arg",
CodeSubcommandConflict: "subcommand_conflict",
CodeArgRequiresDoubleDash: "arg_requires_double_dash",
CodeTooDeep: "too_deep",
CodeHelp: "help",
Expand Down Expand Up @@ -450,6 +456,8 @@ func (e *Error) Error() string {
return "missing value for flag: " + e.Flag.Name
case CodeUnexpectedArg:
return "unexpected argument: " + safe(e.Token)
case CodeSubcommandConflict:
return "subcommand conflicts with an earlier argument: " + safe(e.Token)
case CodeArgRequiresDoubleDash:
return "argument requires a -- separator: " + e.Arg.Name
case CodeTooDeep:
Expand Down
10 changes: 10 additions & 0 deletions go/argv/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ type Parser struct {
// argFilled records whether any word has been bound to a positional of cmd.
// Once one has, no further word can select a subcommand.
argFilled bool
// commandArgFound includes flags as well as positionals for the command
// policy that makes either exclude a later subcommand.
commandArgFound bool
// flagsStopped records whether flag interpretation has stopped. A -- does
// this, and so does an automatic argument taking a value.
flagsStopped bool
Expand Down Expand Up @@ -187,6 +190,9 @@ func (p *Parser) fail(e Error) bool {
// emit records an event and reports that iteration continues.
func (p *Parser) emit(e Event) bool {
p.event = e
if e.Kind == KindFlag || e.Kind == KindArg {
p.commandArgFound = true
}
return true
}

Expand Down Expand Up @@ -474,6 +480,9 @@ func (p *Parser) word(token string) bool {
// equal a subcommand name is just a value.
if !p.argFilled && !p.flagsStopped {
if sub := p.findSubcommand(token); sub != nil {
if p.cmd.ArgsConflictWithSubcommands && p.commandArgFound {
return p.fail(Error{Code: CodeSubcommandConflict, Token: token, Cmd: p.cmd})
}
if !p.descend(sub) {
return false
}
Expand Down Expand Up @@ -596,6 +605,7 @@ func (p *Parser) descend(sub *Command) bool {
p.argPos = 0
p.argTaken = 0
p.argFilled = false
p.commandArgFound = false
return true
}

Expand Down
7 changes: 7 additions & 0 deletions go/argv/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ var (
Args: []*Arg{file, rest},
Subcommands: []*Command{install},
}
argumentConflict = &Command{
Name: "ex",
Flags: []*Flag{force},
Subcommands: []*Command{install},
ArgsConflictWithSubcommands: true,
}

// The same CLI, but one that owns all of its flags and wants typo detection.
strictInstall = &Command{
Expand Down Expand Up @@ -104,6 +110,7 @@ func TestBinding(t *testing.T) {
{"an alias descends", root, []string{"i"}, "cmd:install"},
{"a global reaches a subcommand", root, []string{"install", "--verbose"}, "cmd:install flag:verbose"},
{"a subcommand flag is not in scope above it", root, []string{"--force", "install"}, "flag:force cmd:install"},
{"a parent argument excludes a later subcommand", argumentConflict, []string{"--force", "install"}, "flag:force err:subcommand_conflict"},
{"only the descent position routes", root, []string{"other", "install"}, "arg:file=other arg:rest=install"},
{"a separator makes values", root, []string{"--", "--force"}, "arg:file=--force"},
{"a second separator is a value", root, []string{"--", "a", "--"}, "arg:file=a arg:rest=--"},
Expand Down
Loading
Loading