From 386a80ff6253faca66c8d4f0fe718ea7383dded7 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:23:52 +0000 Subject: [PATCH 1/8] feat(argv,derive): add compiled clause support --- argv/src/complete.rs | 45 ++++++-- argv/src/lib.rs | 96 +++++++++++++++-- argv/src/spec.rs | 54 ++++++++-- argv/tests/no_alloc.rs | 6 +- cli/src/cli/diff.rs | 51 +++++++++ conformance/src/argv.rs | 16 +++ conformance/src/lib.rs | 2 + conformance/src/reference.rs | 16 +++ conformance/src/tables.rs | 36 ++++++- conformance/tests/clause.rs | 42 ++++++++ corpus/15-clause.json | 51 +++++++++ derive/src/codegen.rs | 117 ++++++++++++++++++++- derive/src/model.rs | 162 +++++++++++++++++++++++++++++ docs/spec/reference/clause.md | 21 ++++ go/argv/argv.go | 13 +++ go/argv/parser.go | 41 +++++--- go/conformance/conformance_test.go | 65 +++++++++++- go/internal/spec/spec.go | 13 +++ lib/src/go/mod.rs | 35 +++++++ 19 files changed, 838 insertions(+), 44 deletions(-) create mode 100644 corpus/15-clause.json diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 54636839a..14aa43c42 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -240,7 +240,11 @@ fn for_name_at<'a>( view: Option<&'a crate::spec::ViewMeta<'a>>, ) -> Option>> { fn on(meta: &CommandMeta<'_>, name: &str) -> Option { - for arg in meta.args { + for arg in meta.args.iter().chain( + meta.clause + .into_iter() + .flat_map(|clause| clause.args.iter()), + ) { if arg.arg.name.eq_ignore_ascii_case(name) { if let Some(completer) = arg.complete { return Some(completer); @@ -280,9 +284,17 @@ fn for_name_at<'a>( } } if let Some(wanted) = position.next_arg { - let found = meta.args.iter().find(|field| { - core::ptr::eq(field.arg, wanted) && field.arg.name.eq_ignore_ascii_case(name) - }); + let found = meta + .args + .iter() + .chain( + meta.clause + .into_iter() + .flat_map(|clause| clause.args.iter()), + ) + .find(|field| { + core::ptr::eq(field.arg, wanted) && field.arg.name.eq_ignore_ascii_case(name) + }); if let Some(completer) = found.and_then(|field| field.complete) { return Some(completer); } @@ -339,7 +351,11 @@ fn for_name_at<'a>( #[cfg(feature = "spec")] pub fn completers_on(meta: &CommandMeta<'_>) -> Vec { let mut out = Vec::new(); - for arg in meta.args { + for arg in meta.args.iter().chain( + meta.clause + .into_iter() + .flat_map(|clause| clause.args.iter()), + ) { if arg.complete.is_some() { out.push(arg.arg.name.to_ascii_lowercase()); } @@ -497,7 +513,12 @@ fn trace_from<'a>( let chain = metadata_chain_on_route(spec, &position); let meta = chain.as_ref().and_then(|chain| chain.last().copied()); let next_arg = if restarted(meta, split) { - meta.and_then(first_ordinary_arg).map(|field| field.arg) + meta.and_then(|meta| { + meta.clause + .and_then(|clause| clause.args.first()) + .or_else(|| first_ordinary_arg(meta)) + }) + .map(|field| field.arg) } else { position .next_arg @@ -1584,6 +1605,14 @@ fn arg_meta_owner_on_route<'a>( .args .iter() .find(|field| core::ptr::eq(field.arg, arg)) + .or_else(|| { + owner.clause.and_then(|clause| { + clause + .args + .iter() + .find(|field| core::ptr::eq(field.arg, arg)) + }) + }) .map(|field| (*owner, field)) }) } @@ -2049,6 +2078,10 @@ fn arg_meta<'a>(meta: &'a CommandMeta<'a>, arg: &Arg<'_>) -> Option<&'a ArgMeta< meta.args .iter() .find(|m| core::ptr::eq(m.arg, arg)) + .or_else(|| { + meta.clause + .and_then(|clause| clause.args.iter().find(|m| core::ptr::eq(m.arg, arg))) + }) .or_else(|| meta.subcommands.iter().find_map(|sub| arg_meta(sub, arg))) } diff --git a/argv/src/lib.rs b/argv/src/lib.rs index e09f97b30..a81e4a3a9 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -201,6 +201,8 @@ pub struct Command<'a> { pub flags: &'a [&'a Flag<'a>], /// Positional arguments, in the order they are filled. pub args: &'a [&'a Arg<'a>], + /// A repeatable group of positional arguments, if this command has one. + pub clause: ::core::option::Option>, pub subcommands: &'a [&'a Command<'a>], /// Where a word goes when it names no subcommand of this one. /// @@ -285,6 +287,7 @@ impl Command<'_> { aliases: &[], flags: &[], args: &[], + clause: ::core::option::Option::None, subcommands: &[], default_subcommand: ::core::option::Option::None, external_subcommand: false, @@ -303,6 +306,15 @@ impl Command<'_> { }; } +/// A separator-delimited positional group. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Clause<'a> { + pub key: u64, + pub name: &'a str, + pub separator: &'a [u8], + pub args: &'a [&'a Arg<'a>], +} + /// Basename of argv[0] for a multicall CLI: last path component, with a trailing /// `.exe` stripped so Windows and Unix agree. pub fn multicall_basename(argv0: &str) -> &str { @@ -625,6 +637,8 @@ pub enum Event<'t, 'a, 'v> { /// Whether this value should be split by the argument's declared delimiter. delimit: bool, }, + /// Ended one clause instance and began the next. + ClauseSeparator { clause: Clause<'t> }, /// An unmatched word was forwarded as an external command: the name, then /// every remaining token, including flags. External { values: &'a [&'v OsStr] }, @@ -1850,6 +1864,19 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { let token = bytes(self.argv.get(self.pos)?); self.pos += 1; + // A clause separator remains syntax after an automatic positional stopped flags. + // Only an explicit `--` protects a literal separator. + if !self.separator_seen { + if let Some(clause) = self.cmd.clause.filter(|clause| token == clause.separator) { + self.arg_pos = 0; + self.arg_taken = 0; + self.arg_filled = false; + self.collecting = None; + self.flags_stopped = false; + return Some(Ok(Event::ClauseSeparator { clause })); + } + } + // An automatic trailing argument stops flag interpretation without consuming an // explicit separator. A later `--` must still unlock a required trailing argument // (clap's `last`), while a separator already consumed makes every later `--` data. @@ -1870,7 +1897,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { self.separator_seen = true; // An explicit separator unlocks any argument that required one, even // if earlier arguments are still unfilled. - if let Some(idx) = self.cmd.args[self.arg_pos..] + if let Some(idx) = self.current_args()[self.arg_pos..] .iter() .position(|a| a.double_dash == DoubleDash::Required) { @@ -2389,7 +2416,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } fn next_arg(&self) -> Option<&'t Arg<'t>> { - self.cmd.args[self.arg_pos..] + self.current_args()[self.arg_pos..] .iter() .find(|arg| arg.sigil.is_none()) .copied() @@ -2397,8 +2424,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { fn skip_sigil_args(&mut self) { while self - .cmd - .args + .current_args() .get(self.arg_pos) .is_some_and(|arg| arg.sigil.is_some()) { @@ -2410,7 +2436,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { if self.flags_stopped { return None; } - let own = self.cmd.args.iter().copied(); + let own = self.current_args().iter().copied(); let inherited = self.ancestors[..self.depth] .iter() .rev() @@ -2424,6 +2450,13 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { .max_by_key(|(_, sigil)| sigil.len()) } + fn current_args(&self) -> &'t [&'t Arg<'t>] { + self.cmd + .clause + .map(|clause| clause.args) + .unwrap_or(self.cmd.args) + } + /// Skip empty optional positionals when every remaining value is needed by a later /// required positional. This is clap's opt-in `allow_missing_positional` policy. fn reserve_for_required_positionals(&mut self) { @@ -2437,7 +2470,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { if current.required { return; } - let required_after = self.cmd.args[self.arg_pos + 1..] + let required_after = self.current_args()[self.arg_pos + 1..] .iter() .filter(|arg| arg.required && arg.sigil.is_none()) .count(); @@ -2674,6 +2707,57 @@ fn validate_bool_value<'t, 'v>( mod tests { use super::*; + #[test] + fn clause_separator_resets_inner_args_and_survives_automatic_mode() { + static TASK: Arg = Arg { + key: 91, + name: "task", + ..Arg::REQUIRED + }; + static REST: Arg = Arg { + key: 92, + name: "args", + double_dash: DoubleDash::Automatic, + ..Arg::VAR + }; + static ROOT: Command = Command { + name: "ex", + clause: Some(Clause { + key: 90, + name: "tasks", + separator: b":::", + args: &[&TASK, &REST], + }), + ..Command::EMPTY + }; + let argv = [ + OsStr::new("lint"), + OsStr::new("--fix"), + OsStr::new(":::"), + OsStr::new("test"), + ]; + let mut parser = Parser::new(&ROOT, &argv); + let mut seen = Vec::new(); + while let Some(event) = parser.next_event() { + match event.expect("valid clause") { + Event::Arg { arg, value, .. } => { + seen.push((arg.name, String::from_utf8_lossy(value).into_owned())) + } + Event::ClauseSeparator { clause } => seen.push((clause.name, ":::".into())), + _ => {} + } + } + assert_eq!( + seen, + [ + ("task", "lint".into()), + ("args", "--fix".into()), + ("tasks", ":::".into()), + ("task", "test".into()) + ] + ); + } + static FORCE: Flag = Flag { key: 1, longs: &["force"], diff --git a/argv/src/spec.rs b/argv/src/spec.rs index f8cf423a9..5f184542a 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -856,6 +856,8 @@ pub struct CommandMeta<'a> { /// A token that starts a fresh invocation of this command, such as mise's /// `:::`. pub restart_token: Option<&'a str>, + /// Metadata for a repeatable positional clause. + pub clause: Option>, /// Whether this command cannot be run on its own: naming it and stopping is an /// error, and one of its subcommands has to follow. /// @@ -935,6 +937,7 @@ impl CommandMeta<'_> { effect: None, mount: None, restart_token: None, + clause: None, subcommand_required: false, subcommand_help_heading: None, subcommand_value_name: None, @@ -960,6 +963,16 @@ impl CommandMeta<'_> { }; } +/// Cold metadata for a command's compiled clause table. +#[derive(Debug, Clone, Copy)] +pub struct ClauseMeta<'a> { + pub name: &'a str, + pub separator: &'a str, + pub help: Option<&'a str>, + pub long_help: Option<&'a str>, + pub args: &'a [ArgMeta<'a>], +} + /// A run of one command's flags that arrived from a flattened `Args` type. /// /// `#[usage(flatten)]` splices a struct's declarations into the command that holds it, so a @@ -1931,11 +1944,15 @@ fn write_body<'a>( meta.flags.len(), "every flag in the parse table needs metadata, or it will not be written" ); - debug_assert_eq!( - meta.cmd.args.len(), - meta.args.len(), - "every argument in the parse table needs metadata" - ); + if let (Some(clause), Some(clause_meta)) = (meta.cmd.clause, meta.clause) { + debug_assert_eq!(clause.args.len(), clause_meta.args.len()); + } else { + debug_assert_eq!( + meta.cmd.args.len(), + meta.args.len(), + "every argument in the parse table needs metadata" + ); + } debug_assert_eq!( meta.cmd.subcommands.len(), meta.subcommands.len(), @@ -1968,6 +1985,27 @@ fn write_body<'a>( ); write_arg(out, arg, depth)?; } + if let Some(clause) = meta.clause { + indent(out, depth)?; + write!( + out, + "clause {} separator={}", + quoted(clause.name), + quoted(clause.separator) + )?; + if let Some(help) = clause.help { + write!(out, " help={}", quoted(help))?; + } + if let Some(help) = clause.long_help { + write!(out, " help_long={}", quoted(help))?; + } + out.push_str(" {\n"); + for arg in clause.args { + write_arg(out, arg, depth + 1)?; + } + indent(out, depth)?; + out.push_str("}\n"); + } write_completion_types(out, meta, depth)?; // After the flags and arguments they name, so a reader meets the members before the // rule about them — the order usage-lib writes, so a round trip reads the same way. @@ -2006,7 +2044,11 @@ fn write_completion_types<'a>( depth: usize, ) -> core::fmt::Result { let mut written: Vec<(String, &'a str)> = Vec::new(); - for arg in meta.args { + for arg in meta.args.iter().chain( + meta.clause + .into_iter() + .flat_map(|clause| clause.args.iter()), + ) { if let Some(type_) = arg.complete_type { write_completion_type( out, diff --git a/argv/tests/no_alloc.rs b/argv/tests/no_alloc.rs index a98b4a2ec..15c2fb25e 100644 --- a/argv/tests/no_alloc.rs +++ b/argv/tests/no_alloc.rs @@ -165,7 +165,11 @@ fn drain(argv: &[&OsStr]) -> usize { while let Some(event) = parser.next_event() { match event { Ok( - Event::Command(_) | Event::Flag { .. } | Event::Arg { .. } | Event::External { .. }, + Event::Command(_) + | Event::Flag { .. } + | Event::Arg { .. } + | Event::External { .. } + | Event::ClauseSeparator { .. }, ) => seen += 1, Err(_) => seen += 1, } diff --git a/cli/src/cli/diff.rs b/cli/src/cli/diff.rs index ba8594937..d1395ff7e 100644 --- a/cli/src/cli/diff.rs +++ b/cli/src/cli/diff.rs @@ -452,6 +452,31 @@ fn diff_command_props(old: &SpecCommand, new: &SpecCommand, path: &str, c: &mut _ => {} } + match (&old.clause, &new.clause) { + (Some(was), None) => c.breaking( + "clause-removed", + path, + format!("clause '{}' was removed", was.name), + ), + (None, Some(now)) => c.breaking( + "clause-added", + path, + format!( + "clause '{}' using separator '{}' was added, so matching words now start a new clause", + now.name, now.separator + ), + ), + (Some(was), Some(now)) if was.separator != now.separator => c.breaking( + "clause-separator-changed", + path, + format!( + "clause separator changed from '{}' to '{}'", + was.separator, now.separator + ), + ), + _ => {} + } + diff_unknown_flags(old.unknown_flags, new.unknown_flags, path, c); if !old.args_conflicts_with_subcommands && new.args_conflicts_with_subcommands { @@ -2323,6 +2348,32 @@ cmd "run" help="run" { assert!(codes(BASE, BASE).is_empty()); } + #[test] + fn clause_shape_changes_are_breaking() { + let plain = r#" +name "ex" +bin "ex" +"#; + let colon = r#" +name "ex" +bin "ex" +clause "tasks" separator=":::" { + arg "" +} +"#; + let plus = r#" +name "ex" +bin "ex" +clause "tasks" separator="+++" { + arg "" +} +"#; + + assert_eq!(codes(plain, colon), ["breaking:clause-added"]); + assert_eq!(codes(colon, plain), ["breaking:clause-removed"]); + assert_eq!(codes(colon, plus), ["breaking:clause-separator-changed"]); + } + #[test] fn version_is_never_reported() { // A release bumps it. A compatibility check that fires on every release is one diff --git a/conformance/src/argv.rs b/conformance/src/argv.rs index da6e99e97..526a1c065 100644 --- a/conformance/src/argv.rs +++ b/conformance/src/argv.rs @@ -117,6 +117,7 @@ pub fn run(vector: &Vector) -> Outcome { let mut cmd = Vec::new(); let mut flags: BTreeMap = BTreeMap::new(); let mut args: BTreeMap = BTreeMap::new(); + let mut clauses: BTreeMap>> = BTreeMap::new(); while let Some(event) = parser.next_event() { match event { @@ -166,11 +167,18 @@ pub fn run(vector: &Vector) -> Outcome { args.insert(name, Value::Str(string(value))); } } + Ok(Event::ClauseSeparator { clause }) => { + clauses + .entry(clause.name.to_string()) + .or_default() + .push(std::mem::take(&mut args)); + } Ok(Event::External { values }) => { return Outcome::Parsed(Parsed { cmd, flags, args, + clauses, external: values .iter() .map(|v| v.to_str().expect("corpus values are UTF-8").to_string()) @@ -181,10 +189,18 @@ pub fn run(vector: &Vector) -> Outcome { } } + if let Some(clause) = parser.command().clause { + clauses + .entry(clause.name.to_string()) + .or_default() + .push(std::mem::take(&mut args)); + } + Outcome::Parsed(Parsed { cmd, flags, args, + clauses, external: Vec::new(), }) } diff --git a/conformance/src/lib.rs b/conformance/src/lib.rs index e465bdffc..c439b155b 100644 --- a/conformance/src/lib.rs +++ b/conformance/src/lib.rs @@ -128,6 +128,8 @@ pub struct Parsed { pub flags: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub args: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub clauses: BTreeMap>>, /// Remaining argv captured when an unmatched word was forwarded as an /// external subcommand: the command name first, then every token after it. #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/conformance/src/reference.rs b/conformance/src/reference.rs index c16904672..f1d2d95b5 100644 --- a/conformance/src/reference.rs +++ b/conformance/src/reference.rs @@ -88,6 +88,22 @@ pub fn run(vector: &Vector) -> Observed { cmd, flags, args, + clauses: out + .clauses + .into_iter() + .map(|(name, instances)| { + let instances = instances + .into_iter() + .map(|instance| { + instance + .into_iter() + .map(|(arg, value)| (arg.name.clone(), convert(&value))) + .collect() + }) + .collect(); + (name, instances) + }) + .collect(), external: out.external.unwrap_or_default(), }) } diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 7aa24fad8..99014763a 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -29,11 +29,11 @@ use usage::{ SpecGroup, SpecOutput, }; use usage_argv::spec::{ - AdmonitionKind, AdmonitionMeta, ArgMeta, ChoiceAliasMeta, ChoiceMeta, CommandMeta, DefaultIf, - Effect, Example, ExitCodeMeta, FlagMeta, Framing as ArgvFraming, GroupMeta, HeadingMeta, - OutputMeta, RequiredIfEq, RequiresIf, + AdmonitionKind, AdmonitionMeta, ArgMeta, ChoiceAliasMeta, ChoiceMeta, ClauseMeta, CommandMeta, + DefaultIf, Effect, Example, ExitCodeMeta, FlagMeta, Framing as ArgvFraming, GroupMeta, + HeadingMeta, OutputMeta, RequiredIfEq, RequiresIf, }; -use usage_argv::{Arg, Command, DoubleDash, Flag, UnknownFlags as ArgvUnknownFlags}; +use usage_argv::{Arg, Clause, Command, DoubleDash, Flag, UnknownFlags as ArgvUnknownFlags}; /// A command's two tables, built together so the metadata can borrow the parse table. pub struct Built { @@ -80,6 +80,17 @@ pub fn build( let flags: Vec<&'static Flag<'static>> = cmd.flags.iter().map(build_flag).collect(); let args: Vec<&'static Arg<'static>> = cmd.args.iter().map(build_arg).collect(); + let clause_args: Vec<&'static Arg<'static>> = cmd + .clause + .as_ref() + .map(|clause| clause.args.iter().map(build_arg).collect()) + .unwrap_or_default(); + let clause = cmd.clause.as_ref().map(|clause| Clause { + key: 0, + name: leak(&clause.name), + separator: leak(&clause.separator).as_bytes(), + args: Box::leak(clause_args.clone().into_boxed_slice()), + }); let subs: Vec = cmd .subcommands .values() @@ -101,6 +112,7 @@ pub fn build( aliases: Box::leak(aliases.into_boxed_slice()), flags: Box::leak(flags.clone().into_boxed_slice()), args: Box::leak(args.clone().into_boxed_slice()), + clause, subcommands: Box::leak( subs.iter() .map(|s| s.cmd) @@ -137,6 +149,21 @@ pub fn build( .zip(&args) .map(|(a, table)| arg_meta(a, table, &completers)) .collect(); + let clause_meta = cmd.clause.as_ref().map(|clause| ClauseMeta { + name: leak(&clause.name), + separator: leak(&clause.separator), + help: opt(&clause.help), + long_help: opt(&clause.help_long), + args: Box::leak( + clause + .args + .iter() + .zip(&clause_args) + .map(|(arg, table)| arg_meta(arg, table, &completers)) + .collect::>() + .into_boxed_slice(), + ), + }); let meta: &'static CommandMeta<'static> = Box::leak(Box::new(CommandMeta { cmd: table, @@ -162,6 +189,7 @@ pub fn build( // first is the one the tables can hold. mount: cmd.mounts.first().map(|m| leak(&m.run)), restart_token: opt(&cmd.restart_token), + clause: clause_meta, subcommand_required: cmd.subcommand_required, subcommand_help_heading: opt(&cmd.subcommand_help_heading), subcommand_value_name: opt(&cmd.subcommand_value_name), diff --git a/conformance/tests/clause.rs b/conformance/tests/clause.rs index 10e3ac9ba..2d0b07a1b 100644 --- a/conformance/tests/clause.rs +++ b/conformance/tests/clause.rs @@ -1,5 +1,20 @@ use usage::parse::ParseValue; use usage::Spec; +use usage_derive::{Args, Cli}; + +#[derive(Debug, PartialEq, Eq, Args)] +struct TaskClause { + task: String, + #[usage(double_dash = "automatic")] + args: Vec, +} + +#[derive(Debug, Cli)] +#[usage(bin = "typed-clause")] +struct TypedClause { + #[usage(clause, separator = ":::")] + tasks: Vec, +} fn spec() -> Spec { r#" @@ -171,3 +186,30 @@ clause "items" separator=":::" { assert!(format!("{error:?}").contains(expected), "{error:?}"); } } + +#[test] +fn derive_collects_each_clause_instance() { + let parsed = TypedClause::parse_from(&[ + std::ffi::OsStr::new("lint"), + std::ffi::OsStr::new("--fix"), + std::ffi::OsStr::new(":::"), + std::ffi::OsStr::new("test"), + std::ffi::OsStr::new("--all"), + ]) + .expect("typed clause parses"); + assert_eq!( + parsed.tasks, + [ + TaskClause { + task: "lint".into(), + args: vec!["--fix".into()] + }, + TaskClause { + task: "test".into(), + args: vec!["--all".into()] + }, + ] + ); + let emitted = TypedClause::to_kdl(); + assert!(emitted.contains("clause tasks separator=:::"), "{emitted}"); +} diff --git a/corpus/15-clause.json b/corpus/15-clause.json new file mode 100644 index 000000000..d4d9feb72 --- /dev/null +++ b/corpus/15-clause.json @@ -0,0 +1,51 @@ +{ + "section": "repeatable clauses", + "about": "A clause preserves each separator-delimited positional instance instead of replacing the previous instance.", + "vectors": [ + { + "id": "clause-preserves-instances", + "doc": "Each separator closes one instance and resets the inner positional cursor.", + "spec": "name \"ex\"\nbin \"ex\"\nclause \"tasks\" separator=\":::\" {\n arg \"\"\n arg \"[args]...\" double_dash=\"automatic\"\n}\n", + "argv": ["lint", "--fix", ":::", "test", "--all"], + "expect": { + "ok": { + "clauses": { + "tasks": [ + { "task": "lint", "args": ["--fix"] }, + { "task": "test", "args": ["--all"] } + ] + } + } + } + }, + { + "id": "clause-separator-survives-automatic-mode", + "doc": "The boundary remains syntax after an automatic argument stopped flag parsing.", + "spec": "name \"ex\"\nbin \"ex\"\nclause \"tasks\" separator=\":::\" {\n arg \"\"\n arg \"[args]...\" double_dash=\"automatic\"\n}\n", + "argv": ["one", "-x", ":::", "two", "-y"], + "expect": { + "ok": { + "clauses": { + "tasks": [ + { "task": "one", "args": ["-x"] }, + { "task": "two", "args": ["-y"] } + ] + } + } + } + }, + { + "id": "double-dash-protects-clause-separator", + "doc": "An explicit separator makes the clause separator literal data.", + "spec": "name \"ex\"\nbin \"ex\"\nclause \"tasks\" separator=\":::\" {\n arg \"\"\n arg \"[args]...\" double_dash=\"automatic\"\n}\n", + "argv": ["lint", "--", ":::", "tail"], + "expect": { + "ok": { + "clauses": { + "tasks": [{ "task": "lint", "args": [":::", "tail"] }] + } + } + } + } + ] +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 68959a9f5..68281451e 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -248,6 +248,37 @@ pub fn emit(cli: &Cli) -> TokenStream { .iter() .enumerate() .map(|(i, f)| arg_meta(cli, i, f, &cli.ident)); + let clause_field = cli.fields.iter().find_map(|field| { + let Kind::Clause { ty, separator } = &field.kind else { + return None; + }; + Some((field, ty, separator)) + }); + let clause_table = clause_field + .map(|(field, ty, separator)| { + let name = proc_macro2::Literal::string(&field.name); + let separator = proc_macro2::Literal::byte_string(separator.as_bytes()); + quote!(::core::option::Option::Some(usage_argv::Clause { + key: 0, + name: #name, + separator: #separator, + args: <#ty as usage_argv::spec::CommandArgs>::COMMAND.args, + })) + }) + .unwrap_or_else(|| quote!(::core::option::Option::None)); + let clause_meta = clause_field + .map(|(field, ty, separator)| { + let name = proc_macro2::Literal::string(&field.name); + let separator = proc_macro2::Literal::string(separator); + quote!(::core::option::Option::Some(usage_argv::spec::ClauseMeta { + name: #name, + separator: #separator, + help: ::core::option::Option::None, + long_help: ::core::option::Option::None, + args: <#ty as usage_argv::spec::CommandArgs>::META.args, + })) + }) + .unwrap_or_else(|| quote!(::core::option::Option::None)); // Both the plain slices and, when a field is flattened, the joined arrays. let tables = tables(cli); @@ -1036,6 +1067,7 @@ pub fn emit(cli: &Cli) -> TokenStream { key: #root_key, flags: #flag_table_ref, args: #arg_table_ref, + clause: #clause_table, #sub_commands #sub_default #sub_external @@ -1088,6 +1120,7 @@ pub fn emit(cli: &Cli) -> TokenStream { headings: #headings, flags: #flag_meta_table_ref, args: #arg_meta_table_ref, + clause: #clause_meta, groups: #group_meta_table_ref, flatten_groups: #flatten_group_table_ref, #sub_metas @@ -3235,7 +3268,7 @@ fn tables(cli: &Cli) -> Tables { flag_groups.push(quote!(<#ty as usage_argv::spec::ArgGroup>::FLAGS)); flag_meta_groups.push(quote!(<#ty as usage_argv::spec::ArgGroup>::FLAG_METAS)); } - Kind::Subcommand { .. } | Kind::Skip => {} + Kind::Clause { .. } | Kind::Subcommand { .. } | Kind::Skip => {} } } flush_flags(&mut own_flags, &mut flag_groups, &mut flag_meta_groups); @@ -3573,6 +3606,7 @@ fn standing_presence(field: &Field) -> Option { let ident = &field.ident; match &field.kind { Kind::Skip | Kind::Flatten { .. } => None, + Kind::Clause { .. } => Some(quote!(!__usage_s.#ident.is_empty())), Kind::ArgGroup { multiple: true, .. } => Some(quote!(!__usage_s.#ident.is_empty())), Kind::ArgGroup { optional, .. } | Kind::Subcommand { optional, .. } => Some(if *optional { quote!(__usage_s.#ident.is_some()) @@ -4150,7 +4184,11 @@ fn presence_methods(cli: &Cli) -> TokenStream { let direct_given = cli.fields.iter().filter_map(|field| { if matches!( field.kind, - Kind::Flatten { .. } | Kind::ArgGroup { .. } | Kind::Subcommand { .. } | Kind::Skip + Kind::Flatten { .. } + | Kind::ArgGroup { .. } + | Kind::Clause { .. } + | Kind::Subcommand { .. } + | Kind::Skip ) { return None; } @@ -4318,7 +4356,11 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { let state_arms = cli.fields.iter().filter_map(|field| { if matches!( field.kind, - Kind::Flatten { .. } | Kind::ArgGroup { .. } | Kind::Subcommand { .. } | Kind::Skip + Kind::Flatten { .. } + | Kind::ArgGroup { .. } + | Kind::Clause { .. } + | Kind::Subcommand { .. } + | Kind::Skip ) { return None; } @@ -4378,7 +4420,11 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { let match_arms = cli.fields.iter().filter_map(|field| { if matches!( field.kind, - Kind::Flatten { .. } | Kind::ArgGroup { .. } | Kind::Subcommand { .. } | Kind::Skip + Kind::Flatten { .. } + | Kind::ArgGroup { .. } + | Kind::Clause { .. } + | Kind::Subcommand { .. } + | Kind::Skip ) { return None; } @@ -4604,6 +4650,14 @@ fn partial_struct(cli: &Cli) -> TokenStream { pub #ident: <#ty as usage_argv::spec::ArgGroup>::Partial, }); } + if let Kind::Clause { ty, .. } = &f.kind { + let ident = &f.ident; + let current = format_ident!("__usage_current_{}", ident); + return Some(quote! { + pub #ident: ::std::vec::Vec<<#ty as usage_argv::spec::CommandArgs>::Partial>, + pub #current: <#ty as usage_argv::spec::CommandArgs>::Partial, + }); + } let ident = &f.ident; let ty = match f.shape { Shape::Bool => quote!(bool), @@ -4984,6 +5038,14 @@ fn partial_defaults(cli: &Cli) -> TokenStream { #ident: <#ty as usage_argv::spec::ArgGroup>::start(), }); } + if let Kind::Clause { ty, .. } = &f.kind { + let ident = &f.ident; + let current = format_ident!("__usage_current_{}", ident); + return Some(quote! { + #ident: ::std::vec::Vec::new(), + #current: <#ty as usage_argv::spec::CommandArgs>::start(), + }); + } let ident = &f.ident; let given = format_ident!("__given_{}", ident); let overridden = is_displaceable(cli, f).then(|| { @@ -5071,6 +5133,16 @@ fn field_value(field: &Field, omitter: Option<&TokenStream>) -> TokenStream { }, }; } + if let Kind::Clause { ty, .. } = &field.kind { + let current = format_ident!("__usage_current_{}", ident); + return quote! { + partial.#ident + .into_iter() + .chain(::core::iter::once(partial.#current)) + .map(<#ty as usage_argv::spec::CommandArgs>::build) + .collect::<::std::result::Result<::std::vec::Vec<_>, _>>()? + }; + } // The group's own `build` says which member was given; the field's type says what "none" // means. `check` has already reported both a second member and a required group with none, // so this arm is reached only for a group that was satisfied — the error stays for the @@ -5444,6 +5516,17 @@ fn merge_fn(cli: &Cli) -> TokenStream { } } }), + Kind::Clause { ty, .. } => { + let current = format_ident!("__usage_current_{}", field_ident); + let value = field_value(field, None); + Some(quote! { + if !partial.#field_ident.is_empty() + || <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#current).is_some() + { + __usage_standing.#field_ident = #value; + } + }) + } Kind::Flag { .. } | Kind::Arg { .. } => { let present = merge_present(field); let value = field_value(field, None); @@ -5751,6 +5834,30 @@ fn apply_fn(cli: &Cli) -> TokenStream { )) }) .collect(); + let clauses: Vec = cli + .fields + .iter() + .filter_map(|f| { + let Kind::Clause { ty, .. } = &f.kind else { + return None; + }; + let ident = &f.ident; + let current = format_ident!("__usage_current_{}", ident); + Some(quote! { + if let usage_argv::Event::ClauseSeparator { .. } = event { + let __usage_finished = ::core::mem::replace( + &mut partial.#current, + <#ty as usage_argv::spec::CommandArgs>::start(), + ); + partial.#ident.push(__usage_finished); + return true; + } + if <#ty as usage_argv::spec::CommandArgs>::apply(&mut partial.#current, event) { + return true; + } + }) + }) + .collect(); let mirrored_flattened = cli.fields.iter().filter_map(|f| { let Kind::Flatten { ty, .. } = &f.kind else { return None; @@ -5811,6 +5918,7 @@ fn apply_fn(cli: &Cli) -> TokenStream { #route #(#flattened)* #(#grouped)* + #(#clauses)* // Each arm evaluates to whether it claimed the event, rather than // returning: a command with no flags of its own would otherwise have every // arm diverge, leaving an unreachable tail. @@ -5849,6 +5957,7 @@ fn apply_fn(cli: &Cli) -> TokenStream { // Forwarded argv belongs to the catch-all variant, which the // subcommand route claims; this command's own flags do not. Event::External { .. } => false, + Event::ClauseSeparator { .. } => false, } } diff --git a/derive/src/model.rs b/derive/src/model.rs index 2ee81865f..4070d4c3e 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -662,6 +662,8 @@ pub enum Kind { /// Prefix that classifies this positional independently of declaration order. sigil: Option, }, + /// Holds repeatable instances of a positional-only `Args` struct. + Clause { ty: syn::Type, separator: String }, /// Holds the enum whose variants are this command's subcommands. /// /// The type is carried rather than resolved, because the derive cannot see the @@ -1681,6 +1683,7 @@ impl Cli { // this struct declares is invisible from either side. `Spec::to_kdl`'s // duplicate-form check is where the whole tree is visible. Kind::ArgGroup { .. } => {} + Kind::Clause { .. } => {} Kind::Skip => {} Kind::Arg { double_dash, sigil } => { if let Some(sigil) = sigil { @@ -1775,6 +1778,31 @@ impl Cli { } } + let clauses = self + .fields + .iter() + .filter(|field| matches!(field.kind, Kind::Clause { .. })) + .count(); + if clauses > 1 { + return Err(syn::Error::new( + self.ident.span(), + "a command may declare at most one clause", + )); + } + if clauses == 1 + && self.fields.iter().any(|field| { + matches!( + field.kind, + Kind::Arg { .. } | Kind::Subcommand { .. } | Kind::ArgGroup { .. } + ) + }) + { + return Err(syn::Error::new( + self.ident.span(), + "a clause cannot be combined with top-level arguments or subcommands", + )); + } + // What `run` on a *struct* can generate is a forwarder to its subcommands. A // container that holds only that field implements the trait; a root that also // declares flags gets `run_command`, which moves the subcommand out and leaves the @@ -2296,6 +2324,133 @@ impl Field { })) } + fn clause( + field: &syn::Field, + ident: &syn::Ident, + span: proc_macro2::Span, + ) -> syn::Result> { + let mut found = false; + let mut separator = None; + for attr in attrs(&field.attrs) { + for meta in nested(attr)? { + match ident_of(&meta.path().clone()).as_str() { + "clause" => { + if !matches!(meta, Meta::Path(_)) { + return Err(syn::Error::new_spanned( + meta.path(), + "`clause` takes no value", + )); + } + found = true; + } + "separator" => separator = Some(string_value(&meta)?), + _ => {} + } + } + } + if !found { + return Ok(None); + } + for attr in attrs(&field.attrs) { + for meta in nested(attr)? { + let name = ident_of(&meta.path().clone()); + if !matches!(name.as_str(), "clause" | "separator") { + return Err(syn::Error::new_spanned( + meta.path(), + format!("`clause` cannot be combined with `{name}`"), + )); + } + } + } + let separator = + separator.ok_or_else(|| syn::Error::new(span, "a clause needs `separator = \"…\"`"))?; + if separator.is_empty() || separator.starts_with('-') { + return Err(syn::Error::new( + span, + "a clause separator must be non-empty and cannot start with `-`", + )); + } + let ty = vec_element_type(&field.ty).ok_or_else(|| { + syn::Error::new_spanned( + &field.ty, + "a clause field must be `Vec` where `T` derives `Args`", + ) + })?; + Ok(Some(Field { + ident: ident.clone(), + ty: field.ty.clone(), + name: to_kebab(&ident.to_string()), + value_optional: false, + kind: Kind::Clause { ty, separator }, + effect: None, + complete: None, + complete_type: None, + shape: Shape::Many, + value_ty: None, + optional_collection: false, + optional_value_type: false, + help: None, + long_help: None, + admonitions: Vec::new(), + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, + env: None, + env_fallback: Vec::new(), + deprecated_env: Vec::new(), + setting: None, + default: Vec::new(), + default_value_t: None, + default_fn: None, + help_heading: None, + surface: None, + available_if: Vec::new(), + select: false, + display_order: None, + value_name: None, + value_names: Vec::new(), + required_collection: false, + choices: Vec::new(), + allow_unknown_choices: false, + validate: None, + validate_error: None, + value_enum: false, + var_min: None, + var_max: None, + value_var_min: None, + value_var_max: None, + overrides: Vec::new(), + conflicts: Vec::new(), + requires: Vec::new(), + requires_if: Vec::new(), + default_if: Vec::new(), + delimiter: None, + allow_hyphen_values: false, + allow_negative_numbers: false, + value_terminator: None, + require_equals: false, + bool_value: false, + default_missing: None, + exclusive: false, + group: None, + required_if: Vec::new(), + required_if_eq: Vec::new(), + required_if_eq_all: Vec::new(), + required_unless: Vec::new(), + required_unless_all: Vec::new(), + hide: false, + hide_default_value: false, + hide_env: false, + hide_env_values: false, + hide_possible_values: false, + hide_short_help: false, + hide_long_help: false, + repeatable: true, + action: ArgAction::Set, + span, + })) + } + /// A field marked `#[usage(arg_group)]`, if this is one. /// /// Recognized before flags and arguments for the same reason a flatten is: the field holds @@ -2617,6 +2772,9 @@ impl Field { if let Some(flattened) = Self::flatten(field, &ident, span)? { return Ok(flattened); } + if let Some(clause) = Self::clause(field, &ident, span)? { + return Ok(clause); + } if let Some(group) = Self::arg_group(field, &ident, span)? { return Ok(group); } @@ -4195,6 +4353,10 @@ fn peel(ty: &Type, wrapper: &str) -> Option { } } +fn vec_element_type(ty: &Type) -> Option { + peel(ty, "Vec") +} + /// A type as written, path and all, with the spaces `quote` leaves out. /// /// Distinct from [`type_name`], which keeps only the last segment: telling diff --git a/docs/spec/reference/clause.md b/docs/spec/reference/clause.md index e84705cc3..9708ea8c7 100644 --- a/docs/spec/reference/clause.md +++ b/docs/spec/reference/clause.md @@ -31,3 +31,24 @@ Version 1 deliberately keeps clauses narrow: Use [sigil arguments](./sigils.md) to classify independent prefixed values. Use a clause when several adjacent positional values form one repeatable unit. + +## Rust derive + +The outer field is a `Vec` and `T` derives `Args`: + +```rust +#[derive(usage::Args)] +struct TaskClause { + task: String, + #[usage(double_dash = "automatic")] + args: Vec, +} + +#[derive(usage::Cli)] +struct Run { + #[usage(clause, separator = ":::")] + tasks: Vec, +} +``` + +The compiled parser emits a clause-boundary event and builds each nested partial independently. diff --git a/go/argv/argv.go b/go/argv/argv.go index e7fa12df3..9c5279df5 100644 --- a/go/argv/argv.go +++ b/go/argv/argv.go @@ -49,6 +49,7 @@ type Command struct { Flags []*Flag // Args are positional arguments, in the order they are filled. Args []*Arg + Clause *Clause Subcommands []*Command // DefaultSubcommand is where a word goes when it names no subcommand of this // one. @@ -107,6 +108,14 @@ type Command struct { Key uint64 } +// Clause is a repeatable separator-delimited positional group. +type Clause struct { + Key uint64 + Name string + Separator string + Args []*Arg +} + // Flag is a flag, addressed by any of its long or short forms. type Flag struct { // Key is a caller-assigned identifier, echoed back in the event. This is how @@ -285,6 +294,8 @@ const ( KindFlag // KindArg means a word was bound to a positional argument. KindArg + // KindClauseSeparator ends one clause instance and begins the next. + KindClauseSeparator // KindExternal means an unmatched word was forwarded as an external command: // the name, then every remaining token, including flags. KindExternal @@ -307,6 +318,8 @@ type Event struct { Flag *Flag // Arg is set when Kind is KindArg. Arg *Arg + // Clause is set when Kind is KindClauseSeparator. + Clause *Clause // Value is the bound value. Meaningful for KindArg always, and for KindFlag // when HasValue is set. Value string diff --git a/go/argv/parser.go b/go/argv/parser.go index 60dc184b2..43fbe86dd 100644 --- a/go/argv/parser.go +++ b/go/argv/parser.go @@ -252,6 +252,15 @@ func (p *Parser) step() bool { token := p.argv[p.pos] p.pos++ + if !p.separatorSeen && p.cmd.Clause != nil && token == p.cmd.Clause.Separator { + p.argPos = 0 + p.argTaken = 0 + p.argFilled = false + p.collecting = nil + p.flagsStopped = false + return p.emit(Event{Kind: KindClauseSeparator, Clause: p.cmd.Clause}) + } + if p.flagsStopped { return p.word(token) } @@ -266,8 +275,9 @@ func (p *Parser) step() bool { p.separatorSeen = true // An explicit separator unlocks any argument that required one, even if // earlier arguments are still unfilled. - for i := p.argPos; i < len(p.cmd.Args); i++ { - if p.cmd.Args[i].DoubleDash == DoubleDashRequired { + args := p.currentArgs() + for i := p.argPos; i < len(args); i++ { + if args[i].DoubleDash == DoubleDashRequired { // The count belongs to the argument at argPos, so jumping past it has to // leave the count behind: a bounded variadic before the separator would // otherwise lend its total to the argument after it, which then stops @@ -732,17 +742,24 @@ func valuesIn(value string, delimiter byte) uint32 { return count } +func (p *Parser) currentArgs() []*Arg { + if p.cmd.Clause != nil { + return p.cmd.Clause.Args + } + return p.cmd.Args +} + func (p *Parser) nextArg() *Arg { - for i := p.argPos; i < len(p.cmd.Args); i++ { - if p.cmd.Args[i].Sigil == "" { - return p.cmd.Args[i] + for i := p.argPos; i < len(p.currentArgs()); i++ { + if p.currentArgs()[i].Sigil == "" { + return p.currentArgs()[i] } } return nil } func (p *Parser) skipSigilArgs() { - for p.argPos < len(p.cmd.Args) && p.cmd.Args[p.argPos].Sigil != "" { + for p.argPos < len(p.currentArgs()) && p.currentArgs()[p.argPos].Sigil != "" { p.argPos++ } } @@ -753,17 +770,17 @@ func (p *Parser) matchSigilArg(token string) (*Arg, string) { } var best *Arg bestSigil := "" - match := func(cmd *Command) { - for _, arg := range cmd.Args { + match := func(args []*Arg) { + for _, arg := range args { if arg.Sigil != "" && len(token) >= len(arg.Sigil) && len(arg.Sigil) > len(bestSigil) && token[:len(arg.Sigil)] == arg.Sigil { best, bestSigil = arg, arg.Sigil } } } - match(p.cmd) + match(p.currentArgs()) for i := p.depth - 1; i >= 0; i-- { if p.ancestors[i] != nil { - match(p.ancestors[i]) + match(p.ancestors[i].Args) } } return best, bestSigil @@ -776,9 +793,9 @@ func (p *Parser) reserveForRequiredPositionals() { if !p.cmd.AllowMissingPositional || p.argTaken != 0 { return } - for p.argPos < len(p.cmd.Args) && !p.cmd.Args[p.argPos].Required { + for p.argPos < len(p.currentArgs()) && !p.currentArgs()[p.argPos].Required { requiredAfter := 0 - for _, arg := range p.cmd.Args[p.argPos+1:] { + for _, arg := range p.currentArgs()[p.argPos+1:] { if arg.Required && arg.Sigil == "" { requiredAfter++ } diff --git a/go/conformance/conformance_test.go b/go/conformance/conformance_test.go index 939495841..21dc7c454 100644 --- a/go/conformance/conformance_test.go +++ b/go/conformance/conformance_test.go @@ -65,10 +65,11 @@ type Expect struct { // the spec gives each flag or argument, never by the token that set it, so -j, // --jobs and an env var all land under `jobs`. type Parsed struct { - Cmd []string `json:"cmd"` - Flags map[string]interface{} `json:"flags"` - Args map[string]interface{} `json:"args"` - External []string `json:"external,omitempty"` + Cmd []string `json:"cmd"` + Flags map[string]interface{} `json:"flags"` + Args map[string]interface{} `json:"args"` + Clauses map[string][]map[string]interface{} `json:"clauses,omitempty"` + External []string `json:"external,omitempty"` } type file struct { @@ -290,6 +291,14 @@ func run(s *spec.Spec, args []string, argv0 *string, env map[string]string) (*Pa } return got[key] } + clauseInstances := map[string][]map[uint64]*bound{} + currentClause := map[uint64]*bound{} + clauseEntry := func(key uint64) *bound { + if currentClause[key] == nil { + currentClause[key] = &bound{} + } + return currentClause[key] + } // The commands whose declarations are in scope. A required flag on a command // nobody selected is not missing; it is simply not this invocation's. @@ -315,13 +324,22 @@ func run(s *spec.Spec, args []string, argv0 *string, env map[string]string) (*Pa case argv.KindArg: seen++ b := entry(ev.Arg.Key) + if p.Command().Clause != nil { + b = clauseEntry(ev.Arg.Key) + } b.occurrences++ b.at = seen b.values = append(b.values, argv.SplitValue(ev.Value, ev.Arg.Delimiter, ev.Delimit)...) + case argv.KindClauseSeparator: + clauseInstances[ev.Clause.Name] = append(clauseInstances[ev.Clause.Name], currentClause) + currentClause = map[uint64]*bound{} case argv.KindExternal: external = append(external, ev.Values...) } } + if p.Command().Clause != nil { + clauseInstances[p.Command().Clause.Name] = append(clauseInstances[p.Command().Clause.Name], currentClause) + } if err := p.Err(); err != nil { e, ok := err.(*argv.Error) if !ok { @@ -334,8 +352,29 @@ func run(s *spec.Spec, args []string, argv0 *string, env map[string]string) (*Pa Cmd: []string{}, Flags: map[string]interface{}{}, Args: map[string]interface{}{}, + Clauses: map[string][]map[string]interface{}{}, External: external, } + for _, cmd := range path { + if cmd.Clause == nil { + continue + } + for _, instance := range clauseInstances[cmd.Clause.Name] { + values := map[string]interface{}{} + for _, arg := range cmd.Clause.Args { + b := instance[arg.Key] + if b == nil { + continue + } + if arg.Var { + values[arg.Name] = b.values + } else if len(b.values) > 0 { + values[arg.Name] = b.values[len(b.values)-1] + } + } + out.Clauses[cmd.Clause.Name] = append(out.Clauses[cmd.Clause.Name], values) + } + } for _, cmd := range path[1:] { out.Cmd = append(out.Cmd, cmd.Name) } @@ -574,7 +613,7 @@ func bools(v interface{}) []interface{} { return strs(v) } // root. Likewise for the two maps. The corpus is the definition of correct about // bindings, not about which of two spellings of "nothing" a decoder produces. func normalizeExpected(p *Parsed) *Parsed { - out := &Parsed{Cmd: p.Cmd, Flags: p.Flags, Args: p.Args, External: p.External} + out := &Parsed{Cmd: p.Cmd, Flags: p.Flags, Args: p.Args, Clauses: p.Clauses, External: p.External} if out.Cmd == nil { out.Cmd = []string{} } @@ -584,6 +623,22 @@ func normalizeExpected(p *Parsed) *Parsed { if out.Args == nil { out.Args = map[string]interface{}{} } + if out.Clauses == nil { + out.Clauses = map[string][]map[string]interface{}{} + } + for _, instances := range out.Clauses { + for _, instance := range instances { + for name, value := range instance { + if raw, ok := value.([]interface{}); ok { + strings := make([]string, len(raw)) + for i, item := range raw { + strings[i] = item.(string) + } + instance[name] = strings + } + } + } + } return out } diff --git a/go/internal/spec/spec.go b/go/internal/spec/spec.go index 9527f7fff..53793fffb 100644 --- a/go/internal/spec/spec.go +++ b/go/internal/spec/spec.go @@ -111,6 +111,7 @@ type Cmd struct { HiddenAliases []string `json:"hidden_aliases"` Subcommands Subcommands `json:"subcommands"` Args []Arg `json:"args"` + Clause *Clause `json:"clause"` Flags []Flag `json:"flags"` Mounts []Mount `json:"mounts"` UnknownFlags *string `json:"unknown_flags"` @@ -132,6 +133,12 @@ type Cmd struct { FlattenHelp bool `json:"flatten_help"` } +type Clause struct { + Name string `json:"name"` + Separator string `json:"separator"` + Args []Arg `json:"args"` +} + // Mount is a command whose stdout contributes another spec at this command. type Mount struct { Run string `json:"run"` @@ -759,6 +766,12 @@ func (b *builder) command(c *Cmd, inherited argv.UnknownFlags) *argv.Command { for i := range c.Args { out.Args = append(out.Args, b.arg(&c.Args[i])) } + if c.Clause != nil { + out.Clause = &argv.Clause{Key: b.next(), Name: c.Clause.Name, Separator: c.Clause.Separator} + for i := range c.Clause.Args { + out.Clause.Args = append(out.Clause.Args, b.arg(&c.Clause.Args[i])) + } + } // After the flags, because a relationship names a sibling and every sibling // needs a key before any of them can be pointed at. b.resolveRelationships(c, out) diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index efd868207..da75ced67 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -193,6 +193,22 @@ impl<'a> Emitter<'a> { .iter() .map(|a| (a.clone(), self.name("Arg", path, &a.name))) .collect::>(); + let clause_args = cmd + .clause + .as_ref() + .map(|clause| { + clause + .args + .iter() + .map(|a| { + ( + a.clone(), + self.name("Arg", path, &format!("{}-{}", clause.name, a.name)), + ) + }) + .collect::>() + }) + .unwrap_or_default(); let index = out.len(); out.push(Emitted { @@ -200,6 +216,7 @@ impl<'a> Emitter<'a> { cmd: cmd.clone(), flags, args, + clause_args, subcommands: Vec::new(), root, }); @@ -278,6 +295,11 @@ impl<'a> Emitter<'a> { entries.push((&e.named.key, e.named.number)); entries.extend(e.flags.iter().map(|(_, n)| (n.key.as_str(), n.number))); entries.extend(e.args.iter().map(|(_, n)| (n.key.as_str(), n.number))); + entries.extend( + e.clause_args + .iter() + .map(|(_, n)| (n.key.as_str(), n.number)), + ); } // One run, so every name pads to the longest — which is what gofmt does to // a const block with no blank line in it. @@ -363,6 +385,18 @@ impl<'a> Emitter<'a> { block.push("},".to_string()); lines.push(Line::Block(block)); } + if let Some(clause) = &e.cmd.clause { + let mut block = vec!["Clause: &argv.Clause{".to_string()]; + block.push(format!("\tName: {},", go_string(&clause.name))); + block.push(format!("\tSeparator: {},", go_string(&clause.separator))); + block.push("\tArgs: []*argv.Arg{".to_string()); + for (arg, named) in &e.clause_args { + block.push(format!("\t\t{},", arg_literal(arg, named))); + } + block.push("\t},".to_string()); + block.push("},".to_string()); + lines.push(Line::Block(block)); + } if !e.subcommands.is_empty() { let list = e @@ -1364,6 +1398,7 @@ struct Emitted { cmd: SpecCommand, flags: Vec<(SpecFlag, Named)>, args: Vec<(SpecArg, Named)>, + clause_args: Vec<(SpecArg, Named)>, /// Indices into the flat list, in declaration order. subcommands: Vec, root: bool, From 899b2d62843ae43368d57133faba319fb672ba2b Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:53:09 +0000 Subject: [PATCH 2/8] fix(argv): include clause arguments in help --- argv/src/help.rs | 120 +++++++++++++++++++++++++++++------- conformance/tests/clause.rs | 13 ++++ 2 files changed, 111 insertions(+), 22 deletions(-) diff --git a/argv/src/help.rs b/argv/src/help.rs index 7c5e0b9fa..37329d9b3 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -574,8 +574,9 @@ fn help_structure( !arg.hide_short_help } }; - let mut args: Vec<_> = meta.args.iter().filter(visible_arg).collect(); - order_args(&mut args, meta.args); + let positional_args = positional_args(meta); + let mut args: Vec<_> = positional_args.iter().filter(visible_arg).collect(); + order_args(&mut args, positional_args); if args.iter().any(|arg| arg.help_heading.is_none()) { headings.push("Arguments".to_string()); } @@ -645,7 +646,7 @@ fn flat_help_usages( order_commands(&mut visible); for sub in visible { arg_usages.extend( - sub.args + positional_args(sub) .iter() .filter(|arg| { !arg.hide @@ -834,6 +835,10 @@ pub fn usage_line(path: &[&str], meta: &CommandMeta<'_>) -> String { usage_line_with_subcommands(path, meta, true) } +fn positional_args<'a>(meta: &'a CommandMeta<'a>) -> &'a [ArgMeta<'a>] { + meta.clause.map_or(meta.args, |clause| clause.args) +} + fn usage_line_with_subcommands( path: &[&str], meta: &CommandMeta<'_>, @@ -873,11 +878,20 @@ fn usage_line_with_subcommands( } } - let args: usize = meta.args.iter().filter(|a| !a.hide).count(); + let positional_args = positional_args(meta); + let args: usize = positional_args.iter().filter(|a| !a.hide).count(); if args > 0 { - let required = meta.args.iter().any(|a| !a.hide && demanded(a)); - if args <= INLINE_LIMIT { - for arg in meta.args.iter().filter(|a| !a.hide) { + let required = positional_args.iter().any(|a| !a.hide && demanded(a)); + if let Some(clause) = meta.clause { + let inner = positional_args + .iter() + .filter(|a| !a.hide) + .map(arg_usage) + .collect::>() + .join(" "); + let _ = write!(out, " {inner} [{} {inner}]…", clause.separator); + } else if args <= INLINE_LIMIT { + for arg in positional_args.iter().filter(|a| !a.hide) { let _ = write!(out, " {}", arg_usage(arg)); } } else if required { @@ -1292,12 +1306,12 @@ fn short_sections( // after the name it belonged to, so nothing in `-h` lined up with anything — and `-h` is // the form most people type. One column per section over its visible entries, which is // the rule the long page already follows. - let mut args: Vec<&ArgMeta<'_>> = meta - .args + let positional_args = positional_args(meta); + let mut args: Vec<&ArgMeta<'_>> = positional_args .iter() .filter(|a| !a.hide && !a.hide_short_help) .collect(); - order_args(&mut args, meta.args); + order_args(&mut args, positional_args); let arg_col = args .iter() .map(|a| arg_usage(a).chars().count()) @@ -1635,12 +1649,12 @@ fn flat_commands_short(out: &mut String, path: &[&str], meta: &CommandMeta<'_>, } command_deprecation(out, sub, 0, width); - let mut args: Vec<_> = sub - .args + let positional_args = positional_args(sub); + let mut args: Vec<_> = positional_args .iter() .filter(|arg| !arg.hide && !arg.hide_short_help) .collect(); - order_args(&mut args, sub.args); + order_args(&mut args, positional_args); let mut flags: Vec<&FlagMeta<'_>> = sub .flags .iter() @@ -2237,12 +2251,12 @@ fn long_sections( // One column width per section, over its visible entries — the same two the reference // computes, and separately, so a long flag does not push the arguments out. - let mut args: Vec<&ArgMeta<'_>> = meta - .args + let positional_args = positional_args(meta); + let mut args: Vec<&ArgMeta<'_>> = positional_args .iter() .filter(|a| !a.hide && !a.hide_long_help) .collect(); - order_args(&mut args, meta.args); + order_args(&mut args, positional_args); let arg_col = args .iter() .map(|a| arg_usage(a).chars().count()) @@ -2833,12 +2847,12 @@ fn flat_commands_long(out: &mut String, path: &[&str], meta: &CommandMeta<'_>, w } command_deprecation(out, sub, 0, width); - let mut args: Vec<_> = sub - .args + let positional_args = positional_args(sub); + let mut args: Vec<_> = positional_args .iter() .filter(|arg| !arg.hide && !arg.hide_long_help) .collect(); - order_args(&mut args, sub.args); + order_args(&mut args, positional_args); let mut flags: Vec<&FlagMeta<'_>> = sub .flags .iter() @@ -3872,10 +3886,72 @@ mod style_tests { use super::{ commands_section, display_usage_masked, flag_notes, flag_usage, flat_commands_short, inline_environment_notes, long_help, render_styled, render_view_at_styled, - styled_flag_usage, styled_help, styled_inline, wrap, Shown, Style, + styled_flag_usage, styled_help, styled_inline, usage_line, wrap, Shown, Style, }; - use crate::spec::{ArgMeta, CommandMeta, FlagMeta, Spec, ViewMeta}; - use crate::{Arg, ArgAction, Command, Flag}; + use crate::spec::{ArgMeta, ClauseMeta, CommandMeta, FlagMeta, Spec, ViewMeta}; + use crate::{Arg, ArgAction, Clause, Command, Flag}; + + #[test] + fn compiled_clause_arguments_appear_in_usage_and_help() { + let task = Arg { + name: "TASK", + ..Arg::REQUIRED + }; + let args = Arg { + name: "ARGS", + required: false, + var: true, + ..Arg::REQUIRED + }; + let command = Command { + name: "run", + clause: Some(Clause { + key: 0, + name: "tasks", + separator: b":::", + args: &[&task, &args], + }), + ..Command::EMPTY + }; + let task_meta = ArgMeta { + arg: &task, + required: true, + help: Some("Task to run"), + ..ArgMeta::EMPTY + }; + let args_meta = ArgMeta { + arg: &args, + required: false, + var_min: Some(0), + help: Some("Arguments for the task"), + ..ArgMeta::EMPTY + }; + let meta = CommandMeta { + cmd: &command, + clause: Some(ClauseMeta { + name: "tasks", + separator: ":::", + help: None, + long_help: None, + args: &[task_meta, args_meta], + }), + ..CommandMeta::EMPTY + }; + let spec = Spec { + name: "ex", + root: &meta, + ..Spec::EMPTY + }; + + assert_eq!( + usage_line(&["ex"], &meta), + "ex [ARGS]… [::: [ARGS]…]…" + ); + let help = super::short_help(&spec, &["ex"], &[&meta]); + assert!(help.contains("Arguments:"), "{help}"); + assert!(help.contains(""), "{help}"); + assert!(help.contains("[ARGS]…"), "{help}"); + } #[test] fn nested_lists_keep_their_hanging_indent() { diff --git a/conformance/tests/clause.rs b/conformance/tests/clause.rs index 2d0b07a1b..038daa973 100644 --- a/conformance/tests/clause.rs +++ b/conformance/tests/clause.rs @@ -213,3 +213,16 @@ fn derive_collects_each_clause_instance() { let emitted = TypedClause::to_kdl(); assert!(emitted.contains("clause tasks separator=:::"), "{emitted}"); } + +#[test] +fn derived_clause_arguments_appear_in_compiled_help() { + let spec = TypedClause::spec(); + let usage = usage_argv::help::usage_line(&["typed-clause"], spec.root); + assert!(usage.contains(""), "{usage}"); + assert!(usage.contains(":::"), "{usage}"); + + let help = usage_argv::help::short_help(spec, &["typed-clause"], &[spec.root]); + assert!(help.contains("Arguments:"), "{help}"); + assert!(help.contains(""), "{help}"); + assert!(help.contains("[ARGS]…"), "{help}"); +} From 6beb5a6f07c90b93c60845b9449e7396f8fd7d3c Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:50:02 +0000 Subject: [PATCH 3/8] fix(go): include clause args in cold tables --- lib/src/go/mod.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index da75ced67..e9bac67de 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -498,11 +498,14 @@ impl Emitter<'_> { for (arg, named) in &e.args { by_key.insert(named.number, arg_meta(self.spec, arg, named, e, commands)); } + for (arg, named) in &e.clause_args { + by_key.insert(named.number, arg_meta(self.spec, arg, named, e, commands)); + } } let total = commands .iter() - .map(|e| 1 + e.flags.len() + e.args.len()) + .map(|e| 1 + e.flags.len() + e.args.len() + e.clause_args.len()) .sum::() as u64; let _ = writeln!( @@ -985,11 +988,14 @@ impl Emitter<'_> { for (arg, named) in &e.args { by_key.insert(named.number, arg_help(arg, named)); } + for (arg, named) in &e.clause_args { + by_key.insert(named.number, arg_help(arg, named)); + } } let total = commands .iter() - .map(|e| 1 + e.flags.len() + e.args.len()) + .map(|e| 1 + e.flags.len() + e.args.len() + e.clause_args.len()) .sum::() as u64; let _ = writeln!( @@ -2265,6 +2271,48 @@ cmd "run" arg_required_else_help=#true { assert!(strict.contains("RejectDuplicate: true"), "{strict}"); } + #[test] + fn clause_args_and_later_entries_reach_go_cold_tables() { + let out = go(r#" +name "ex" +bin "ex" +cmd "run" { + clause "items" separator=":::" { + arg "" help="Task to run" + } +} +cmd "later" { + flag "--mode " help="Later flag" +} +"#); + let meta = out + .split_once("var Meta = argv.Metadata{") + .and_then(|(_, rest)| rest.split_once("var HelpText = argv.HelpTable{")) + .map(|(meta, _)| meta) + .expect("generated metadata and help tables"); + let help = out + .split_once("var HelpText = argv.HelpTable{") + .and_then(|(_, rest)| rest.split_once("var HelpMeta = argv.HelpSpec{")) + .map(|(help, _)| help) + .expect("generated help table and metadata"); + + assert!( + meta.contains("Key: ArgRunItemsTask, Name: \"task\""), + "{out}" + ); + assert!(meta.contains("Key: FlagLaterMode, Name: \"mode\""), "{out}"); + assert!( + help.contains("Key: ArgRunItemsTask, Demanded: true"), + "{out}" + ); + assert!( + help.lines().any(|line| { + line.contains("Key: FlagLaterMode") && line.contains("Short: \"Later flag\"") + }), + "{out}" + ); + } + #[test] fn strict_negated_flags_track_each_spelling_separately() { let out = go( From 9eabdda7b7535e53c465cc1a6180aac61ae494f8 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:19:56 +0000 Subject: [PATCH 4/8] fix(go): include clause arguments in help --- go/argv/argv.go | 9 +++++++++ go/argv/help.go | 16 +++++++++++++--- go/argv/page.go | 5 +++-- go/argv/parser.go | 5 +---- go/argv/sections_test.go | 22 ++++++++++++++++++++++ 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/go/argv/argv.go b/go/argv/argv.go index 9c5279df5..b877f9338 100644 --- a/go/argv/argv.go +++ b/go/argv/argv.go @@ -116,6 +116,15 @@ type Clause struct { Args []*Arg } +// positionalArgs is the positional grammar a command exposes. A clause replaces +// ordinary arguments rather than supplementing them. +func positionalArgs(cmd *Command) []*Arg { + if cmd.Clause != nil { + return cmd.Clause.Args + } + return cmd.Args +} + // Flag is a flag, addressed by any of its long or short forms. type Flag struct { // Key is a caller-assigned identifier, echoed back in the event. This is how diff --git a/go/argv/help.go b/go/argv/help.go index c9b1e2ce3..32a0c85f8 100644 --- a/go/argv/help.go +++ b/go/argv/help.go @@ -174,9 +174,10 @@ func usageLine(path []string, cmd *Command, help HelpTable, includeSubcommands b } } - visibleArgs := make([]*Arg, 0, len(cmd.Args)) + args := positionalArgs(cmd) + visibleArgs := make([]*Arg, 0, len(args)) demandedArg := false - for _, a := range cmd.Args { + for _, a := range args { h := help.Lookup(a.Key) if h != nil && h.Hide { continue @@ -187,7 +188,16 @@ func usageLine(path []string, cmd *Command, help HelpTable, includeSubcommands b } } if n := len(visibleArgs); n > 0 { - if n <= inlineLimit { + if cmd.Clause != nil { + var inner strings.Builder + for i, a := range visibleArgs { + if i > 0 { + inner.WriteByte(' ') + } + inner.WriteString(argUsage(a, help.Lookup(a.Key))) + } + out.WriteString(" " + inner.String() + " [" + cmd.Clause.Separator + " " + inner.String() + "]…") + } else if n <= inlineLimit { for _, a := range visibleArgs { out.WriteString(" " + argUsage(a, help.Lookup(a.Key))) } diff --git a/go/argv/page.go b/go/argv/page.go index ae613c38e..56dfc6c5d 100644 --- a/go/argv/page.go +++ b/go/argv/page.go @@ -677,8 +677,9 @@ func orderFlags(flags []*Flag, help HelpTable) { } func visibleArgs(cmd *Command, help HelpTable, long bool) []*Arg { - out := make([]*Arg, 0, len(cmd.Args)) - for _, a := range cmd.Args { + args := positionalArgs(cmd) + out := make([]*Arg, 0, len(args)) + for _, a := range args { if h := help.Lookup(a.Key); h != nil { if h.Hide || (!long && h.HideShortHelp) || (long && h.HideLongHelp) { continue diff --git a/go/argv/parser.go b/go/argv/parser.go index 43fbe86dd..e71391d13 100644 --- a/go/argv/parser.go +++ b/go/argv/parser.go @@ -743,10 +743,7 @@ func valuesIn(value string, delimiter byte) uint32 { } func (p *Parser) currentArgs() []*Arg { - if p.cmd.Clause != nil { - return p.cmd.Clause.Args - } - return p.cmd.Args + return positionalArgs(p.cmd) } func (p *Parser) nextArg() *Arg { diff --git a/go/argv/sections_test.go b/go/argv/sections_test.go index a495ed1a3..e25414f41 100644 --- a/go/argv/sections_test.go +++ b/go/argv/sections_test.go @@ -277,6 +277,28 @@ func TestAPageWithoutATemplateIsUnchanged(t *testing.T) { } } +func TestClauseArgumentsAppearInUsageAndHelp(t *testing.T) { + task := &Arg{Key: 2, Name: "task", Required: true} + args := &Arg{Key: 3, Name: "args", Var: true} + root := &Command{ + Name: "ex", Key: 1, + Clause: &Clause{Name: "tasks", Separator: ":::", Args: []*Arg{task, args}}, + } + help := helpKeyed( + Help{Key: 2, Short: "Task to run", Demanded: true}, + Help{Key: 3, Short: "Arguments for the task"}, + ) + if got, want := UsageLine([]string{"ex"}, root, help), "ex [args]… [::: [args]…]…"; got != want { + t.Fatalf("usage differs\n got: %s\nwant: %s", got, want) + } + page := ShortHelp(HelpSpec{Name: "ex", Bin: "ex"}, []string{"ex"}, []*Command{root}, help) + for _, text := range []string{"Arguments:", "", "Task to run", "[args]…", "Arguments for the task"} { + if !strings.Contains(page, text) { + t.Fatalf("missing %q:\n%s", text, page) + } + } +} + func TestAPlaceholderNamingNoSectionIsLeftAlone(t *testing.T) { force := &Flag{Key: 2, Name: "force", Longs: []string{"force"}} root := &Command{Name: "ex", Key: 1, Flags: []*Flag{force}} From 15c51160a9104495ec76affa7cb86be5b34094f8 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:19:56 +0000 Subject: [PATCH 5/8] fix(go): bind generated clause instances --- lib/src/go/mod.rs | 12 +++ lib/src/go/structs.rs | 176 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 4 deletions(-) diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index e9bac67de..43c0c8a3a 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -387,6 +387,7 @@ impl<'a> Emitter<'a> { } if let Some(clause) = &e.cmd.clause { let mut block = vec!["Clause: &argv.Clause{".to_string()]; + block.push(format!("\tKey: {},", e.named.key)); block.push(format!("\tName: {},", go_string(&clause.name))); block.push(format!("\tSeparator: {},", go_string(&clause.separator))); block.push("\tArgs: []*argv.Arg{".to_string()); @@ -845,6 +846,7 @@ fn resolve_relationship(names: &[String], owner: &Emitted, commands: &[Emitted]) found = owner .args .iter() + .chain(owner.clause_args.iter()) .find(|(arg, _)| arg.name == *name) .map(|(_, named)| named.key.clone()); } @@ -2311,6 +2313,16 @@ cmd "later" { }), "{out}" ); + for generated in [ + "type RunCmdItemsClause struct {", + "Items []RunCmdItemsClause // clause items", + "case argv.KindClauseSeparator:", + "for _, instance := range clauseInstances[CmdRun] {", + "if err := argv.Check(Meta.Lookup(key), values, 0); err != nil {", + "cmdRunV.Items = append(cmdRunV.Items, item)", + ] { + assert!(out.contains(generated), "missing {generated:?}:\n{out}"); + } } #[test] diff --git a/lib/src/go/structs.rs b/lib/src/go/structs.rs index d5708b0dd..bb0519fbf 100644 --- a/lib/src/go/structs.rs +++ b/lib/src/go/structs.rs @@ -23,10 +23,59 @@ use crate::{SpecArg, SpecFlag}; /// two cannot disagree about where a value goes. type Fields = HashMap; +struct ClauseFields { + field: String, + type_name: String, + args: Fields, +} + +type Clauses = HashMap; + /// Write every command's struct, then `Parse`. pub(super) fn emit(out: &mut String, commands: &[Emitted]) { let mut assigned: Fields = HashMap::new(); + let mut clauses: Clauses = HashMap::new(); for e in commands { + let clause_type = e.cmd.clause.as_ref().map(|clause| { + let type_name = format!("{}{}Clause", name(e), field_name(&clause.name)); + let mut args = Fields::new(); + let mut taken = HashSet::new(); + let _ = writeln!( + out, + "// {type_name} is one `{}` clause instance.", + clause.name + ); + let _ = writeln!(out, "type {type_name} struct {{"); + let fields = e + .clause_args + .iter() + .map(|(arg, named)| { + let base = field_name(&arg.name); + let field = if taken.insert(base.clone()) { + base + } else { + let mut n = 2; + loop { + let candidate = format!("{base}{n}"); + if taken.insert(candidate.clone()) { + break candidate; + } + n += 1; + } + }; + args.insert(named.key.clone(), field.clone()); + (field, arg_type(arg), named.key.as_str()) + }) + .collect::>(); + let name_col = fields.iter().map(|(n, _, _)| n.len()).max().unwrap_or(0); + let type_col = fields.iter().map(|(_, t, _)| t.len()).max().unwrap_or(0); + for (field, ty, key) in fields { + let _ = writeln!(out, "\t{field:>(); + let key_list = keys.join(", "); + let owner = owner_of(e); + let _ = writeln!( + out, + "\t{{\n\t\tclauseScope := []uint64{{{key_list}}}\n\ + \t\tfor _, instance := range clauseInstances[{}] {{\n\ + \t\t\tclauseSources := map[uint64]argv.Source{{}}\n\ + \t\t\tfor _, key := range clauseScope {{\n\ + \t\t\t\tvalues := instance[key]\n\ + \t\t\t\tif values != nil {{\n\t\t\t\t\tclauseSources[key] = argv.FromArgv\n\t\t\t\t}} else {{\n\t\t\t\t\tclauseSources[key] = argv.Unset\n\t\t\t\t}}\n\ + \t\t\t\tif err := argv.Check(Meta.Lookup(key), values, 0); err != nil {{\n\t\t\t\t\treturn nil, err\n\t\t\t\t}}\n\ + \t\t\t}}\n\ + \t\t\tinstanceScope := append(append([]uint64{{}}, scope...), clauseScope...)\n", + e.named.key + ); + if has_relationship_values { + let _ = writeln!( + out, + "\t\t\tif err := argv.CheckRelationshipsWithValuesAndRequirements(Meta, instanceScope, func(k uint64) argv.Source {{\n\ + \t\t\t\tswitch k {{\n\t\t\t\tcase {key_list}:\n\t\t\t\t\treturn clauseSources[k]\n\t\t\t\tdefault:\n\t\t\t\t\treturn sources[k]\n\t\t\t\t}}\n\ + \t\t\t}}, func(k uint64) []string {{\n\ + \t\t\t\tswitch k {{\n\t\t\t\tcase {key_list}:\n\t\t\t\t\treturn argv.RelationshipValues(Meta.Lookup(k), instance[k], clauseSources[k], false)\n\t\t\t\tdefault:\n\t\t\t\t\treturn argv.RelationshipValues(Meta.Lookup(k), resolved[k], sources[k], negated[k])\n\t\t\t\t}}\n\ + \t\t\t}}, func(k uint64) bool {{\n\ + \t\t\t\tswitch k {{\n\t\t\t\tcase {key_list}:\n\t\t\t\t\treturn true\n\t\t\t\tdefault:\n\t\t\t\t\treturn requirements[k]\n\t\t\t\t}}\n\ + \t\t\t}}); err != nil {{\n\t\t\t\treturn nil, err\n\t\t\t}}" + ); + } else { + let _ = writeln!( + out, + "\t\t\tif err := argv.CheckRelationshipsWithValuesAndRequirements(Meta, instanceScope, func(k uint64) argv.Source {{\n\ + \t\t\t\tswitch k {{\n\t\t\t\tcase {key_list}:\n\t\t\t\t\treturn clauseSources[k]\n\t\t\t\tdefault:\n\t\t\t\t\treturn sources[k]\n\t\t\t\t}}\n\ + \t\t\t}}, nil, func(k uint64) bool {{\n\ + \t\t\t\tswitch k {{\n\t\t\t\tcase {key_list}:\n\t\t\t\t\treturn true\n\t\t\t\tdefault:\n\t\t\t\t\treturn requirements[k]\n\t\t\t\t}}\n\ + \t\t\t}}); err != nil {{\n\t\t\t\treturn nil, err\n\t\t\t}}" + ); + } + let _ = writeln!(out, "\t\t\titem := {}{{}}", fields.type_name); + for (arg, named) in &e.clause_args { + let field = &fields.args[&named.key]; + let assign = if arg.var { + format!("item.{field} = append(item.{field}, values...)") + } else { + format!("item.{field} = values[len(values)-1]") + }; + let _ = writeln!( + out, + "\t\t\tif values := instance[{}]; len(values) > 0 {{\n\t\t\t\t{assign}\n\t\t\t}}", + named.key + ); + } + let _ = writeln!( + out, + "\t\t\t{owner}.{} = append({owner}.{}, item)\n\t\t}}\n\t}}", + fields.field, fields.field + ); + } +} + /// The cases that put an `env` or `default` value into its field. fn fallback_cases(out: &mut String, commands: &[Emitted], assigned: &Fields) { for e in commands { From 540a079e1d0d0778960997c4b72c978eb8174695 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:30:28 +0000 Subject: [PATCH 6/8] fix(go): defer clause-dependent flag relationships --- lib/src/go/mod.rs | 18 ++++++++++- lib/src/go/structs.rs | 71 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index 43c0c8a3a..23d57895c 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -2279,6 +2279,7 @@ cmd "run" arg_required_else_help=#true { name "ex" bin "ex" cmd "run" { + flag "--needs-task" requires="task" clause "items" separator=":::" { arg "" help="Task to run" } @@ -2302,6 +2303,13 @@ cmd "later" { meta.contains("Key: ArgRunItemsTask, Name: \"task\""), "{out}" ); + assert!( + meta.lines().any(|line| { + line.contains("Key: FlagRunNeedsTask") + && line.contains("Requires: []uint64{ArgRunItemsTask}") + }), + "{out}" + ); assert!(meta.contains("Key: FlagLaterMode, Name: \"mode\""), "{out}"); assert!( help.contains("Key: ArgRunItemsTask, Demanded: true"), @@ -2315,14 +2323,22 @@ cmd "later" { ); for generated in [ "type RunCmdItemsClause struct {", - "Items []RunCmdItemsClause // clause items", "case argv.KindClauseSeparator:", "for _, instance := range clauseInstances[CmdRun] {", "if err := argv.Check(Meta.Lookup(key), values, 0); err != nil {", + "clauseSources := map[uint64]argv.Source{ArgRunItemsTask: argv.Unset}", "cmdRunV.Items = append(cmdRunV.Items, item)", ] { assert!(out.contains(generated), "missing {generated:?}:\n{out}"); } + assert!( + out.lines().any(|line| { + line.contains("Items") + && line.contains("[]RunCmdItemsClause") + && line.contains("// clause items") + }), + "{out}" + ); } #[test] diff --git a/lib/src/go/structs.rs b/lib/src/go/structs.rs index bb0519fbf..1b582c19e 100644 --- a/lib/src/go/structs.rs +++ b/lib/src/go/structs.rs @@ -455,19 +455,77 @@ fn parse_fn(out: &mut String, commands: &[Emitted], assigned: &Fields, clauses: ); fallback_cases(out, commands, assigned); let _ = writeln!(out, "\t\t\t}}\n\t\t}}\n\t}}"); + let clause_keys = commands + .iter() + .flat_map(|command| { + command + .clause_args + .iter() + .map(|(_, named)| named.key.as_str()) + }) + .collect::>() + .join(", "); + if !clauses.is_empty() { + let initial_sources = commands + .iter() + .flat_map(|command| command.clause_args.iter()) + .map(|(_, named)| format!("{}: argv.Unset", named.key)) + .collect::>() + .join(", "); + let aggregate_values = if has_relationship_values { + "\t\t\tclauseValues[key] = append(clauseValues[key], values...)\n" + } else { + "" + }; + let aggregate_binding = if has_relationship_values { + "key, values" + } else { + "key" + }; + let values_state = if has_relationship_values { + "\tclauseValues := map[uint64][]string{}\n" + } else { + "" + }; + let _ = writeln!( + out, + "\tclauseSources := map[uint64]argv.Source{{{initial_sources}}}\n\ + {values_state}\ + \tfor _, instances := range clauseInstances {{\n\ + \t\tfor _, instance := range instances {{\n\ + \t\t\tfor {aggregate_binding} := range instance {{\n\ + \t\t\t\tclauseSources[key] = argv.FromArgv\n\ + {aggregate_values}\ + \t\t\t}}\n\t\t}}\n\t}}" + ); + } + let relationship_source = if clauses.is_empty() { + "\t\treturn sources[k]\n".to_string() + } else { + format!( + "\t\tswitch k {{\n\t\tcase {clause_keys}:\n\t\t\treturn clauseSources[k]\n\t\tdefault:\n\t\t\treturn sources[k]\n\t\t}}\n" + ) + }; if has_relationship_values { + let relationship_values = if clauses.is_empty() { + "\t\treturn argv.RelationshipValues(Meta.Lookup(k), resolved[k], sources[k], negated[k])\n".to_string() + } else { + format!( + "\t\tswitch k {{\n\t\tcase {clause_keys}:\n\t\t\treturn argv.RelationshipValues(Meta.Lookup(k), clauseValues[k], clauseSources[k], false)\n\t\tdefault:\n\t\t\treturn argv.RelationshipValues(Meta.Lookup(k), resolved[k], sources[k], negated[k])\n\t\t}}\n" + ) + }; let _ = writeln!( out, "\tif err := argv.CheckRelationshipsWithValuesAndRequirements(Meta, scope, func(k uint64) argv.Source {{\n\ - \t\treturn sources[k]\n\t}}, func(k uint64) []string {{\n\ - \t\treturn argv.RelationshipValues(Meta.Lookup(k), resolved[k], sources[k], negated[k])\n\ + {relationship_source}\t}}, func(k uint64) []string {{\n\ + {relationship_values}\ \t}}, func(k uint64) bool {{ return requirements[k] }}); err != nil {{\n\t\treturn nil, err\n\t}}" ); } else { let _ = writeln!( out, "\tif err := argv.CheckRelationshipsWithValuesAndRequirements(Meta, scope, func(k uint64) argv.Source {{\n\ - \t\treturn sources[k]\n\t}}, nil, func(k uint64) bool {{ return requirements[k] }}); err != nil {{\n\t\treturn nil, err\n\t}}" + {relationship_source}\t}}, nil, func(k uint64) bool {{ return requirements[k] }}); err != nil {{\n\t\treturn nil, err\n\t}}" ); } emit_clause_instances(out, commands, clauses, has_relationship_values); @@ -478,8 +536,9 @@ fn parse_fn(out: &mut String, commands: &[Emitted], assigned: &Fields, clauses: /// /// Clause arguments deliberately stay out of the command-wide `given` and `scope`: reusing /// those maps would collapse repeated instances into one value and let one instance satisfy -/// another's required argument. Flags remain visible to relationship checks by prepending the -/// ordinary command scope to each instance's own keys. +/// another's required argument. Flags remain visible through the source/value callbacks, but +/// are not entries in this pass: flag relationships are judged once against clause values +/// aggregated across every instance, matching the reference parser. fn emit_clause_instances( out: &mut String, commands: &[Emitted], @@ -507,7 +566,7 @@ fn emit_clause_instances( \t\t\t\tif values != nil {{\n\t\t\t\t\tclauseSources[key] = argv.FromArgv\n\t\t\t\t}} else {{\n\t\t\t\t\tclauseSources[key] = argv.Unset\n\t\t\t\t}}\n\ \t\t\t\tif err := argv.Check(Meta.Lookup(key), values, 0); err != nil {{\n\t\t\t\t\treturn nil, err\n\t\t\t\t}}\n\ \t\t\t}}\n\ - \t\t\tinstanceScope := append(append([]uint64{{}}, scope...), clauseScope...)\n", + \t\t\tinstanceScope := clauseScope\n", e.named.key ); if has_relationship_values { From 7722f9c8294047006b85d138bbf535d441d4e58d Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:14:30 +0000 Subject: [PATCH 7/8] fix(complete): preserve clause restart targets --- argv/src/complete.rs | 82 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 14aa43c42..1989df92f 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -513,12 +513,7 @@ fn trace_from<'a>( let chain = metadata_chain_on_route(spec, &position); let meta = chain.as_ref().and_then(|chain| chain.last().copied()); let next_arg = if restarted(meta, split) { - meta.and_then(|meta| { - meta.clause - .and_then(|clause| clause.args.first()) - .or_else(|| first_ordinary_arg(meta)) - }) - .map(|field| field.arg) + meta.and_then(restart_arg).map(|field| field.arg) } else { position .next_arg @@ -1108,7 +1103,7 @@ fn declared_files_at_cursor( let after_restart = restarted(meta, split); let sigil_cursor = sigil_arg_at_cursor(spec, position, split); let at_cursor = if after_restart { - meta.and_then(first_ordinary_arg).map(|m| m.arg) + meta.and_then(restart_arg).map(|m| m.arg) } else { sigil_cursor .map(|(_, field, _, _)| field.arg) @@ -1209,14 +1204,14 @@ fn complete_inner<'a>( let candidates = candidates_inner(spec, split, view); // Which argument the cursor is at — the same question `candidates` answers, asked once so - // that the two halves cannot disagree. Past a restart token it is the command's *first* - // ordinary argument, whatever the words before the token filled, and everything below - // follows from that: whether paths belong, whether the set is declared, whether a separator - // is owed. Sigil arguments are overlays and do not occupy the positional cursor. + // that the two halves cannot disagree. Past a restart token it is the first clause argument, + // or the command's first ordinary argument when there is no clause, whatever the words + // before the token filled. Everything below follows from that: whether paths belong, + // whether the set is declared, whether a separator is owed. let after_restart = restarted(meta, split); let sigil_cursor = sigil_arg_at_cursor(spec, &position, split); let at_cursor = if after_restart { - meta.and_then(first_ordinary_arg).map(|m| m.arg) + meta.and_then(restart_arg).map(|m| m.arg) } else { sigil_cursor .map(|(_, field, _, _)| field.arg) @@ -1484,7 +1479,7 @@ fn overlay_at_cursor<'o>( let sigil_target = sigil_arg_at_cursor(spec, position, split); let target = if restarted(meta, split) { meta.and_then(|owner| { - first_ordinary_arg(owner).map(|field| { + restart_arg(owner).map(|field| { ( owner, field.arg.name, @@ -1638,6 +1633,13 @@ fn first_ordinary_arg<'m, 'a>(meta: &'m CommandMeta<'a>) -> Option<&'m ArgMeta<' meta.args.iter().find(|field| field.arg.sigil.is_none()) } +/// The positional target immediately after this command's restart token. +fn restart_arg<'m, 'a>(meta: &'m CommandMeta<'a>) -> Option<&'m ArgMeta<'a>> { + meta.clause + .and_then(|clause| clause.args.first()) + .or_else(|| first_ordinary_arg(meta)) +} + /// The metadata route selected by the parser, preserving parent identity even when two /// wrappers reuse the same nested command tables. fn metadata_chain_on_route<'a>( @@ -1709,9 +1711,9 @@ fn candidates_inner<'a>( short_flags(spec, &position, token) } else if restarted(meta, split) { // Past a restart token — mise's `:::`, which starts a fresh invocation of the same - // command — the cursor is at that command's first ordinary argument again, whatever - // the words before the token filled. Sigil arguments do not occupy that cursor. - meta.and_then(first_ordinary_arg) + // command — the cursor is at its first clause argument, or its first ordinary argument + // when there is no clause, whatever the words before the token filled. + meta.and_then(restart_arg) .map(|m| positional(m, &position, split, token)) .unwrap_or_default() } else if let Some(flag) = position.awaiting_value { @@ -3651,6 +3653,54 @@ mod tests { assert_eq!(offered("mise task one ::: t"), ["two"]); } + #[test] + fn a_clause_restart_targets_the_first_inner_argument() { + static TASK_ARG: Arg = Arg { + key: 91, + name: "task", + ..Arg::REQUIRED + }; + static TASKS: crate::Clause = crate::Clause { + key: 92, + name: "tasks", + separator: b":::", + args: &[&TASK_ARG], + }; + static COMMAND: Command = Command { + name: "ex", + clause: Some(TASKS), + ..Command::EMPTY + }; + static META: CommandMeta = CommandMeta { + cmd: &COMMAND, + restart_token: Some(":::"), + clause: Some(crate::spec::ClauseMeta { + name: "tasks", + separator: ":::", + help: None, + long_help: None, + args: &[ArgMeta { + arg: &TASK_ARG, + choices: &["build", "check"], + ..ArgMeta::EMPTY + }], + }), + ..CommandMeta::EMPTY + }; + static CLAUSE_SPEC: Spec = Spec { + name: "ex", + bin: Some("ex"), + root: &META, + ..Spec::EMPTY + }; + + let values = candidates(&CLAUSE_SPEC, &at_end("ex build ::: b")) + .into_iter() + .map(|candidate| candidate.value) + .collect::>(); + assert_eq!(values, ["build"]); + } + #[test] fn the_root_offers_what_the_command_it_falls_back_to_accepts() { // `mise build` means `mise run build`, so a word at the root may be meant for the From 8703b562b08a71772e387af3dfab03a34f88cddd Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:44:52 +0000 Subject: [PATCH 8/8] fix(complete): include default compiled clauses --- argv/src/complete.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 1989df92f..04cdae7e0 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -1625,7 +1625,7 @@ fn default_subcommand_arg<'a>( subcommands() .find(|sub| sub.cmd.name == default) .or_else(|| subcommands().find(|sub| sub.cmd.aliases.contains(&default))) - .and_then(|sub| first_ordinary_arg(sub).map(|field| (sub, field))) + .and_then(|sub| restart_arg(sub).map(|field| (sub, field))) } /// The first argument that advances the ordinary positional cursor. @@ -3667,7 +3667,7 @@ mod tests { args: &[&TASK_ARG], }; static COMMAND: Command = Command { - name: "ex", + name: "run", clause: Some(TASKS), ..Command::EMPTY }; @@ -3687,14 +3687,31 @@ mod tests { }), ..CommandMeta::EMPTY }; + static ROOT: Command = Command { + name: "ex", + subcommands: &[&COMMAND], + ..Command::EMPTY + }; + static ROOT_META: CommandMeta = CommandMeta { + cmd: &ROOT, + subcommands: &[&META], + ..CommandMeta::EMPTY + }; static CLAUSE_SPEC: Spec = Spec { name: "ex", bin: Some("ex"), - root: &META, + root: &ROOT_META, + default_subcommand: Some("run"), ..Spec::EMPTY }; - let values = candidates(&CLAUSE_SPEC, &at_end("ex build ::: b")) + let values = candidates(&CLAUSE_SPEC, &at_end("ex run build ::: b")) + .into_iter() + .map(|candidate| candidate.value) + .collect::>(); + assert_eq!(values, ["build"]); + + let values = candidates(&CLAUSE_SPEC, &at_end("ex b")) .into_iter() .map(|candidate| candidate.value) .collect::>();