From 7a1537a2e448d5b4ec36940537a6d41fdc73be2e Mon Sep 17 00:00:00 2001 From: Jeff Dickey <216188+jdx@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:59:41 -0500 Subject: [PATCH 1/4] feat(parse): add opt-in default-subcommand flag routing Entire-Checkpoint: 01M2645HAETT5B3HQ5EFSMPR75 --- argv/src/complete.rs | 8 +- argv/src/lib.rs | 156 ++++++- argv/src/spec.rs | 3 + cli/src/cli/complete_word.rs | 22 +- cli/src/cli/lint.rs | 9 + conformance/src/argv.rs | 1 + conformance/src/tables.rs | 6 + conformance/tests/default_subcommand_flags.rs | 85 ++++ corpus/16-default-subcommand-flags.json | 404 ++++++++++++++++++ .../complete/04-default-subcommand-flags.json | 42 ++ derive/src/codegen.rs | 2 + derive/src/lib.rs | 2 +- derive/src/model.rs | 25 +- docs/spec/argv.md | 5 +- docs/spec/reference/index.md | 31 ++ go/argv/argv.go | 2 + go/argv/complete.go | 5 + go/argv/complete_test.go | 23 + go/argv/parser.go | 147 ++++++- go/internal/spec/spec.go | 4 +- lib/src/go/mod.rs | 11 + lib/src/parse.rs | 164 ++++++- lib/src/spec/mod.rs | 20 + xtask/src/shadow.rs | 7 + 24 files changed, 1169 insertions(+), 15 deletions(-) create mode 100644 conformance/tests/default_subcommand_flags.rs create mode 100644 corpus/16-default-subcommand-flags.json create mode 100644 corpus/complete/04-default-subcommand-flags.json diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 560dc05af..4bc7e483f 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -183,6 +183,12 @@ fn walk_inner<'t>( } let next_arg = parser.pending_arg(); + let mut flags: Vec<_> = parser.flags_in_scope().collect(); + if parser.command().default_subcommand_flags { + if let Some(default) = parser.command().default_subcommand { + flags.extend_from_slice(default.flags); + } + } Position { path: parser.command_path(), cmd: parser.command(), @@ -197,7 +203,7 @@ fn walk_inner<'t>( separator_seen: parser.double_dash_seen(), command_start: parser.command_start(), help_topic: false, - flags: parser.flags_in_scope().collect(), + flags, external, } } diff --git a/argv/src/lib.rs b/argv/src/lib.rs index cef4f8d9c..100b978cf 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -217,6 +217,9 @@ pub struct Command<'a> { /// Resolve it with [`find_subcommand`], which turns a name that no subcommand answers to /// into a compile error. pub default_subcommand: ::core::option::Option<&'a Command<'a>>, + /// Look ahead past parent/default flags before implicitly selecting the default. + /// Explicit siblings win; parent-only flags retain their ordinary meaning. + pub default_subcommand_flags: bool, /// Whether an unmatched word is forwarded as an external command plus the rest of argv. /// /// clap's `allow_external_subcommands`. Known subcommands still win; a @@ -290,6 +293,7 @@ impl Command<'_> { clause: ::core::option::Option::None, subcommands: &[], default_subcommand: ::core::option::Option::None, + default_subcommand_flags: false, external_subcommand: false, arg_required_else_help: false, subcommand_negates_reqs: false, @@ -1622,7 +1626,10 @@ fn os_values_given<'t, 'v, T: From>( Ok(out) } -/// A single-pass parse over `argv`. +/// A single binding pass over `argv`. +/// +/// [`Command::default_subcommand_flags`] adds a read-only lookahead before binding +/// to choose an implicit command boundary. /// /// Created with [`Parser::new`] and driven with [`Parser::next_event`]. pub struct Parser<'t, 'a, 'v> { @@ -1689,6 +1696,8 @@ pub struct Parser<'t, 'a, 'v> { /// Once, per parse: a default subcommand that itself declares one would otherwise /// descend on every word until the tree ran out. default_taken: bool, + /// First default-only flag, when lookahead found no explicit sibling. + default_flag_at: Option, /// Set once a fatal error has been reported, so iteration stops. done: bool, /// Whether declared built-in actions stop parsing with their action error. @@ -1727,7 +1736,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { argv: &'a [&'v OsStr], action_errors: bool, ) -> Self { - Parser { + let mut parser = Parser { argv, pos: 0, cmd: root, @@ -1756,11 +1765,142 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { flags_stopped: false, separator_seen: false, default_taken: false, + default_flag_at: None, done: false, action_errors, help_span: (0, 0), pending_clause_boundary: ::core::option::Option::None, + }; + if root.default_subcommand_flags { + parser.default_flag_at = parser.default_flag_route(); + } + parser + } + + /// Find the implicit boundary without binding anything. Parent spellings win while + /// scanning the prefix; after the boundary the ordinary child/global scope applies. + /// Unknown flags stop lookahead because their value arity cannot be guessed. + fn default_flag_route(&self) -> Option { + let default = self.cmd.default_subcommand?; + let mut at = None; + let mut i = 0; + while let Some(token) = self.argv.get(i).map(bytes) { + if token == b"--" || token == b"-" { + return at; + } + if !is_flag_like(token) { + return if self.find_subcommand(token).is_some() + || (token == b"help" && !self.cmd.disable_help_subcommand) + { + None + } else { + at + }; + } + let mut value_flag = None; + let mut attached = None; + if let Some(body) = token.strip_prefix(b"--") { + let end = body.iter().position(|b| *b == b'=').unwrap_or(body.len()); + let name = &body[..end]; + let parent = self.find_long(name).or_else(|| self.find_negation(name)); + if parent.is_none() + && ((name == b"help" && !self.cmd.disable_help_flag) + || (name == b"version" + && self.cmd.version + && !self.cmd.disable_version_flag)) + { + i += 1; + continue; + } + let flag = parent.or_else(|| { + default.flags.iter().copied().find(|f| { + f.longs.iter().any(|l| l.as_bytes() == name) + || f.negate.is_some_and(|n| n.as_bytes() == name) + }) + }); + let Some(flag) = flag else { + return at; + }; + if parent.is_none() { + at.get_or_insert(i); + } + if flag.takes_value && !flag.negate.is_some_and(|n| n.as_bytes() == name) { + value_flag = Some(flag); + attached = (end < body.len()).then(|| &body[end + 1..]); + } + } else { + for (offset, byte) in token[1..].iter().enumerate() { + let parent = self.find_short(*byte); + let Some(flag) = parent.or_else(|| { + default + .flags + .iter() + .copied() + .find(|f| f.shorts.contains(byte)) + }) else { + return at; + }; + if parent.is_none() { + at.get_or_insert(i); + } + if flag.takes_value { + value_flag = Some(flag); + let rest = &token[offset + 2..]; + attached = + (!rest.is_empty()).then_some(rest.strip_prefix(b"=").unwrap_or(rest)); + break; + } + } + } + i += 1; + if let Some(flag) = value_flag { + let first = if let Some(value) = attached { + Some(value) + } else if !flag.require_equals { + self.argv + .get(i) + .map(bytes) + .filter(|next| { + flag.allow_hyphen_values + || !is_flag_like(next) + || (flag.allow_negative_numbers && is_negative_number(next)) + }) + .inspect(|_| i += 1) + } else { + None + }; + if first.is_none() && !flag.value_optional && flag.default_missing.is_none() { + return at; + } + if flag.variadic { + let mut count = first.map_or(0, |v| values_in(v, flag.delimiter)); + while !flag.var_max.is_some_and(|max| count >= max) { + let Some(next) = self.argv.get(i).map(bytes) else { + break; + }; + if next == b"--" || flag.value_terminator.is_some_and(|end| end == next) { + if next != b"--" { + i += 1; + } + break; + } + if is_flag_like(next) + && !(flag.allow_negative_numbers && is_negative_number(next)) + { + break; + } + if self.cmd.subcommand_precedence_over_arg + && self.find_subcommand(next).is_some() + { + return None; + } + count += values_in(next, flag.delimiter); + i += 1; + } + } + } } + at } /// Restrict inherited root globals to those carried by an executable view. @@ -1881,6 +2021,18 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } fn step(&mut self) -> Option, Error<'t, 'v>>> { + if self.default_flag_at == Some(self.pos) && self.bundle.is_empty() { + self.default_flag_at = None; + if let Some(default) = self.cmd.default_subcommand { + if self.cmd.args_conflicts_with_subcommands && self.command_arg_found { + return Some(Err(Error::SubcommandConflict { + subcommand: default, + })); + } + self.default_taken = true; + return Some(self.descend(default).map(|()| Event::Command(default))); + } + } if let Some(clause) = self.pending_clause_boundary.take() { self.arg_pos = 0; self.arg_taken = 0; diff --git a/argv/src/spec.rs b/argv/src/spec.rs index fad7fe3e4..ad67cabad 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -1593,6 +1593,9 @@ impl Spec<'_> { if let Some(default_subcommand) = self.default_subcommand { prop(out, "default_subcommand", default_subcommand)?; } + if self.root.cmd.default_subcommand_flags { + writeln!(out, "default_subcommand_flags #true")?; + } if self.multicall { writeln!(out, "multicall #true")?; } diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index 28cc50d75..85dc5d593 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -194,7 +194,27 @@ impl CompleteWord { let mut has_explicit_choices = false; // Not `available_flags`: inside a mounted command, the mounting CLI's flags stay // recognized for parsing but are not accepted there, so they must not be offered. - let flags = parsed.completion_flags(); + let mut flags = parsed.completion_flags(); + if spec.default_subcommand_flags && parsed.cmds.len() == 1 { + if let Some(default) = spec + .default_subcommand + .as_deref() + .and_then(|name| spec.cmd.find_subcommand(name)) + { + for flag in &default.flags { + let flag = Arc::new(flag.clone()); + for key in flag + .long + .iter() + .map(|name| format!("--{name}")) + .chain(flag.short.iter().map(|name| format!("-{name}"))) + .chain(flag.negate.iter().cloned()) + { + flags.entry(key).or_insert_with(|| Arc::clone(&flag)); + } + } + } + } // An explicit `--` stops the parser reading flags, so past one there is no such thing // as a flag to complete — a dash-prefixed word is a positional value. let restart_seen = parsed.tokens.iter().any(|token| { diff --git a/cli/src/cli/lint.rs b/cli/src/cli/lint.rs index de9d85c45..a70edaff7 100644 --- a/cli/src/cli/lint.rs +++ b/cli/src/cli/lint.rs @@ -157,6 +157,15 @@ impl usage_rs::Run for Lint { pub fn lint_spec(spec: &Spec, opts: LintOptions) -> Vec { let mut issues = Vec::new(); + if spec.default_subcommand_flags && spec.default_subcommand.is_none() { + issues.push(LintIssue { + severity: Severity::Error, + code: "invalid-default-subcommand-flags".to_string(), + message: "default_subcommand_flags requires default_subcommand".to_string(), + location: None, + }); + } + // Check default_subcommand reference if let Some(default_subcmd) = &spec.default_subcommand { // Resolved the way a typed word is, rather than by canonical key alone: the name may diff --git a/conformance/src/argv.rs b/conformance/src/argv.rs index 526a1c065..9e352f9d9 100644 --- a/conformance/src/argv.rs +++ b/conformance/src/argv.rs @@ -89,6 +89,7 @@ pub fn run(vector: &Vector) -> Outcome { .or_else(|| subcommands().find(|sub| sub.aliases.contains(&name))); Box::leak(Box::new(Command { default_subcommand: default, + default_subcommand_flags: spec.default_subcommand_flags, ..*root })) } diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 3ee2a1e15..d49bf85e1 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -133,6 +133,7 @@ pub fn build( // Both filled in by the caller for the root, which is the only place a spec declares // either. default_subcommand: None, + default_subcommand_flags: false, version: false, disable_help_flag: cmd.disable_help_flag, disable_help_subcommand: cmd.disable_help_subcommand, @@ -268,6 +269,11 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { // describing a flag that never binds. let root_cmd: &'static Command<'static> = Box::leak(Box::new(Command { version: spec.version.is_some(), + default_subcommand_flags: spec.default_subcommand_flags, + default_subcommand: spec + .default_subcommand + .as_deref() + .map(|name| usage_argv::find_subcommand(root.cmd.subcommands, name)), ..*root.cmd })); let mut root_examples = root.meta.examples.to_vec(); diff --git a/conformance/tests/default_subcommand_flags.rs b/conformance/tests/default_subcommand_flags.rs new file mode 100644 index 000000000..68293a3a8 --- /dev/null +++ b/conformance/tests/default_subcommand_flags.rs @@ -0,0 +1,85 @@ +use std::ffi::OsStr; +use usage_derive::{Args, Cli, Subcommands}; + +#[derive(Cli)] +#[usage( + bin = "em", + default_subcommand = "install", + default_subcommand_flags, + unknown_flags = "error" +)] +struct Em { + #[usage(short = 'p')] + pretend: bool, + #[usage(subcommand)] + command: Option, +} + +#[derive(Subcommands)] +enum Commands { + Install(Install), + Query, +} + +#[derive(Args)] +struct Install { + #[usage(short = 'u')] + update: bool, + #[usage(short = 'a')] + ask: bool, + package: Option, +} + +#[test] +fn default_flags_bind_to_the_derived_child_and_parent_prefix_keeps_its_owner() { + let parsed = Em::parse_from(&["-p", "-ua", "@world"].map(OsStr::new)).unwrap(); + assert!(parsed.pretend); + let Some(Commands::Install(install)) = parsed.command else { + panic!("expected install") + }; + assert!(install.update && install.ask); + assert_eq!(install.package.as_deref(), Some("@world")); +} + +#[test] +fn parent_help_and_explicit_siblings_keep_their_meaning() { + let parsed = Em::parse_from(&[OsStr::new("-p")]).unwrap(); + assert!(parsed.pretend); + assert!(parsed.command.is_none()); + let parsed = Em::parse_from(&[OsStr::new("query")]).unwrap(); + assert!(matches!(parsed.command, Some(Commands::Query))); + assert!(Em::parse_from(&["-u", "query"].map(OsStr::new)).is_err()); + let Err(usage_argv::Error::Help { cmd, .. }) = Em::parse_from(&[OsStr::new("--help")]) else { + panic!("expected parent help") + }; + assert_eq!(cmd.name, "em"); + let Err(usage_argv::Error::Help { cmd, .. }) = + Em::parse_from(&["-u", "--help"].map(OsStr::new)) + else { + panic!("expected child help") + }; + assert_eq!(cmd.name, "install"); +} + +#[test] +fn the_opt_in_survives_derive_kdl_and_json_roundtrips() { + let kdl = Em::to_kdl(); + assert!(kdl.contains("default_subcommand_flags #true"), "{kdl}"); + let spec: usage::Spec = kdl.parse().unwrap(); + assert!(spec.default_subcommand_flags); + let json = serde_json::to_string(&spec).unwrap(); + let json: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(json["default_subcommand_flags"], true); + let again: usage::Spec = spec.to_string().parse().unwrap(); + assert!(again.default_subcommand_flags); +} + +#[test] +fn an_explicit_false_overrides_an_enabled_spec() { + let mut spec: usage::Spec = Em::to_kdl().parse().unwrap(); + let disabled: usage::Spec = "default_subcommand_flags #false".parse().unwrap(); + spec.merge(disabled); + assert!(!spec.default_subcommand_flags); + let roundtrip: usage::Spec = spec.to_string().parse().unwrap(); + assert!(!roundtrip.default_subcommand_flags); +} diff --git a/corpus/16-default-subcommand-flags.json b/corpus/16-default-subcommand-flags.json new file mode 100644 index 000000000..bb1501e02 --- /dev/null +++ b/corpus/16-default-subcommand-flags.json @@ -0,0 +1,404 @@ +{ + "section": "default-subcommand-flags", + "about": "Opt-in lookahead for default-command flag prefixes. Parent flags before the first default-only flag retain their bindings; the implicit boundary otherwise has normal subcommand scope.", + "vectors": [ + { + "id": "default-flags-bundle", + "doc": "Default-only bundled flags select the default before binding.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-ua", "@world"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "update": true, + "ask": true + }, + "args": { + "package": "@world" + } + } + } + }, + { + "id": "default-flags-single", + "doc": "A default-only flag needs no selector word.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-r"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "remove": true + } + } + } + }, + { + "id": "default-flags-long", + "doc": "Long flags can select the default.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--update", "firefox"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "update": true + }, + "args": { + "package": "firefox" + } + } + } + }, + { + "id": "default-flags-parent", + "doc": "Parent-only flags do not select a default.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-p"], + "expect": { + "ok": { + "cmd": [], + "flags": { + "pretend": true + } + } + } + }, + { + "id": "default-flags-bare", + "doc": "An empty invocation stays on the parent.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": [], + "expect": { + "ok": { + "cmd": [] + } + } + }, + { + "id": "default-flags-parent-prefix", + "doc": "Local parent flags before the implicit boundary keep their owner.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-p", "-u", "firefox"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "pretend": true, + "update": true + }, + "args": { + "package": "firefox" + } + } + } + }, + { + "id": "default-flags-parent-word", + "doc": "Ordinary word fallback retains the existing parent prefix.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-p", "firefox"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "pretend": true + }, + "args": { + "package": "firefox" + } + } + } + }, + { + "id": "default-flags-global-bundle", + "doc": "A bundle may mix inherited globals with default flags.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-vu", "firefox"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "verbose": true, + "update": true + }, + "args": { + "package": "firefox" + } + } + } + }, + { + "id": "default-flags-sibling", + "doc": "An explicit sibling keeps ordinary parsing.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-p", "query"], + "expect": { + "ok": { + "cmd": ["query"], + "flags": { + "pretend": true + } + } + } + }, + { + "id": "default-flags-sibling-wins", + "doc": "A sibling prevents default routing; its prefix still rejects a default-only flag.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-u", "query"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-alias-wins", + "doc": "Sibling aliases have the same precedence as canonical names.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-u", "q"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-explicit", + "doc": "Explicit default selection still works.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["install", "-ua", "@world"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "update": true, + "ask": true + }, + "args": { + "package": "@world" + } + } + } + }, + { + "id": "default-flags-explicit-prefix", + "doc": "Opting in does not legalize child-only flags before an explicit command.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-u", "install"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-parent-value", + "doc": "A parent flag value spelling a sibling is skipped.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--config", "query", "-r"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "config": "query", + "remove": true + } + } + } + }, + { + "id": "default-flags-parent-attached-value", + "doc": "Attached parent values do not become selectors.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-cquery", "-r"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "config": "query", + "remove": true + } + } + } + }, + { + "id": "default-flags-child-value", + "doc": "A default flag value spelling a sibling is skipped.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--output", "query"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "output": "query" + } + } + } + }, + { + "id": "default-flags-child-attached-value", + "doc": "Attached default values do not become selectors.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--output=query"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "output": "query" + } + } + } + }, + { + "id": "default-flags-value-then-sibling", + "doc": "A real selector after a default flag value still wins.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--output", "query", "search"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-separator", + "doc": "A separator ends selector lookahead after a default flag.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-u", "--", "query"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "update": true + }, + "args": { + "package": "query" + } + } + } + }, + { + "id": "default-flags-separator-first", + "doc": "A leading separator does not select the default.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--", "-u"], + "expect": { + "error": "unexpected_arg" + } + }, + { + "id": "default-flags-unknown", + "doc": "An unknown flag does not gain permission to select a command.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--wat"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-off", + "doc": "The new behavior is opt-in.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #false\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-ua", "@world"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-alias-default", + "doc": "The configured default may use an alias.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"i\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-r"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "remove": true + } + } + } + }, + { + "id": "default-flags-negation", + "doc": "Negated default flags also select the default.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n flag \"--color\" negate=\"--no-color\"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--no-color"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "color": false + } + } + } + }, + { + "id": "default-flags-optional-equals", + "doc": "An optional equals-only value leaves a sibling selector visible.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n flag \"--color \" require_equals=#true default_missing=\"auto\"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--color", "query"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-optional-bare", + "doc": "An optional flag can select the default without a value.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n flag \"--color \" require_equals=#true default_missing=\"auto\"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--color"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "color": "auto" + } + } + } + }, + { + "id": "default-flags-missing-value", + "doc": "A missing default flag value is diagnosed in the selected command.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n \n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--output"], + "expect": { + "error": "missing_flag_value" + } + }, + { + "id": "default-flags-variadic", + "doc": "All variadic values are skipped when looking for a selector.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n flag \"--include ...\"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--include", "one", "query"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "include": ["one", "query"] + } + } + } + }, + { + "id": "default-flags-variadic-limit", + "doc": "A sibling after a bounded variadic value remains a selector.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n flag \"--include ...\" { arg \"...\" var_max=1; }\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--include", "one", "query"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-variadic-terminator", + "doc": "A value terminator exposes the following sibling selector.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n flag \"--include ...\" { arg \"...\" value_terminator=\"END\"; }\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--include", "one", "END", "query"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-negative-value", + "doc": "An opted-in negative value is skipped before a sibling selector.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n flag \"--offset \" { arg \"\" allow_negative_numbers=#true; }\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--offset", "-2", "query"], + "expect": { + "error": "unknown_flag" + } + } + ] +} diff --git a/corpus/complete/04-default-subcommand-flags.json b/corpus/complete/04-default-subcommand-flags.json new file mode 100644 index 000000000..ae6b72fb6 --- /dev/null +++ b/corpus/complete/04-default-subcommand-flags.json @@ -0,0 +1,42 @@ +{ + "section": "default-subcommand-flags", + "about": "Completion follows the same opt-in default route as invocation parsing.", + "vectors": [ + { + "id": "default-flags-complete-argument", + "doc": "Completion follows implicit default routing for argument candidates.", + "spec": "name \"em\"\nbin \"em\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\ncmd \"install\" {\n flag \"-u --update\"\n flag \"--output \" { choices \"local\" \"remote\"; }\n arg \"[package]\" { choices \"firefox\" \"fish\"; }\n}\ncmd \"query\" {}\n", + "line": "em -u fi", + "expect": { + "candidates": ["firefox", "fish"] + } + }, + { + "id": "default-flags-complete-value", + "doc": "Completion follows implicit default routing for value candidates.", + "spec": "name \"em\"\nbin \"em\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\ncmd \"install\" {\n flag \"-u --update\"\n flag \"--output \" { choices \"local\" \"remote\"; }\n arg \"[package]\" { choices \"firefox\" \"fish\"; }\n}\ncmd \"query\" {}\n", + "line": "em --output lo", + "expect": { + "candidates": ["local"] + } + }, + { + "id": "default-flags-complete-flag", + "doc": "Completion follows implicit default routing for flag candidates.", + "spec": "name \"em\"\nbin \"em\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\ncmd \"install\" {\n flag \"-u --update\"\n flag \"--output \" { choices \"local\" \"remote\"; }\n arg \"[package]\" { choices \"firefox\" \"fish\"; }\n}\ncmd \"query\" {}\n", + "line": "em -u --out", + "expect": { + "candidates": ["--output"] + } + }, + { + "id": "default-flags-complete-prefix", + "doc": "The default flags are offered before a command has been selected.", + "spec": "name \"em\"\nbin \"em\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\ncmd \"install\" {\n flag \"-u --update\"\n flag \"--output \" { choices \"local\" \"remote\"; }\n arg \"[package]\" { choices \"firefox\" \"fish\"; }\n}\ncmd \"query\" {}\n", + "line": "em --up", + "expect": { + "candidates": ["--update"] + } + } + ] +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 6dce135bf..803840681 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -189,6 +189,7 @@ pub fn emit(cli: &Cli) -> TokenStream { let unknown_flags = unknown_flags_tokens(cli); let default_subcommand = option_str(cli.default_subcommand.as_deref()); + let default_subcommand_flags = cli.default_subcommand_flags; let multicall = cli.multicall; let no_binary_name = cli.no_binary_name; let arg_required_else_help = cli.arg_required_else_help; @@ -1105,6 +1106,7 @@ pub fn emit(cli: &Cli) -> TokenStream { clause: #clause_table, #sub_commands #sub_default + default_subcommand_flags: #default_subcommand_flags, #sub_external ..usage_argv::Command::EMPTY }; diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 1714eb79e..a702c2f70 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -251,7 +251,7 @@ //! `visible_alias(es)`, hidden `alias(es)`, and `hide` may be declared on an //! `Args` struct and are inherited by every subcommand variant that mounts it — //! `verbatim_doc_comment` — preserve doc-comment line breaks and whitespace — -//! `default_subcommand`, `multicall` — argv[0]'s basename selects a subcommand — +//! `default_subcommand`, `default_subcommand_flags`, `multicall` — argv[0]'s basename selects a subcommand — //! `arg_required_else_help` — a selected command with no argv of its own shows short help — //! `disable_help_flag`, `disable_help_subcommand`, and `disable_version_flag` — remove the //! corresponding synthesized entry point so a field with `action = usage::ArgAction::Help`, diff --git a/derive/src/model.rs b/derive/src/model.rs index ef1944665..e11026abe 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -163,6 +163,7 @@ pub struct Cli { /// /// Only the root has one, and it is what mise sets by hand on the emitted spec today. pub default_subcommand: Option, + pub default_subcommand_flags: bool, /// Whether argv[0]'s basename selects a subcommand (busybox-style applets). /// /// clap's `multicall`. Only the root has one: a spec declares it once, for the @@ -830,6 +831,7 @@ impl Cli { source_code_link_template: None, unknown_flags: None, default_subcommand: None, + default_subcommand_flags: false, multicall: false, no_binary_name: false, arg_required_else_help: false, @@ -1071,6 +1073,7 @@ impl Cli { "default_subcommand" => { cli.default_subcommand = Some(strip_dashes(&string_value(&meta)?)) } + "default_subcommand_flags" => cli.default_subcommand_flags = flag_value(&meta)?, "multicall" => cli.multicall = flag_value(&meta)?, "no_binary_name" => cli.no_binary_name = flag_value(&meta)?, "arg_required_else_help" => cli.arg_required_else_help = flag_value(&meta)?, @@ -1181,7 +1184,7 @@ impl Cli { format!( "unknown option `{other}` on a struct; usage::Cli takes \ `name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `long_version`, `long_version_spec`, `author`, `license`, `repository`, `source_code_link_template`, `usage`, `alias`, `alias_hidden`, `visible_alias`, `hide`, `surface`, `available_if`, `deprecated`, `deprecated_warn_at`, `deprecated_remove_at`, `verbatim_doc_comment`, `unknown_flags`, \ - `default_subcommand`, `multicall`, `no_binary_name`, `arg_required_else_help`, `disable_help_flag`, `disable_help_subcommand`, `disable_version_flag`, `dont_delimit_trailing_values`, `args_override_self`, `subcommand_negates_reqs`, `args_conflicts_with_subcommands`, `subcommand_precedence_over_arg`, `allow_missing_positional`, \ + `default_subcommand`, `default_subcommand_flags`, `multicall`, `no_binary_name`, `arg_required_else_help`, `disable_help_flag`, `disable_help_subcommand`, `disable_version_flag`, `dont_delimit_trailing_values`, `args_override_self`, `subcommand_negates_reqs`, `args_conflicts_with_subcommands`, `subcommand_precedence_over_arg`, `allow_missing_positional`, \ `next_help_heading`, `subcommand_help_heading`, `next_line_help`, `flatten_help`, `help_template`, `term_width`, `max_term_width`, \ `subcommand_value_name`, `restart_token`, `mount`, `example`, `heading`, `select`, `output`, `exit_code`, `run`, `run_with`, `run_async`, `run_async_with`, \ `group`, `view`, `validate_with`, and `try_into` here, and the description comes from the doc comment" @@ -1456,7 +1459,7 @@ impl Cli { )); } // A spec declares one `default_subcommand`, at the top. - if self.default_subcommand.is_some() { + if self.default_subcommand.is_some() || self.default_subcommand_flags { return Err(self.misplaced( ident, "`default_subcommand` belongs on the root, where `#[derive(Cli)]` is: a \ @@ -1543,6 +1546,12 @@ impl Cli { } } + if self.default_subcommand_flags && self.default_subcommand.is_none() { + return Err(self.misplaced( + ident, + "`default_subcommand_flags` requires `default_subcommand`", + )); + } if self.default_subcommand.is_some() && !self .fields @@ -9404,6 +9413,18 @@ mod tests { assert!(err.contains("not supported"), "unhelpful message: {err}"); } + #[test] + fn flag_routing_requires_a_default_subcommand() { + let err = position_error( + r#" + #[usage(bin = "ex", default_subcommand_flags)] + struct Ex {} + "#, + true, + ); + assert!(err.contains("requires `default_subcommand`"), "{err}"); + } + #[test] fn a_default_subcommand_needs_subcommands_to_name() { let err = position_error( diff --git a/docs/spec/argv.md b/docs/spec/argv.md index 41a95bad7..52ed6abe5 100644 --- a/docs/spec/argv.md +++ b/docs/spec/argv.md @@ -98,8 +98,9 @@ and so name flags that do not exist. ## Reading a command line -Tokens are read once, left to right. There is no backtracking, no reordering, and -no second pass: what a token binds to is decided when it is read, from the +With `default_subcommand_flags #true`, a read-only lookahead first chooses the +implicit default-command boundary (see [default-command flags](./reference/index.md#default-command-flags)). +Binding then reads tokens once, left to right, with no backtracking or reordering: what a token binds to is decided when it is read, from the command in scope at that moment. This is what makes the grammar implementable as a single loop, and it is also why a `--` or a subcommand word changes the meaning of everything after it but nothing before it. diff --git a/docs/spec/reference/index.md b/docs/spec/reference/index.md index 277be655f..5b6223fda 100644 --- a/docs/spec/reference/index.md +++ b/docs/spec/reference/index.md @@ -145,6 +145,37 @@ recognition of `-h`, `--help`, `-?`, and `help`; use the narrower `disable_help_flag` or `disable_help_subcommand` command policies when only one entry point should be removed. +### Default-command flags + +Opt in with `default_subcommand_flags #true` to allow the default command's +flags before its first argument: + +```kdl +default_subcommand "install" +default_subcommand_flags #true +cmd "install" { + flag "-u --update" + flag "-a --ask" + arg "[package]" +} +cmd "query" {} +``` + +`em -ua @world` then means `em install -ua @world`. The parser looks past +recognized parent/default flags and their values for an explicit command name +or alias. An explicit sibling keeps ordinary parsing: `em -u query` still +rejects `-u` on the parent. Parent-only flags, including `--help`, and an empty +invocation stay on the parent. + +The implicit command boundary is immediately before the first default-only +flag. Parent flags before it retain their bindings; flags after it use the +normal child/global scope. A shared flag spelling is interpreted using the +parent declaration during lookahead. `--` ends lookahead, and an unknown flag +stops it because its value arity is unknown. Without this opt-in, default +routing continues to happen only at an unmatched word. + +Rust derives use `#[usage(default_subcommand = "install", default_subcommand_flags)]`. + ## Multicall `multicall #true` is clap's busybox-style applets: argv[0]'s basename selects a diff --git a/go/argv/argv.go b/go/argv/argv.go index a732757b0..5bb41826f 100644 --- a/go/argv/argv.go +++ b/go/argv/argv.go @@ -62,6 +62,8 @@ type Command struct { // Applied at most once per parse, so a CLI cannot loop through it, and only // where a subcommand could still be selected. DefaultSubcommand *Command + // DefaultSubcommandFlags opts in to flag-prefix default routing. + DefaultSubcommandFlags bool // ExternalSubcommand is whether an unmatched word is forwarded as an external // command plus the rest of argv. // diff --git a/go/argv/complete.go b/go/argv/complete.go index 446bb8276..74738d78b 100644 --- a/go/argv/complete.go +++ b/go/argv/complete.go @@ -308,6 +308,11 @@ func flagsInScope(chain []*Command) []inScope { for _, f := range chain[len(chain)-1].Flags { offer(f) } + if len(chain) == 1 && chain[0].DefaultSubcommandFlags && chain[0].DefaultSubcommand != nil { + for _, f := range chain[0].DefaultSubcommand.Flags { + offer(f) + } + } for i := len(chain) - 2; i >= 0; i-- { for _, f := range chain[i].Flags { if f.Global { diff --git a/go/argv/complete_test.go b/go/argv/complete_test.go index 7b6a33bab..c28444a62 100644 --- a/go/argv/complete_test.go +++ b/go/argv/complete_test.go @@ -469,3 +469,26 @@ func count(list []string, want string) int { } return n } + +func TestCompletionFollowsDefaultFlagRouting(t *testing.T) { + root, help, meta := completionFixture() + root.DefaultSubcommand = root.Subcommands[0] + root.DefaultSubcommandFlags = true + for _, tc := range []struct { + words []string + partial, want string + }{ + {nil, "--she", "--shell"}, + {[]string{"--shell"}, "ba", "bash"}, + {[]string{"--shell", "bash"}, "fa", "fast"}, + } { + got := values(Candidates(Walk(root, tc.words), tc.partial, help, meta)) + if !offered(got, tc.want) { + t.Errorf("%v %s: want %s, got %v", tc.words, tc.partial, tc.want, got) + } + } + root.DefaultSubcommandFlags = false + if got := values(Candidates(Walk(root, nil), "--she", help, meta)); offered(got, "--shell") { + t.Errorf("default flags require the opt-in: %v", got) + } +} diff --git a/go/argv/parser.go b/go/argv/parser.go index 9ee5f84b9..f2a137805 100644 --- a/go/argv/parser.go +++ b/go/argv/parser.go @@ -1,8 +1,14 @@ package argv +import ( + "slices" + "strings" +) + // Parser reads a command line once, left to right, against static tables. // -// There is no backtracking, no reordering, and no second pass: what a token binds +// With DefaultSubcommandFlags enabled, a read-only lookahead chooses the implicit +// command boundary first. Binding itself has no backtracking or reordering: what a token binds // to is decided when it is read, from the command in scope at that moment. That // is what makes the grammar a single loop, and also why a -- or a subcommand word // changes the meaning of everything after it and nothing before it. @@ -82,7 +88,8 @@ type Parser struct { // defaultTaken records whether the default subcommand has been used, which may // happen at most once: a default that itself declares one would otherwise // descend on every word until the tree ran out. - defaultTaken bool + defaultTaken bool + defaultFlagAt int // done stops iteration, set when argv runs out or an error is reported. done bool @@ -95,11 +102,132 @@ type Parser struct { // New begins parsing argv against root. argv excludes the program name. func New(root *Command, argv []string) Parser { - return Parser{ + p := Parser{ + defaultFlagAt: -1, argv: argv, cmd: root, dontDelimitTrailingValues: root.DontDelimitTrailingValues, } + if root.DefaultSubcommandFlags { + p.defaultFlagAt = p.defaultFlagRoute() + } + return p +} + +// defaultFlagRoute chooses an implicit boundary without binding any flags. A +// sibling selector wins, and a flag's values are never mistaken for selectors. +func (p *Parser) defaultFlagRoute() int { + d := p.cmd.DefaultSubcommand + if d == nil { + return -1 + } + at := -1 + for i := 0; i < len(p.argv); { + token := p.argv[i] + if token == "--" || token == "-" { + return at + } + if !isFlagLike(token) { + if p.findSubcommand(token) != nil || (token == "help" && !p.cmd.DisableHelpSubcommand) { + return -1 + } + return at + } + var valueFlag *Flag + var attached string + hasAttached := false + if strings.HasPrefix(token, "--") { + name, value, has := strings.Cut(token[2:], "=") + parent := p.findLong(name) + if parent == nil { + parent = p.findNegation(name) + } + if parent == nil && ((name == "help" && !p.cmd.DisableHelpFlag) || (name == "version" && p.cmd.Version && !p.cmd.DisableVersionFlag)) { + i++ + continue + } + flag := parent + if flag == nil { + for _, f := range d.Flags { + if slices.Contains(f.Longs, name) || (f.Negate != "" && f.Negate == name) { + flag = f + break + } + } + } + if flag == nil { + return at + } + if parent == nil && at < 0 { + at = i + } + if flag.TakesValue && flag.Negate != name { + valueFlag, attached, hasAttached = flag, value, has + } + } else { + for j := 1; j < len(token); j++ { + parent := p.findShort(token[j]) + flag := parent + if flag == nil { + for _, f := range d.Flags { + if slices.Contains(f.Shorts, token[j]) { + flag = f + break + } + } + } + if flag == nil { + return at + } + if parent == nil && at < 0 { + at = i + } + if flag.TakesValue { + valueFlag = flag + attached = strings.TrimPrefix(token[j+1:], "=") + hasAttached = j+1 < len(token) + break + } + } + } + i++ + if f := valueFlag; f != nil { + if !hasAttached && !f.RequireEquals && i < len(p.argv) { + next := p.argv[i] + if f.AllowHyphenValues || !isFlagLike(next) || (f.AllowNegativeNumbers && isNegativeNumber(next)) { + attached, hasAttached = next, true + i++ + } + } + if !hasAttached && !f.ValueOptional && f.DefaultMissing == "" { + return at + } + if f.Variadic { + var count uint32 + if hasAttached { + count = valuesIn(attached, f.Delimiter) + } + for i < len(p.argv) && (f.VarMax == 0 || count < f.VarMax) { + next := p.argv[i] + if next == "--" || (f.ValueTerminator != "" && next == f.ValueTerminator) { + if next != "--" { + i++ + } + break + } + if isFlagLike(next) && !(f.AllowNegativeNumbers && isNegativeNumber(next)) { + break + } + if p.cmd.SubcommandPrecedenceOverArg && p.findSubcommand(next) != nil { + return -1 + } + count += valuesIn(next, f.Delimiter) + i++ + } + } + } + } + return at } // Next reads the next event, reporting false when argv is exhausted or the parse @@ -200,6 +328,19 @@ func (p *Parser) emit(e Event) bool { } func (p *Parser) step() bool { + if p.defaultFlagAt == p.pos && len(p.bundle) == 0 { + p.defaultFlagAt = -1 + if d := p.cmd.DefaultSubcommand; d != nil { + if p.cmd.ArgsConflictWithSubcommands && p.commandArgFound { + return p.fail(Error{Code: CodeSubcommandConflict, Cmd: p.cmd}) + } + p.defaultTaken = true + if !p.descend(d) { + return false + } + return p.emit(Event{Kind: KindCommand, Command: d}) + } + } for { if p.clauseBoundaryPending { p.clauseBoundaryPending = false diff --git a/go/internal/spec/spec.go b/go/internal/spec/spec.go index 38960a6fd..75b84e001 100644 --- a/go/internal/spec/spec.go +++ b/go/internal/spec/spec.go @@ -37,7 +37,8 @@ type Spec struct { UnknownFlags string `json:"unknown_flags"` // DefaultSubcommand is declared once, at the top, and names a subcommand of // the root. - DefaultSubcommand string `json:"default_subcommand"` + DefaultSubcommand string `json:"default_subcommand"` + DefaultSubcommandFlags bool `json:"default_subcommand_flags"` // Multicall is whether argv[0]'s basename selects a subcommand (busybox-style // applets). clap's multicall. The dispatcher names (Name / Bin) are skipped; // any other basename is parsed as the first word. @@ -523,6 +524,7 @@ func (s *Spec) BuildAll() (*argv.Command, argv.Metadata, argv.HelpTable) { // Names before aliases, the same precedence a typed word gets: a command's own // name outranks another command's alias, so this does not depend on the order // the spec declares them in. + root.DefaultSubcommandFlags = s.DefaultSubcommandFlags if s.DefaultSubcommand != "" { for _, sub := range root.Subcommands { if sub.Name == s.DefaultSubcommand { diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index 5cd0b8f60..fc93de2fc 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -491,6 +491,9 @@ impl<'a> Emitter<'a> { )); } if e.root { + if self.spec.default_subcommand_flags { + lines.push(Line::Field("DefaultSubcommandFlags".into(), "true".into())); + } if let Some(var) = &default_subcommand { lines.push(Line::Field("DefaultSubcommand".into(), var.clone())); } @@ -1859,6 +1862,14 @@ cmd "macos" { insta::assert_snapshot!(out); } + #[test] + fn default_flag_routing_reaches_generated_go_tables() { + let out = go( + "name ex\nbin ex\ndefault_subcommand run\ndefault_subcommand_flags #true\ncmd run {}\n", + ); + assert!(out.contains("DefaultSubcommandFlags: true"), "{out}"); + } + #[test] fn a_default_subcommand_points_into_the_tree() { let out = go(r#" diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 06b16c90d..61c01f5c6 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1311,6 +1311,119 @@ fn parse_partial_with_env( ) } +/// Locate the first default-only flag, while preserving an explicit sibling selector. +/// This only reads declarations: it neither binds values nor executes mount commands. +fn default_flag_route(spec: &Spec, root: &SpecCommand, input: &VecDeque) -> Option { + let default = root.find_subcommand(spec.default_subcommand.as_deref()?)?; + let parent = gather_flags(root); + let child = gather_flags(default); + let path = std::slice::from_ref(root); + let mut at = None; + let mut i = 0; + while let Some(token) = input.get(i).map(|t| t.word.as_str()) { + if token == "--" || token == "-" { + return at; + } + if !is_flag_like(token) { + return if root.find_subcommand(token).is_some() + || (token == "help" + && spec.disable_help != Some(true) + && !root.disable_help_subcommand) + { + None + } else { + at + }; + } + let mut value_flag = None; + let mut attached = None; + if token.starts_with("--") { + let (name, value) = token + .split_once('=') + .map_or((token, None), |(n, v)| (n, Some(v))); + if !parent.contains_key(name) + && (is_help_arg(spec, root, name) || is_version_arg(spec, path, name)) + { + i += 1; + continue; + } + let flag = parent.get(name).or_else(|| child.get(name)); + let Some(flag) = flag else { + return at; + }; + if !parent.contains_key(name) { + at.get_or_insert(i); + } + if flag.arg.is_some() && flag.negate.as_deref() != Some(name) { + value_flag = Some(flag); + attached = value; + } + } else { + for (offset, letter) in token.char_indices().skip(1) { + let key = format!("-{letter}"); + if !parent.contains_key(&key) && supplied_short(spec, path, letter).is_some() { + continue; + } + let flag = parent.get(&key).or_else(|| child.get(&key)); + let Some(flag) = flag else { + return at; + }; + if !parent.contains_key(&key) { + at.get_or_insert(i); + } + if flag.arg.is_some() { + value_flag = Some(flag); + let rest = &token[offset + letter.len_utf8()..]; + attached = (!rest.is_empty()).then_some(rest.strip_prefix('=').unwrap_or(rest)); + break; + } + } + } + i += 1; + if let Some(flag) = value_flag { + let first = if attached.is_some() { + attached + } else { + input + .get(i) + .map(|t| t.word.as_str()) + .filter(|next| accepts_detached_flag_value(flag, next)) + .inspect(|_| i += 1) + }; + if first.is_none() && !flag.value_optional && flag.default_missing.is_none() { + return at; + } + let arg = flag.arg.as_ref().expect("value flag"); + if arg.var { + let count_values = |v: &str| arg.delimiter.map_or(1, |d| v.split(d).count()); + let mut count = first.map_or(0, count_values); + while !arg.var_max.is_some_and(|max| count >= max) { + let Some(next) = input.get(i).map(|t| t.word.as_str()) else { + break; + }; + if next == "--" || arg.value_terminator.as_deref() == Some(next) { + if next != "--" { + i += 1; + } + break; + } + if is_flag_like(next) + && !(arg.allow_negative_numbers && is_negative_number(next)) + { + break; + } + if root.subcommand_precedence_over_arg && root.find_subcommand(next).is_some() { + return None; + } + count += count_values(next); + i += 1; + } + } + } + } + at +} + /// The binding phase, with the trace left somewhere the caller can still read it. /// /// A failure this phase cannot continue past — a word no declaration can take, a flag a @@ -1440,6 +1553,11 @@ fn parse_partial_traced( out.cmd = mounted; } + let default_flag_at = spec + .default_subcommand_flags + .then(|| default_flag_route(spec, &out.cmd, &input)) + .flatten(); + while idx < input.len() { // Only for a word that could name a command, and only when it matches // nothing already declared. A CLI that declares its commands and mounts more @@ -1476,7 +1594,14 @@ fn parse_partial_traced( { break; } - if let Some(subcommand) = out.cmd.find_subcommand(&input[idx].word) { + let implicit_default = !used_default_subcommand && default_flag_at == Some(idx); + let selected_command = if implicit_default { + out.cmd + .find_subcommand(spec.default_subcommand.as_deref().expect("default route")) + } else { + out.cmd.find_subcommand(&input[idx].word) + }; + if let Some(subcommand) = selected_command { if out.cmd.args_conflicts_with_subcommands && command_arg_found { bail!( "subcommand '{}' cannot be used with arguments on its parent command", @@ -1495,7 +1620,12 @@ fn parse_partial_traced( crossing_mount, ); // Remove subcommand from input - let selected = input.remove(idx); + let selected = if implicit_default { + used_default_subcommand = true; + None + } else { + input.remove(idx) + }; if let Some(selected) = selected { trace.record( selected.argv, @@ -1686,6 +1816,36 @@ fn parse_partial_traced( // following word; `-i9229` and `-i=9229` still bind. `default_missing` binds // only when the value is actually missing, so `-cnever` is still `never`. let attached_continuation = grouped_flag; + // The opt-in lookahead uses declared value arity, so binding must consume + // the same attached value even after the default changed flag scope. + // Other specs retain the reference parser's legacy short-bundle rules. + if spec.default_subcommand_flags + && attached_continuation + && !out.flag_awaiting_value.is_empty() + { + grouped_flag = false; + w.remove(0); + if w.starts_with('=') { + w.remove(0); + } + if bind_pending_flag_value( + spec, + &out.cmd, + &mut out.errors, + &mut out.flags, + &mut out.flag_awaiting_value, + &mut w, + &mut input, + custom_env, + trace, + argv, + true, + )? { + record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input); + return Ok((out, overridden_flags)); + } + continue; + } // A clause boundary is syntax even after an automatic trailing argument disabled // flags. Only an explicit `--` protects a literal separator. diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index 8338f91d6..62c25e61a 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -136,6 +136,11 @@ pub struct Spec { /// This enables "naked" command syntax like `mise foo` instead of `mise run foo`. #[serde(skip_serializing_if = "Option::is_none")] pub default_subcommand: Option, + /// Opt in to routing flags of the default command before its first positional. + #[serde(skip_serializing_if = "is_false")] + pub default_subcommand_flags: bool, + #[serde(skip)] + pub default_subcommand_flags_set: bool, /// Whether argv[0]'s basename selects a subcommand (busybox-style applets). /// /// clap's `multicall`. The dispatcher names ([`Self::name`] and [`Self::bin`]) @@ -448,6 +453,8 @@ impl Spec { spec.complete = complete; spec.cmd = promoted; spec.default_subcommand = None; + spec.default_subcommand_flags = false; + spec.default_subcommand_flags_set = false; spec.multicall = false; spec.multicall_set = false; spec.views.clear(); @@ -621,6 +628,10 @@ impl Spec { "default_subcommand" => { schema.default_subcommand = Some(node.arg(0)?.ensure_string()?) } + "default_subcommand_flags" => { + schema.default_subcommand_flags = node.arg(0)?.ensure_bool()?; + schema.default_subcommand_flags_set = true; + } "multicall" => { schema.multicall = node.arg(0)?.ensure_bool()?; schema.multicall_set = true; @@ -876,6 +887,10 @@ impl Spec { merge_opt!(disable_help); merge_opt!(min_usage_version); merge_opt!(default_subcommand); + if other.default_subcommand_flags_set { + self.default_subcommand_flags = other.default_subcommand_flags; + self.default_subcommand_flags_set = true; + } if other.multicall_set { self.multicall = other.multicall; self.multicall_set = true; @@ -1167,6 +1182,11 @@ impl Display for Spec { node.push(string_entry(None, default_subcommand)); nodes.push(node); } + if self.default_subcommand_flags_set || self.default_subcommand_flags { + let mut node = KdlNode::new("default_subcommand_flags"); + node.push(KdlEntry::new(self.default_subcommand_flags)); + nodes.push(node); + } if self.multicall_set { let mut node = KdlNode::new("multicall"); node.push(KdlEntry::new(self.multicall)); diff --git a/xtask/src/shadow.rs b/xtask/src/shadow.rs index 61f27fd0c..1c18fe2f1 100644 --- a/xtask/src/shadow.rs +++ b/xtask/src/shadow.rs @@ -162,6 +162,7 @@ fn render(spec: &Spec, spec_path: &Path, dialect: Dialect) -> (String, Skipped) version: spec.version.as_deref(), unknown_flags: spec.unknown_flags.as_ref().map(|mode| mode.as_str()), default_subcommand: spec.default_subcommand.as_deref(), + default_subcommand_flags: spec.default_subcommand_flags, multicall: spec.multicall, about: spec.about.as_deref(), about_long: spec.about_long.as_deref(), @@ -292,6 +293,7 @@ struct Run<'a> { unknown_flags: Option<&'a str>, /// Only the root has one, and only it declares it. default_subcommand: Option<&'a str>, + default_subcommand_flags: bool, /// Busybox-style applets: argv[0]'s basename selects a subcommand. multicall: bool, /// The spec's own description, which belongs to the root. @@ -496,6 +498,11 @@ fn emit_command(out: &mut String, cmd: &SpecCommand, ty: &Type, is_root: bool, r } for (present, declaration, what) in [ + ( + is_root && run.default_subcommand_flags, + Some("default_subcommand_flags".to_string()), + "`default_subcommand_flags` on a command", + ), ( is_root && run.default_subcommand.is_some(), run.default_subcommand From 159f8fea954aa92f23eb53dfc50bc61cd4087888 Mon Sep 17 00:00:00 2001 From: Jeff Dickey <216188+jdx@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:25:57 -0500 Subject: [PATCH 2/4] fix(parse): preserve parent flags in implicit default bundles Entire-Checkpoint: 01M265NN41MW8HS2KZFHQV0QPR --- argv/src/lib.rs | 66 +++++++- conformance/tests/default_subcommand_flags.rs | 54 ++++++ corpus/16-default-subcommand-flags.json | 156 ++++++++++++++++++ docs/spec/reference/index.md | 3 +- go/argv/parser.go | 59 ++++++- go/argv/parser_test.go | 12 ++ lib/src/parse.rs | 110 ++++++++++-- 7 files changed, 435 insertions(+), 25 deletions(-) diff --git a/argv/src/lib.rs b/argv/src/lib.rs index 100b978cf..2deff9a87 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -1698,6 +1698,8 @@ pub struct Parser<'t, 'a, 'v> { default_taken: bool, /// First default-only flag, when lookahead found no explicit sibling. default_flag_at: Option, + /// Cursor just after the implicit boundary bundle, whose shorts keep parent ownership. + default_bundle_end: usize, /// Set once a fatal error has been reported, so iteration stops. done: bool, /// Whether declared built-in actions stop parsing with their action error. @@ -1766,6 +1768,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { separator_seen: false, default_taken: false, default_flag_at: None, + default_bundle_end: 0, done: false, action_errors, help_span: (0, 0), @@ -1785,7 +1788,11 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { let mut at = None; let mut i = 0; while let Some(token) = self.argv.get(i).map(bytes) { - if token == b"--" || token == b"-" { + let scope = if at.is_some() { default } else { self.cmd }; + if token == b"--" + || token == b"-" + || scope.clause.is_some_and(|c| c.separator == Some(token)) + { return at; } if !is_flag_like(token) { @@ -1854,6 +1861,9 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } i += 1; if let Some(flag) = value_flag { + let scope = if at.is_some() { default } else { self.cmd }; + let is_separator = + |next: &[u8]| scope.clause.is_some_and(|c| c.separator == Some(next)); let first = if let Some(value) = attached { Some(value) } else if !flag.require_equals { @@ -1861,9 +1871,10 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { .get(i) .map(bytes) .filter(|next| { - flag.allow_hyphen_values - || !is_flag_like(next) - || (flag.allow_negative_numbers && is_negative_number(next)) + !is_separator(next) + && (flag.allow_hyphen_values + || !is_flag_like(next) + || (flag.allow_negative_numbers && is_negative_number(next))) }) .inspect(|_| i += 1) } else { @@ -1874,10 +1885,13 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } if flag.variadic { let mut count = first.map_or(0, |v| values_in(v, flag.delimiter)); - while !flag.var_max.is_some_and(|max| count >= max) { + while flag.var_max.is_none_or(|max| count < max) { let Some(next) = self.argv.get(i).map(bytes) else { break; }; + if is_separator(next) { + break; + } if next == b"--" || flag.value_terminator.is_some_and(|end| end == next) { if next != b"--" { i += 1; @@ -2020,15 +2034,43 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { event } + /// Count parent flags in the shared boundary token before entering the child. + fn default_bundle_has_parent_flag(&self, default: &Command<'_>) -> bool { + let Some(token) = self.argv.get(self.pos).map(bytes) else { + return false; + }; + if !token.starts_with(b"-") || token.starts_with(b"--") { + return false; + } + for byte in &token[1..] { + if self.find_short(*byte).is_some() { + return true; + } + match default.flags.iter().find(|f| f.shorts.contains(byte)) { + Some(flag) if !flag.takes_value => {} + _ => break, + } + } + false + } + fn step(&mut self) -> Option, Error<'t, 'v>>> { if self.default_flag_at == Some(self.pos) && self.bundle.is_empty() { self.default_flag_at = None; if let Some(default) = self.cmd.default_subcommand { - if self.cmd.args_conflicts_with_subcommands && self.command_arg_found { + if self.cmd.args_conflicts_with_subcommands + && (self.command_arg_found || self.default_bundle_has_parent_flag(default)) + { return Some(Err(Error::SubcommandConflict { subcommand: default, })); } + if self.argv.get(self.pos).is_some_and(|word| { + let token = bytes(word); + token.starts_with(b"-") && !token.starts_with(b"--") + }) { + self.default_bundle_end = self.pos + 1; + } self.default_taken = true; return Some(self.descend(default).map(|()| Event::Command(default))); } @@ -2836,6 +2878,18 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } fn find_short(&self, byte: u8) -> Option<&'t Flag<'t>> { + // Only the boundary token is shared. Later tokens use ordinary child/global scope. + if self.default_taken && self.pos == self.default_bundle_end { + if let Some(flag) = self.ancestors[0].and_then(|parent| { + parent + .flags + .iter() + .copied() + .find(|f| f.shorts.contains(&byte)) + }) { + return Some(flag); + } + } self.in_scope() .find(|f| f.shorts.contains(&byte)) // As for `--help`: supplied by the parser, and only where the command has not diff --git a/conformance/tests/default_subcommand_flags.rs b/conformance/tests/default_subcommand_flags.rs index 68293a3a8..e62a74cde 100644 --- a/conformance/tests/default_subcommand_flags.rs +++ b/conformance/tests/default_subcommand_flags.rs @@ -83,3 +83,57 @@ fn an_explicit_false_overrides_an_enabled_spec() { let roundtrip: usage::Spec = spec.to_string().parse().unwrap(); assert!(!roundtrip.default_subcommand_flags); } + +/// Mixed clusters route each field to its declaring derived struct. +#[test] +fn mixed_short_bundles_keep_parent_fields_in_both_orders() { + for bundle in ["-pua", "-upa", "-uap"] { + let parsed = Em::parse_from(&[bundle, "@world"].map(OsStr::new)).unwrap(); + assert!(parsed.pretend); + let Some(Commands::Install(install)) = parsed.command else { + panic!("expected install") + }; + assert!(install.update && install.ask); + assert_eq!(install.package.as_deref(), Some("@world")); + } +} + +/// Conflict diagnostics name the selected command, not the token that implied it. +#[test] +fn implicit_conflicts_name_the_default_command() { + let kdl = Em::to_kdl(); + let mut spec: usage::Spec = kdl.parse().unwrap(); + spec.cmd.args_conflicts_with_subcommands = true; + for words in [vec!["em", "-p", "-u"], vec!["em", "-pu"], vec!["em", "-up"]] { + let error = usage::Parser::new(&spec) + .parse(&words.into_iter().map(String::from).collect::>()) + .unwrap_err(); + assert!( + error.to_string().contains("subcommand 'install'"), + "{error}" + ); + assert!(!error.to_string().contains("subcommand '-u'"), "{error}"); + } +} + +#[test] +fn mixed_bundles_honor_parent_conflicts_in_the_runtime() { + #[derive(Cli)] + #[usage( + default_subcommand = "install", + default_subcommand_flags, + args_conflicts_with_subcommands + )] + struct Conflicts { + #[usage(short = 'p')] + pretend: bool, + #[usage(subcommand)] + command: Option, + } + for bundle in ["-pu", "-up"] { + assert!(matches!( + Conflicts::parse_from(&[OsStr::new(bundle)]), + Err(usage_argv::Error::SubcommandConflict { .. }) + )); + } +} diff --git a/corpus/16-default-subcommand-flags.json b/corpus/16-default-subcommand-flags.json index bb1501e02..97a1d26e9 100644 --- a/corpus/16-default-subcommand-flags.json +++ b/corpus/16-default-subcommand-flags.json @@ -399,6 +399,162 @@ "expect": { "error": "unknown_flag" } + }, + { + "id": "default-flags-mixed-pua", + "doc": "The boundary bundle preserves parent-local shorts in either order.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-pua", "@world"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "pretend": true, + "update": true, + "ask": true + }, + "args": { + "package": "@world" + } + } + } + }, + { + "id": "default-flags-mixed-upa", + "doc": "The boundary bundle preserves parent-local shorts in either order.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-upa", "@world"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "pretend": true, + "update": true, + "ask": true + }, + "args": { + "package": "@world" + } + } + } + }, + { + "id": "default-flags-mixed-uap", + "doc": "The boundary bundle preserves parent-local shorts in either order.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-uap", "@world"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "pretend": true, + "update": true, + "ask": true + }, + "args": { + "package": "@world" + } + } + } + }, + { + "id": "default-flags-mixed-parent-value", + "doc": "A value-taking parent short keeps its attached value in a mixed bundle.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-ucquery"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "update": true, + "config": "query" + } + } + } + }, + { + "id": "default-flags-mixed-parent-detached-value", + "doc": "A value-taking parent short keeps its detached value in a mixed bundle.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-uc", "query"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "update": true, + "config": "query" + } + } + } + }, + { + "id": "default-flags-mixed-parent-shadow", + "doc": "Shared spellings in the boundary bundle retain the parent declaration.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-p --child-pretend\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-up"], + "expect": { + "ok": { + "cmd": ["install"], + "flags": { + "update": true, + "pretend": true + } + } + } + }, + { + "id": "default-flags-mixed-scope-ends", + "doc": "Parent-local shorts do not leak into following tokens.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-up", "-c", "local"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-mixed-unknown", + "doc": "The whole mixed bundle is rejected if it contains an unknown short.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-upz"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-mixed-sibling", + "doc": "Explicit sibling precedence also applies to mixed bundles.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["-pu", "query"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-root-clause-value", + "doc": "A root clause separator is not an optional detached flag value.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \" default_missing=\"auto\"\nclause \"tasks\" separator=\":::\" { arg \"[task]\"; }\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--config", ":::", "-u"], + "expect": { + "error": "unknown_flag" + } + }, + { + "id": "default-flags-root-clause-required-value", + "doc": "A root clause separator cannot satisfy a required flag value.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nclause \"tasks\" separator=\":::\" { arg \"[task]\"; }\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n arg \"[package]\"\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--config", ":::", "-u"], + "expect": { + "error": "missing_flag_value" + } + }, + { + "id": "default-flags-default-clause-value", + "doc": "A default clause separator ends lookahead before a following sibling name.", + "spec": "name \"em\"\nbin \"em\"\nunknown_flags \"error\"\ndefault_subcommand \"install\"\ndefault_subcommand_flags #true\nflag \"-p --pretend\"\nflag \"-c --config \"\nflag \"-v --verbose\" global=#true\ncmd \"install\" {\n alias \"i\"\n flag \"-u --update\"\n flag \"-a --ask\"\n flag \"-r --remove\"\n flag \"--output \"\n clause \"tasks\" separator=\":::\" { arg \"[task]\"; }\n}\ncmd \"query\" { alias \"q\"; }\ncmd \"search\" {}\n", + "argv": ["--output", ":::", "query"], + "expect": { + "error": "missing_flag_value" + } } ] } diff --git a/docs/spec/reference/index.md b/docs/spec/reference/index.md index 5b6223fda..8a55b6f3d 100644 --- a/docs/spec/reference/index.md +++ b/docs/spec/reference/index.md @@ -168,7 +168,8 @@ rejects `-u` on the parent. Parent-only flags, including `--help`, and an empty invocation stay on the parent. The implicit command boundary is immediately before the first default-only -flag. Parent flags before it retain their bindings; flags after it use the +flag token. Parent flags before it and within the same short bundle retain +their bindings in either order (`-pu` or `-up`). Subsequent tokens use the normal child/global scope. A shared flag spelling is interpreted using the parent declaration during lookahead. `--` ends lookahead, and an unknown flag stops it because its value arity is unknown. Without this opt-in, default diff --git a/go/argv/parser.go b/go/argv/parser.go index f2a137805..9feef4098 100644 --- a/go/argv/parser.go +++ b/go/argv/parser.go @@ -90,6 +90,8 @@ type Parser struct { // descend on every word until the tree ran out. defaultTaken bool defaultFlagAt int + // Cursor after the boundary bundle, whose letters retain parent ownership. + defaultBundleEnd int // done stops iteration, set when argv runs out or an error is reported. done bool @@ -124,7 +126,11 @@ func (p *Parser) defaultFlagRoute() int { at := -1 for i := 0; i < len(p.argv); { token := p.argv[i] - if token == "--" || token == "-" { + scope := p.cmd + if at >= 0 { + scope = d + } + if token == "--" || token == "-" || (scope.Clause != nil && scope.Clause.Separator != "" && scope.Clause.Separator == token) { return at } if !isFlagLike(token) { @@ -192,9 +198,16 @@ func (p *Parser) defaultFlagRoute() int { } i++ if f := valueFlag; f != nil { + scope := p.cmd + if at >= 0 { + scope = d + } + isSeparator := func(next string) bool { + return scope.Clause != nil && scope.Clause.Separator != "" && scope.Clause.Separator == next + } if !hasAttached && !f.RequireEquals && i < len(p.argv) { next := p.argv[i] - if f.AllowHyphenValues || !isFlagLike(next) || (f.AllowNegativeNumbers && isNegativeNumber(next)) { + if !isSeparator(next) && (f.AllowHyphenValues || !isFlagLike(next) || (f.AllowNegativeNumbers && isNegativeNumber(next))) { attached, hasAttached = next, true i++ } @@ -209,6 +222,9 @@ func (p *Parser) defaultFlagRoute() int { } for i < len(p.argv) && (f.VarMax == 0 || count < f.VarMax) { next := p.argv[i] + if isSeparator(next) { + break + } if next == "--" || (f.ValueTerminator != "" && next == f.ValueTerminator) { if next != "--" { i++ @@ -327,13 +343,43 @@ func (p *Parser) emit(e Event) bool { return true } +// defaultBundleHasParentFlag counts parent flags before entering the child. +func (p *Parser) defaultBundleHasParentFlag(d *Command) bool { + if p.pos >= len(p.argv) { + return false + } + token := p.argv[p.pos] + if !strings.HasPrefix(token, "-") || strings.HasPrefix(token, "--") { + return false + } + for i := 1; i < len(token); i++ { + if p.findShort(token[i]) != nil { + return true + } + var flag *Flag + for _, f := range d.Flags { + if slices.Contains(f.Shorts, token[i]) { + flag = f + break + } + } + if flag == nil || flag.TakesValue { + break + } + } + return false +} + func (p *Parser) step() bool { if p.defaultFlagAt == p.pos && len(p.bundle) == 0 { p.defaultFlagAt = -1 if d := p.cmd.DefaultSubcommand; d != nil { - if p.cmd.ArgsConflictWithSubcommands && p.commandArgFound { + if p.cmd.ArgsConflictWithSubcommands && (p.commandArgFound || p.defaultBundleHasParentFlag(d)) { return p.fail(Error{Code: CodeSubcommandConflict, Cmd: p.cmd}) } + if p.pos < len(p.argv) && strings.HasPrefix(p.argv[p.pos], "-") && !strings.HasPrefix(p.argv[p.pos], "--") { + p.defaultBundleEnd = p.pos + 1 + } p.defaultTaken = true if !p.descend(d) { return false @@ -1027,6 +1073,13 @@ func (p *Parser) findNegation(name string) *Flag { } func (p *Parser) findShort(b byte) *Flag { + if p.defaultTaken && p.pos == p.defaultBundleEnd && p.ancestors[0] != nil { + for _, f := range p.ancestors[0].Flags { + if slices.Contains(f.Shorts, b) { + return f + } + } + } f := p.eachInScope(func(f *Flag) bool { for _, s := range f.Shorts { if s == b { diff --git a/go/argv/parser_test.go b/go/argv/parser_test.go index 5ccbd7ef6..07441b5ee 100644 --- a/go/argv/parser_test.go +++ b/go/argv/parser_test.go @@ -589,3 +589,15 @@ func BenchmarkParse(b *testing.B) { } } } + +func TestMixedDefaultBundleHonorsParentConflicts(t *testing.T) { + child := &Command{Name: "install", Flags: []*Flag{{Name: "update", Shorts: []byte{'u'}}}} + root := &Command{Name: "em", Flags: []*Flag{{Name: "pretend", Shorts: []byte{'p'}}}, + Subcommands: []*Command{child}, DefaultSubcommand: child, + DefaultSubcommandFlags: true, ArgsConflictWithSubcommands: true} + for _, bundle := range []string{"-pu", "-up"} { + if got := collect(root, bundle); got != "err:subcommand_conflict" { + t.Errorf("%s: got %s", bundle, got) + } + } +} diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 61c01f5c6..a886b45c1 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1235,6 +1235,25 @@ struct Token { binding: Option<(Arc, usize)>, } +/// Flag scope for the one token straddling an implicit default-command boundary. +struct DefaultBundle { + argv: usize, + flags: BTreeMap>, + parent_keys: HashSet, +} + +impl DefaultBundle { + /// Preserve the declaration and command level when a short tail is requeued. + fn binding(&self, key: &str) -> Option<(Arc, usize)> { + self.flags.get(key).map(|flag| { + ( + Arc::clone(flag), + usize::from(!self.parent_keys.contains(key)), + ) + }) + } +} + impl Token { fn new(word: String, argv: usize) -> Self { Self { @@ -1321,7 +1340,14 @@ fn default_flag_route(spec: &Spec, root: &SpecCommand, input: &VecDeque) let mut at = None; let mut i = 0; while let Some(token) = input.get(i).map(|t| t.word.as_str()) { - if token == "--" || token == "-" { + let scope = if at.is_some() { default } else { root }; + if token == "--" + || token == "-" + || scope + .clause + .as_ref() + .is_some_and(|c| c.separator.as_deref() == Some(token)) + { return at; } if !is_flag_like(token) { @@ -1381,13 +1407,20 @@ fn default_flag_route(spec: &Spec, root: &SpecCommand, input: &VecDeque) } i += 1; if let Some(flag) = value_flag { + let scope = if at.is_some() { default } else { root }; + let is_separator = |next: &str| { + scope + .clause + .as_ref() + .is_some_and(|c| c.separator.as_deref() == Some(next)) + }; let first = if attached.is_some() { attached } else { input .get(i) .map(|t| t.word.as_str()) - .filter(|next| accepts_detached_flag_value(flag, next)) + .filter(|next| !is_separator(next) && accepts_detached_flag_value(flag, next)) .inspect(|_| i += 1) }; if first.is_none() && !flag.value_optional && flag.default_missing.is_none() { @@ -1397,10 +1430,13 @@ fn default_flag_route(spec: &Spec, root: &SpecCommand, input: &VecDeque) if arg.var { let count_values = |v: &str| arg.delimiter.map_or(1, |d| v.split(d).count()); let mut count = first.map_or(0, count_values); - while !arg.var_max.is_some_and(|max| count >= max) { + while arg.var_max.is_none_or(|max| count < max) { let Some(next) = input.get(i).map(|t| t.word.as_str()) else { break; }; + if is_separator(next) { + break; + } if next == "--" || arg.value_terminator.as_deref() == Some(next) { if next != "--" { i += 1; @@ -1526,6 +1562,7 @@ fn parse_partial_traced( // Track whether we've already applied the default_subcommand to prevent // multiple switches (e.g., if default is "run" and there's a task named "run") let mut used_default_subcommand = false; + let mut default_bundle: Option = None; // Whether the command in scope has had its own mounts run. A mount on the root // is the case that needs this: a subcommand's mounts are run when the parser // descends into it, but nothing descends into the root. @@ -1602,12 +1639,37 @@ fn parse_partial_traced( out.cmd.find_subcommand(&input[idx].word) }; if let Some(subcommand) = selected_command { - if out.cmd.args_conflicts_with_subcommands && command_arg_found { + let boundary_has_parent = out.cmd.args_conflicts_with_subcommands + && implicit_default + && input[idx].word.starts_with('-') + && !input[idx].word.starts_with("--") + && { + let child_flags = gather_flags(subcommand); + let mut found = false; + for short in input[idx].word[1..].chars() { + let key = format!("-{short}"); + if out.available_flags.contains_key(&key) { + found = true; + break; + } + match child_flags.get(&key) { + Some(flag) if flag.arg.is_none() => {} + _ => break, + } + } + found + }; + if out.cmd.args_conflicts_with_subcommands && (command_arg_found || boundary_has_parent) + { bail!( "subcommand '{}' cannot be used with arguments on its parent command", - input[idx].word + subcommand.name ); } + let boundary_parent = (implicit_default + && input[idx].word.starts_with('-') + && !input[idx].word.starts_with("--")) + .then(|| out.available_flags.clone()); let mut subcommand = subcommand.clone(); // Pass prefix words (global flags before this subcommand) to mount subcommand.mount(&mount_prefix_words(&prefix_flags), mount_outputs)?; @@ -1619,6 +1681,16 @@ fn parse_partial_traced( gather_flags(&subcommand), crossing_mount, ); + if let Some(parent) = boundary_parent { + let parent_keys = parent.keys().cloned().collect(); + let mut flags = out.available_flags.clone(); + flags.extend(parent); + default_bundle = Some(DefaultBundle { + argv: input[idx].argv, + flags, + parent_keys, + }); + } // Remove subcommand from input let selected = if implicit_default { used_default_subcommand = true; @@ -1656,14 +1728,13 @@ fn parse_partial_traced( // first read: a token containing an unrecognized letter is not a bundle, // and recording it as one is what let `-a` be applied from a token that // never named it. - let is_bundle = word.starts_with("--") - || short_bundle_is_known(spec, &out.cmds, &out.available_flags, &word); - if let Some(f) = out - .available_flags - .get(flag_key) - .cloned() - .filter(|_| is_bundle) - { + let boundary = default_bundle + .as_ref() + .filter(|bundle| bundle.argv == input[idx].argv); + let flags = boundary.map_or(&out.available_flags, |bundle| &bundle.flags); + let is_bundle = + word.starts_with("--") || short_bundle_is_known(spec, &out.cmds, flags, &word); + if let Some(f) = flags.get(flag_key).cloned().filter(|_| is_bundle) { command_arg_found = true; variadic_flag_active = f.arg.as_ref().is_some_and(|arg| arg.var); // Skip the flag and keep scanning. Both global and non-global flags may precede @@ -1673,7 +1744,9 @@ fn parse_partial_traced( // // Only globals are forwarded to mounts: a non-global flag belongs to the // command that declared it, not to what is mounted below it. - input[idx].binding = Some((Arc::clone(&f), out.cmds.len() - 1)); + input[idx].binding = boundary + .and_then(|bundle| bundle.binding(flag_key)) + .or_else(|| Some((Arc::clone(&f), out.cmds.len() - 1))); let mut forwarded = f.global.then(|| vec![word.clone()]); idx += 1; @@ -2261,7 +2334,14 @@ fn parse_partial_traced( if !rest.is_empty() { // `-abc` is one token that names three flags, so the tail is read at the // bundle's own position rather than at one of its own. - input.push_front(Token::new(format!("-{rest}"), argv)); + let mut tail = Token::new(format!("-{rest}"), argv); + if f.arg.is_none() { + tail.binding = default_bundle + .as_ref() + .filter(|bundle| bundle.argv == argv) + .and_then(|bundle| bundle.binding(get_flag_key(&tail.word))); + } + input.push_front(tail); } // A fully consumed short is no longer a grouped continuation. // Leaving this set after `-ai` made `-i` skip `require_equals` From 0193de9759355720bcbb855889ec69c97d01cbbd Mon Sep 17 00:00:00 2001 From: Jeff Dickey <216188+jdx@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:33:28 -0500 Subject: [PATCH 3/4] style(parse): simplify negated flag lookahead Entire-Checkpoint: 01M2663CMV7E5FDKF7JPB23EQ7 --- argv/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argv/src/lib.rs b/argv/src/lib.rs index 2deff9a87..1ea2abcf7 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -1831,7 +1831,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { if parent.is_none() { at.get_or_insert(i); } - if flag.takes_value && !flag.negate.is_some_and(|n| n.as_bytes() == name) { + if flag.takes_value && flag.negate.is_none_or(|n| n.as_bytes() != name) { value_flag = Some(flag); attached = (end < body.len()).then(|| &body[end + 1..]); } From 947d438b0e73f8403359f8119b080ea6470603a3 Mon Sep 17 00:00:00 2001 From: Jeff Dickey <216188+jdx@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:42:12 -0500 Subject: [PATCH 4/4] fix(parse): include built-ins in default bundle conflicts Entire-Checkpoint: 01M266KD8FPVMA344YP0TTB7NW --- conformance/tests/default_subcommand_flags.rs | 14 +++++++++++--- go/argv/parser_test.go | 4 ++-- lib/src/parse.rs | 4 +++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/conformance/tests/default_subcommand_flags.rs b/conformance/tests/default_subcommand_flags.rs index e62a74cde..62ce99b69 100644 --- a/conformance/tests/default_subcommand_flags.rs +++ b/conformance/tests/default_subcommand_flags.rs @@ -104,7 +104,14 @@ fn implicit_conflicts_name_the_default_command() { let kdl = Em::to_kdl(); let mut spec: usage::Spec = kdl.parse().unwrap(); spec.cmd.args_conflicts_with_subcommands = true; - for words in [vec!["em", "-p", "-u"], vec!["em", "-pu"], vec!["em", "-up"]] { + spec.version = Some("1.2.3".into()); + for words in [ + vec!["em", "-p", "-u"], + vec!["em", "-pu"], + vec!["em", "-up"], + vec!["em", "-uhp"], + vec!["em", "-uVp"], + ] { let error = usage::Parser::new(&spec) .parse(&words.into_iter().map(String::from).collect::>()) .unwrap_err(); @@ -122,7 +129,8 @@ fn mixed_bundles_honor_parent_conflicts_in_the_runtime() { #[usage( default_subcommand = "install", default_subcommand_flags, - args_conflicts_with_subcommands + args_conflicts_with_subcommands, + version = "1.2.3" )] struct Conflicts { #[usage(short = 'p')] @@ -130,7 +138,7 @@ fn mixed_bundles_honor_parent_conflicts_in_the_runtime() { #[usage(subcommand)] command: Option, } - for bundle in ["-pu", "-up"] { + for bundle in ["-pu", "-up", "-uhp", "-uVp"] { assert!(matches!( Conflicts::parse_from(&[OsStr::new(bundle)]), Err(usage_argv::Error::SubcommandConflict { .. }) diff --git a/go/argv/parser_test.go b/go/argv/parser_test.go index 07441b5ee..893b32b99 100644 --- a/go/argv/parser_test.go +++ b/go/argv/parser_test.go @@ -594,8 +594,8 @@ func TestMixedDefaultBundleHonorsParentConflicts(t *testing.T) { child := &Command{Name: "install", Flags: []*Flag{{Name: "update", Shorts: []byte{'u'}}}} root := &Command{Name: "em", Flags: []*Flag{{Name: "pretend", Shorts: []byte{'p'}}}, Subcommands: []*Command{child}, DefaultSubcommand: child, - DefaultSubcommandFlags: true, ArgsConflictWithSubcommands: true} - for _, bundle := range []string{"-pu", "-up"} { + DefaultSubcommandFlags: true, ArgsConflictWithSubcommands: true, Version: true} + for _, bundle := range []string{"-pu", "-up", "-uhp", "-uVp"} { if got := collect(root, bundle); got != "err:subcommand_conflict" { t.Errorf("%s: got %s", bundle, got) } diff --git a/lib/src/parse.rs b/lib/src/parse.rs index a886b45c1..94151f1d0 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1648,7 +1648,9 @@ fn parse_partial_traced( let mut found = false; for short in input[idx].word[1..].chars() { let key = format!("-{short}"); - if out.available_flags.contains_key(&key) { + if out.available_flags.contains_key(&key) + || supplied_short(spec, &out.cmds, short).is_some() + { found = true; break; }