diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 12c1d3627..1586f4738 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,8 +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(|meta| meta.args.first()) - .map(|field| field.arg) + meta.and_then(|meta| { + meta.clause + .and_then(|clause| clause.args.first()) + .or_else(|| meta.args.first()) + }) + .map(|field| field.arg) } else { position .next_arg @@ -1584,6 +1604,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)) }) } @@ -2044,6 +2072,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/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/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 3e3a73e7a..c721c0623 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#" @@ -151,3 +166,43 @@ 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}"); +} + +#[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}"); +} 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,