From 33f743f0393f6c519ec42667fe68e773b3205e01 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:56:12 +0000 Subject: [PATCH 01/11] feat(spec,parse): add repeatable clause groups --- cli/src/cli/complete_word.rs | 15 +++- cli/tests/complete_word.rs | 6 ++ conformance/tests/clause.rs | 65 +++++++++++++++++ docs/.vitepress/config.mts | 3 +- docs/spec/reference/clause.md | 33 +++++++++ docs/spec/reference/cmd.md | 4 ++ examples/clause.usage.kdl | 9 +++ lib/src/docs/models.rs | 3 + lib/src/error.rs | 7 ++ lib/src/lib.rs | 1 + lib/src/parse.rs | 128 ++++++++++++++++++++++++++++++++-- lib/src/spec/clause.rs | 79 +++++++++++++++++++++ lib/src/spec/cmd.rs | 35 ++++++++++ lib/src/spec/mod.rs | 21 ++++++ 14 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 conformance/tests/clause.rs create mode 100644 docs/spec/reference/clause.md create mode 100644 examples/clause.usage.kdl create mode 100644 lib/src/spec/clause.rs diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index 3b15093ea..740e91d2f 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -174,7 +174,12 @@ impl CompleteWord { .cmd .restart_token .as_ref() - .is_some_and(|rt| prev_token == Some(rt.as_str())); + .is_some_and(|rt| prev_token == Some(rt.as_str())) + || parsed + .cmd + .clause + .as_ref() + .is_some_and(|clause| prev_token == Some(clause.separator.as_str())); let cx = Ctx { tera: &ctx, @@ -244,7 +249,13 @@ impl CompleteWord { // This must be checked after flag checks (to allow --flag after :::) // but before flag_awaiting_value (since restart clears pending flag values) let mut choices = vec![]; - if let Some(arg) = parsed.cmd.args.first() { + if let Some(arg) = parsed + .cmd + .clause + .as_ref() + .and_then(|clause| clause.args.first()) + .or_else(|| parsed.cmd.args.first()) + { let (found, constrained) = self.complete_positional( &cx, &parsed.cmd, diff --git a/cli/tests/complete_word.rs b/cli/tests/complete_word.rs index d7fd2df71..6aa010e41 100644 --- a/cli/tests/complete_word.rs +++ b/cli/tests/complete_word.rs @@ -277,6 +277,12 @@ complete "tool" run="printf '%s\\n' {{ words[CURRENT] | shell_quote }}" .stdout("+node\n"); } +#[test] +fn complete_word_clause_separator_restarts_at_the_first_inner_arg() { + assert_cmd("clause.usage.kdl", &["--", "lint", "--fix", ":::", "t"]) + .stdout("test\n"); +} + #[test] fn complete_word_choices_from_env() { cmd("env-choices.usage.kdl", Some("fish")) diff --git a/conformance/tests/clause.rs b/conformance/tests/clause.rs new file mode 100644 index 000000000..643993d51 --- /dev/null +++ b/conformance/tests/clause.rs @@ -0,0 +1,65 @@ +use usage::parse::ParseValue; +use usage::Spec; + +fn spec() -> Spec { + r#" +min_usage_version "6.5" +name "clause" +bin "clause" +clause "tasks" separator=":::" { + arg "" + arg "[args]..." var=#true double_dash="automatic" +} +"# + .parse() + .expect("valid clause spec") +} + +fn strings<'a>( + mut instance: impl Iterator, &'a ParseValue)>, + name: &str, +) -> Vec { + instance + .find(|(arg, _)| arg.name == name) + .map(|(_, value)| match value { + ParseValue::String(value) => vec![value.clone()], + ParseValue::MultiString(values) => values.clone(), + other => panic!("unexpected value: {other:?}"), + }) + .unwrap_or_default() +} + +#[test] +fn clause_instances_preserve_values_and_restart_flags() { + let parsed = usage::Parser::new(&spec()) + .parse(&["clause", "lint", "--fix", ":::", "test", "--all"].map(str::to_string)) + .expect("valid invocation"); + let instances = &parsed.clauses["tasks"]; + assert_eq!(instances.len(), 2); + assert_eq!(strings(instances[0].iter(), "task"), ["lint"]); + assert_eq!(strings(instances[0].iter(), "args"), ["--fix"]); + assert_eq!(strings(instances[1].iter(), "task"), ["test"]); + assert_eq!(strings(instances[1].iter(), "args"), ["--all"]); + assert!(parsed.args.is_empty()); +} + +#[test] +fn explicit_double_dash_protects_a_literal_separator() { + let parsed = usage::Parser::new(&spec()) + .parse(&["clause", "lint", "--", ":::", "tail"].map(str::to_string)) + .expect("valid invocation"); + let instances = &parsed.clauses["tasks"]; + assert_eq!(instances.len(), 1); + assert_eq!(strings(instances[0].iter(), "args"), [":::", "tail"]); +} + +#[test] +fn clause_round_trips_through_canonical_kdl() { + let spec = spec(); + let emitted = spec.to_string(); + let reparsed: Spec = emitted.parse().expect("emitted clause spec reparses"); + let clause = reparsed.cmd.clause.expect("clause retained"); + assert_eq!(clause.name, "tasks"); + assert_eq!(clause.separator, ":::"); + assert_eq!(clause.args.len(), 2); +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ec3a828a4..1b3eb961b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -120,7 +120,8 @@ export default defineConfig({ link: "/spec/reference/", items: [ { text: "arg", link: "/spec/reference/arg" }, - { text: "sigils", link: "/spec/reference/sigils" }, + { text: "sigils", link: "/spec/reference/sigils" }, + { text: "clauses", link: "/spec/reference/clause" }, { text: "cmd", link: "/spec/reference/cmd" }, { text: "complete", link: "/spec/reference/complete" }, { text: "flag", link: "/spec/reference/flag" }, diff --git a/docs/spec/reference/clause.md b/docs/spec/reference/clause.md new file mode 100644 index 000000000..9fca40637 --- /dev/null +++ b/docs/spec/reference/clause.md @@ -0,0 +1,33 @@ +# Clauses + +A clause is a repeatable group of positional arguments. A separator ends the current +instance and starts another without discarding the values already parsed. + +```kdl +min_usage_version "6.5" + +clause "tasks" separator=":::" { + arg "" + arg "[args]..." var=#true double_dash="automatic" +} +``` + +`run lint --fix ::: test --all` produces two `tasks` instances. Each instance is a +map keyed by its inner argument declarations: the first contains `task="lint"` and +`args=["--fix"]`; the second contains `task="test"` and `args=["--all"]`. + +The separator is recognized even after `double_dash="automatic"` made tokens verbatim, +and it re-enables flags for the next instance. An explicit `--` protects the separator, +so `run lint -- :::` passes `:::` as data instead. + +Version 1 deliberately keeps clauses narrow: + +- A command may declare one clause. +- A clause contains positional `arg` nodes only. +- Top-level arguments, `restart_token`, and sigil arguments cannot be combined with a + clause on the same command. +- Defaults and environment variables do not fill inner arguments; every instance reflects + argv supplied for that instance. + +Use [sigil arguments](./sigils.md) to classify independent prefixed values. Use a clause +when several adjacent positional values form one repeatable unit. diff --git a/docs/spec/reference/cmd.md b/docs/spec/reference/cmd.md index fd958d3cd..5fa286d5f 100644 --- a/docs/spec/reference/cmd.md +++ b/docs/spec/reference/cmd.md @@ -364,6 +364,10 @@ cmd "run" restart_token=":::" { retains the last invocation's scalar bindings. This is a command-only property; it is not accepted as a top-level node. +For new grammars that need every invocation rather than only the last one's values, +use a repeatable [clause](./clause.md). A clause is the value-preserving successor to +`restart_token`; the two cannot be declared together on one command. + ## Command-local completions A `complete` child applies only while parsing that command. It has the same diff --git a/examples/clause.usage.kdl b/examples/clause.usage.kdl new file mode 100644 index 000000000..89e3aed78 --- /dev/null +++ b/examples/clause.usage.kdl @@ -0,0 +1,9 @@ +min_usage_version "6.5" +name "Clause example" +bin "clause" +clause "tasks" separator=":::" { + arg "" { + choices "lint" "test" "build" + } + arg "[args]..." var=#true double_dash="automatic" +} diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index 5f26f9a70..bad98ab11 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -42,6 +42,7 @@ pub struct SpecCommand { /// Visible subcommand summaries partitioned by their own `help_heading`. pub subcommand_groups: Vec>, pub args: Vec, + pub clause: Option, pub flags: Vec, /// `flags`, partitioned by `help_heading`. Same flags, same order. pub flag_groups: Vec>, @@ -739,6 +740,7 @@ impl From<&crate::SpecCommand> for SpecCommand { allow_missing_positional: _, // Rendered above, or deliberately absent from the docs model. args: _, + clause, flags: _, // Where a flag was declared is not something a rendered page shows: a `use` is // resolved before docs are generated, and the flags it named are in `flags`. @@ -841,6 +843,7 @@ impl From<&crate::SpecCommand> for SpecCommand { arg_groups, headings, args, + clause: clause.clone(), flags, deprecated: deprecated.clone(), deprecated_warn_at: deprecated_warn_at.clone(), diff --git a/lib/src/error.rs b/lib/src/error.rs index 5b6fe427d..fc66e3f39 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -46,6 +46,13 @@ pub enum UsageErr { #[error("Missing required arg: <{0}>")] MissingArg(String), + #[error("Missing required arg <{arg}> in clause {clause} instance {instance}")] + MissingClauseArg { + clause: String, + instance: usize, + arg: String, + }, + /// A command that declares `subcommand_required` was given none. /// /// The spec could say this and the parser did not read it, so `mise generate` — which diff --git a/lib/src/lib.rs b/lib/src/lib.rs index c51cf65e6..6478c59d2 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -7,6 +7,7 @@ pub use crate::spec::admonition::{SpecAdmonition, SpecAdmonitionKind}; pub use crate::spec::arg::{SpecArg, SpecDoubleDashChoices, SpecRequiredIfEq}; pub use crate::spec::builder::{SpecArgBuilder, SpecCommandBuilder, SpecFlagBuilder}; pub use crate::spec::choices::{SpecChoice, SpecChoiceAlias, SpecChoices}; +pub use crate::spec::clause::SpecClause; pub use crate::spec::cmd::SpecCommand; pub use crate::spec::complete::SpecComplete; pub use crate::spec::effect::SpecCommandEffect; diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 3f1481b66..50497fb13 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -345,6 +345,8 @@ pub enum TokenRole { /// over here. Recorded because the words before it are still in the report, and without /// this row they look like they filled arguments that then came back empty. Restart, + /// Ended one instance of a repeatable clause and began the next. + ClauseSeparator { name: String }, /// A flag-like word no declaration matched. `bound_as` is the positional that took it /// under `unknown_flags="value"`, and `None` when the word was refused. UnknownFlag { bound_as: Option> }, @@ -382,6 +384,8 @@ pub struct ParseOutput { pub cmd: SpecCommand, pub cmds: Vec, pub args: IndexMap, ParseValue>, + /// Separator-delimited positional instances, keyed by clause name. + pub clauses: IndexMap, ParseValue>>>, pub flags: IndexMap, ParseValue>, /// What each word of the command line became, in argv order, one entry per word. /// @@ -784,6 +788,7 @@ impl<'a> Parser<'a> { self.mount_outputs.as_ref(), MountTiming::WhenAWordIsUnknown, )?; + restore_current_clause(&mut out); trace!("{out:?}"); // A flag still waiting for a value never got one, so the command line ended @@ -835,7 +840,12 @@ impl<'a> Parser<'a> { // // Not `skip(out.args.len())`: an explicit `--` can jump the parser's cursor past an arg // that stayed empty, leaving a gap that makes the fill count a wrong starting offset. - for arg in out.cmd.args.iter() { + for arg in active_args(&out.cmd) { + // Clause instances contain argv only: defaults and environment values do not + // manufacture fields inside a repeated group. + if out.cmd.clause.is_some() { + break; + } if out.args.contains_key(arg) { continue; } @@ -1029,6 +1039,51 @@ impl<'a> Parser<'a> { &mut out.errors, ); } + if let Some(clause) = &out.cmd.clause { + let mut clause_errors = Vec::new(); + for (index, instance) in out + .clauses + .get(&clause.name) + .into_iter() + .flatten() + .chain(std::iter::once(&out.args)) + .enumerate() + { + for arg in &clause.args { + let Some(value) = instance.get(arg) else { + if arg.required { + clause_errors.push(UsageErr::MissingClauseArg { + clause: clause.name.clone(), + instance: index + 1, + arg: arg.name.clone(), + }); + } + continue; + }; + if let (true, ParseValue::MultiString(values)) = (arg.var, value) { + if let Some(min) = arg.var_min { + if values.len() < min { + clause_errors.push(UsageErr::VarArgTooFew { + name: format!("{} instance {}: {}", clause.name, index + 1, arg.name), + min, + got: values.len(), + }); + } + } + if let Some(max) = arg.var_max { + if values.len() > max { + clause_errors.push(UsageErr::VarArgTooMany { + name: format!("{} instance {}: {}", clause.name, index + 1, arg.name), + max, + got: values.len(), + }); + } + } + } + } + } + out.errors.extend(clause_errors); + } for (flag, parsed) in &out.flags { if let Some(arg) = &flag.arg { validate_expression( @@ -1043,6 +1098,7 @@ impl<'a> Parser<'a> { // Applied once, here, because this is where the CLI's own version is known: a // `deprecated_warn_at` the spec has not reached yet is an author saying *not yet*. crate::warn::retain_reached(&mut out.warnings, self.spec.version.as_deref()); + finalize_current_clause(&mut out); Ok(out) } } @@ -1267,6 +1323,7 @@ fn parse_partial_traced( cmd: spec.cmd.clone(), cmds: vec![spec.cmd.clone()], args: IndexMap::new(), + clauses: IndexMap::new(), flags: IndexMap::new(), tokens: vec![], flag_origins: IndexMap::new(), @@ -1583,6 +1640,27 @@ fn parse_partial_traced( // only when the value is actually missing, so `-cnever` is still `never`. let attached_continuation = grouped_flag; + // A clause boundary is syntax even after an automatic trailing argument disabled + // flags. Only an explicit `--` protects a literal separator. + if !seen_double_dash { + if let Some(clause) = out.cmd.clause.as_ref() { + if w == clause.separator { + let name = clause.name.clone(); + out.clauses + .entry(name.clone()) + .or_default() + .push(std::mem::take(&mut out.args)); + out.arg_origins.clear(); + trace.record(argv, TokenRole::ClauseSeparator { name }); + next_arg_idx = 0; + out.flag_awaiting_value.clear(); + enable_flags = true; + seen_double_dash = false; + continue; + } + } + } + // Check for restart_token - resets argument parsing for multiple command invocations // e.g., `mise run lint ::: test ::: check` with restart_token=":::" if let Some(ref restart_token) = out.cmd.restart_token { @@ -1696,7 +1774,7 @@ fn parse_partial_traced( // that would otherwise swallow the rest. This mirrors clap's `Arg::last(true)`, // which is what `double_dash="required"` is generated from. Specs without such // an arg find nothing and keep the cursor where it was. - let target = out.cmd.args.iter().position(|arg| { + let target = active_args(&out.cmd).iter().position(|arg| { arg.double_dash == SpecDoubleDashChoices::Required && !out.args.contains_key(arg) }); @@ -2190,11 +2268,11 @@ fn parse_partial_traced( if out.cmd.allow_missing_positional { next_arg_idx = cursor_skip_sigils(&out.cmd, next_arg_idx); - while let Some(current) = out.cmd.args.get(next_arg_idx) { + while let Some(current) = active_args(&out.cmd).get(next_arg_idx) { if current.required || out.args.contains_key(current) { break; } - let required_after = out.cmd.args[next_arg_idx + 1..] + let required_after = active_args(&out.cmd)[next_arg_idx + 1..] .iter() .filter(|arg| arg.required && arg.sigil.is_none()) .count(); @@ -2217,7 +2295,7 @@ fn parse_partial_traced( } } - if let Some(arg) = out.cmd.args.get(next_arg_idx) { + if let Some(arg) = active_args(&out.cmd).get(next_arg_idx) { if arg.var && out.args.contains_key(arg) && arg.value_terminator.as_deref() == Some(w.as_str()) @@ -2680,7 +2758,7 @@ fn parse_partial_traced( && (flag_was_parsed(other) || flag_has_env(other, custom_env)) }) .map(|other| format!("--{}", other.name)); - let other_arg = out.cmd.args.iter().find(|arg| { + let other_arg = active_args(&out.cmd).iter().find(|arg| { out.args.keys().any(|given| given.name == arg.name) || arg .env @@ -4144,17 +4222,51 @@ fn record_stop( .cloned() .map(Arc::new); out.double_dash_seen = seen_double_dash; + finalize_current_clause(out); trace.close(unread); out.tokens = std::mem::take(&mut trace.tokens); } +fn finalize_current_clause(out: &mut ParseOutput) { + let Some(clause) = out.cmd.clause.as_ref() else { + return; + }; + out.clauses + .entry(clause.name.clone()) + .or_default() + .push(std::mem::take(&mut out.args)); +} + +fn restore_current_clause(out: &mut ParseOutput) { + let Some(clause) = out.cmd.clause.as_ref() else { + return; + }; + if let Some(current) = out + .clauses + .get_mut(&clause.name) + .and_then(Vec::pop) + { + out.args = current; + } +} + fn cursor_skip_sigils(cmd: &SpecCommand, mut idx: usize) -> usize { - while cmd.args.get(idx).is_some_and(|arg| arg.sigil.is_some()) { + while active_args(cmd) + .get(idx) + .is_some_and(|arg| arg.sigil.is_some()) + { idx += 1; } idx } +fn active_args(cmd: &SpecCommand) -> &[SpecArg] { + cmd.clause + .as_ref() + .map(|clause| clause.args.as_slice()) + .unwrap_or(cmd.args.as_slice()) +} + fn match_sigil_arg<'a>( cmd: &'a SpecCommand, word: &'a str, @@ -4288,6 +4400,7 @@ fn render_role(role: &TokenRole) -> String { TokenRole::Builtin { spelling } => format!("built-in {spelling}"), TokenRole::ValueTerminator { ends } => format!("value terminator, ends {ends}"), TokenRole::Restart => "restart".to_string(), + TokenRole::ClauseSeparator { name } => format!("clause separator for {name}"), TokenRole::UnknownFlag { bound_as } => match bound_as { Some(arg) => format!("unknown flag, bound as {}", arg.name), None => "unknown flag".to_string(), @@ -4310,6 +4423,7 @@ impl Debug for ParseOutput { .map(|(a, w)| format!("{}: {w}", a.name)) .collect_vec(), ) + .field("clauses", &self.clauses) .field( "available_flags", &self diff --git a/lib/src/spec/clause.rs b/lib/src/spec/clause.rs new file mode 100644 index 000000000..1f4de8e7d --- /dev/null +++ b/lib/src/spec/clause.rs @@ -0,0 +1,79 @@ +use crate::error::Result; +use crate::kdl::{KdlDocument, KdlEntry, KdlNode}; +use crate::spec::context::ParsingContext; +use crate::spec::helpers::{string_entry, NodeHelper}; +use crate::SpecArg; +use serde::Serialize; + +/// A repeatable, separator-delimited group of positional arguments. +#[derive(Debug, Default, Clone, Serialize)] +#[non_exhaustive] +pub struct SpecClause { + pub name: String, + pub separator: String, + pub args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub help: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub help_long: Option, + pub usage: String, +} + +impl SpecClause { + pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result { + let mut clause = Self { + name: node.arg(0)?.ensure_string()?, + ..Self::default() + }; + for (key, value) in node.props() { + match key { + "separator" => clause.separator = value.ensure_string()?, + "help" => clause.help = Some(value.ensure_string()?), + "help_long" | "long_help" => clause.help_long = Some(value.ensure_string()?), + key => bail_parse!(ctx, value.entry.span(), "unsupported clause key {key}"), + } + } + for child in node.children() { + match child.name() { + "arg" => clause.args.push(SpecArg::parse(ctx, &child)?), + key => bail_parse!(ctx, child.node.name().span(), "unsupported clause child {key}"), + } + } + if clause.name.is_empty() { + bail_parse!(ctx, node.span(), "a clause needs a name"); + } + if clause.separator.is_empty() { + bail_parse!(ctx, node.span(), "clause {} needs a non-empty separator", clause.name); + } + if clause.separator.starts_with('-') { + bail_parse!(ctx, node.span(), "clause separator cannot start with `-`"); + } + if clause.args.is_empty() { + bail_parse!(ctx, node.span(), "clause {} needs at least one argument", clause.name); + } + clause.usage = clause.usage(); + Ok(clause) + } + + pub fn usage(&self) -> String { + let inner = self.args.iter().map(SpecArg::usage).collect::>().join(" "); + format!("{inner} [{} {inner}]…", self.separator) + } +} + +impl From<&SpecClause> for KdlNode { + fn from(clause: &SpecClause) -> Self { + let mut node = KdlNode::new("clause"); + node.push(KdlEntry::new(clause.name.clone())); + node.push(string_entry(Some("separator"), &clause.separator)); + if let Some(help) = &clause.help { + node.push(string_entry(Some("help"), help)); + } + if let Some(help) = &clause.help_long { + node.push(string_entry(Some("help_long"), help)); + } + let children = node.children_mut().get_or_insert_with(KdlDocument::new); + children.nodes_mut().extend(clause.args.iter().map(Into::into)); + node + } +} diff --git a/lib/src/spec/cmd.rs b/lib/src/spec/cmd.rs index 20ce7bfd6..51aa654e6 100644 --- a/lib/src/spec/cmd.rs +++ b/lib/src/spec/cmd.rs @@ -5,6 +5,7 @@ use crate::error::UsageErr; use crate::kdl::{KdlDocument, KdlEntry, KdlNode}; use crate::sh::sh; use crate::spec::builder::SpecCommandBuilder; +use crate::spec::clause::SpecClause; use crate::spec::context::ParsingContext; use crate::spec::effect::{SpecCommandEffect, EFFECT_VALUES}; use crate::spec::exit_code::SpecExitCode; @@ -48,6 +49,9 @@ pub struct SpecCommand { pub subcommands: IndexMap, /// Positional arguments for this command pub args: Vec, + /// A repeatable separator-delimited positional group. + #[serde(skip_serializing_if = "Option::is_none")] + pub clause: Option, /// Flags/options for this command pub flags: Vec, /// Flagsets this command pulls in, and where in [`Self::flags`] they belong. @@ -285,6 +289,7 @@ impl Default for SpecCommand { subcommand_precedence_over_arg: false, allow_missing_positional: false, restart_token: None, + clause: None, help: None, help_long: None, help_md: None, @@ -509,6 +514,12 @@ impl SpecCommand { } cmd.args.push(arg); } + "clause" => { + if cmd.clause.is_some() { + bail_parse!(ctx, child.node.name().span(), "a command may declare at most one clause"); + } + cmd.clause = Some(SpecClause::parse(ctx, &child)?); + } "mount" => cmd.mounts.push(SpecMount::parse(ctx, &child)?), "group" => cmd.groups.push(SpecGroup::parse(ctx, &child)?), "cmd" => { @@ -721,6 +732,17 @@ impl SpecCommand { sigils.push(sigil); } } + if let Some(clause) = &cmd.clause { + if !cmd.args.is_empty() { + bail_parse!(ctx, node.span(), "a command cannot declare both top-level arguments and a clause"); + } + if cmd.restart_token.is_some() { + bail_parse!(ctx, node.span(), "a command cannot declare both restart_token and a clause"); + } + if clause.args.iter().any(|arg| arg.sigil.is_some()) { + bail_parse!(ctx, node.span(), "sigil arguments are not supported inside clauses"); + } + } Ok(cmd) } @@ -747,6 +769,7 @@ impl SpecCommand { } pub(crate) fn is_empty(&self) -> bool { self.args.is_empty() + && self.clause.is_none() && self.flags.is_empty() && self.mounts.is_empty() && self.subcommands.is_empty() @@ -799,6 +822,9 @@ impl SpecCommand { usage = format!("{usage} [ARGS]…"); } } + if let Some(clause) = &self.clause { + usage = format!("{usage} {}", clause.usage()); + } // TODO: mounts? // if !self.mounts.is_empty() { // name = format!("{name} [mounts]"); @@ -841,6 +867,7 @@ impl SpecCommand { after_help_long, after_help_md, args, + clause, flags, uses, mounts, @@ -923,6 +950,9 @@ impl SpecCommand { if !args.is_empty() { self.args = args; } + if clause.is_some() { + self.clause = clause; + } let flags_replaced = !flags.is_empty(); if flags_replaced { self.flags = flags; @@ -1175,6 +1205,7 @@ impl From<&SpecCommand> for KdlNode { effect, flags, args, + clause, mounts, groups, subcommands, @@ -1375,6 +1406,10 @@ impl From<&SpecCommand> for KdlNode { let children = node.children_mut().get_or_insert_with(KdlDocument::new); children.nodes_mut().push(arg.into()); } + if let Some(clause) = clause { + let children = node.children_mut().get_or_insert_with(KdlDocument::new); + children.nodes_mut().push(clause.into()); + } for mount in mounts { let children = node.children_mut().get_or_insert_with(KdlDocument::new); children.nodes_mut().push(mount.into()); diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index 79c4c6fbd..f17c576dc 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -2,6 +2,7 @@ pub mod admonition; pub mod arg; pub mod builder; pub mod choices; +pub mod clause; pub mod cmd; pub mod complete; pub mod config; @@ -550,6 +551,12 @@ impl Spec { } schema.cmd.args.push(arg); } + "clause" => { + if schema.cmd.clause.is_some() { + bail_parse!(ctx, node.span(), "a command may declare at most one clause"); + } + schema.cmd.clause = Some(crate::SpecClause::parse(ctx, &node)?); + } "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?), // The root command's groups, as its flags and arguments are: a spec // whose top level declares flags can group them there too. @@ -773,6 +780,17 @@ impl Spec { } else { schema.bin.clone() }; + if let Some(clause) = &schema.cmd.clause { + if !schema.cmd.args.is_empty() { + bail_parse!(ctx, kdl.span(), "a command cannot declare both top-level arguments and a clause"); + } + if schema.cmd.restart_token.is_some() { + bail_parse!(ctx, kdl.span(), "a command cannot declare both restart_token and a clause"); + } + if clause.args.iter().any(|arg| arg.sigil.is_some()) { + bail_parse!(ctx, kdl.span(), "sigil arguments are not supported inside clauses"); + } + } // Before ancestors are stamped, because expanding a flagset or narrowing a selector can // add a flag to a command and the usage strings are computed from the flag list. flagset::expand(ctx, &mut schema.cmd, &mut schema.flagsets)?; @@ -1255,6 +1273,9 @@ impl Display for Spec { for arg in self.cmd.args.iter() { nodes.push(arg.into()); } + if let Some(clause) = &self.cmd.clause { + nodes.push(clause.into()); + } // Written here rather than by SpecCommand, because the root's own nodes // live at the top level of the document instead of inside a `cmd` block. for mount in self.cmd.mounts.iter() { From cf27005abbc7b5f92637e9b0b0b62b0f6ea826ad Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:14:38 +0000 Subject: [PATCH 02/11] fix(parse): include clause args in exclusivity --- lib/src/parse.rs | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 50497fb13..e3f7116c9 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1064,7 +1064,12 @@ impl<'a> Parser<'a> { if let Some(min) = arg.var_min { if values.len() < min { clause_errors.push(UsageErr::VarArgTooFew { - name: format!("{} instance {}: {}", clause.name, index + 1, arg.name), + name: format!( + "{} instance {}: {}", + clause.name, + index + 1, + arg.name + ), min, got: values.len(), }); @@ -1073,7 +1078,12 @@ impl<'a> Parser<'a> { if let Some(max) = arg.var_max { if values.len() > max { clause_errors.push(UsageErr::VarArgTooMany { - name: format!("{} instance {}: {}", clause.name, index + 1, arg.name), + name: format!( + "{} instance {}: {}", + clause.name, + index + 1, + arg.name + ), max, got: values.len(), }); @@ -2760,6 +2770,11 @@ fn parse_partial_traced( .map(|other| format!("--{}", other.name)); let other_arg = active_args(&out.cmd).iter().find(|arg| { out.args.keys().any(|given| given.name == arg.name) + || out + .clauses + .values() + .flatten() + .any(|instance| instance.keys().any(|given| given.name == arg.name)) || arg .env .as_ref() @@ -4241,11 +4256,7 @@ fn restore_current_clause(out: &mut ParseOutput) { let Some(clause) = out.cmd.clause.as_ref() else { return; }; - if let Some(current) = out - .clauses - .get_mut(&clause.name) - .and_then(Vec::pop) - { + if let Some(current) = out.clauses.get_mut(&clause.name).and_then(Vec::pop) { out.args = current; } } @@ -5862,6 +5873,22 @@ flag "--file " required_unless="--stdin" parse(&spec, &input(&["ex", "--verbose", "t"])).expect("without it, nothing changes"); } + #[test] + fn an_exclusive_flag_conflicts_with_clause_arguments() { + let spec: Spec = r#"name "ex" +bin "ex" +flag "--dump" exclusive=#true +clause "tasks" separator=":::" { + arg "" +} +"# + .parse() + .unwrap(); + + let err = parse(&spec, &input(&["ex", "--dump", "lint"])).unwrap_err(); + assert!(err.to_string().contains("on its own"), "{err}"); + } + #[test] fn an_exclusive_flag_is_not_disturbed_by_a_default() { // Only what was supplied counts, as `conflicts` reads it. A default counting as From 374dc829e23db495c8adce5e2db62cf3659e0613 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:14:55 +0000 Subject: [PATCH 03/11] style: format clause implementation --- cli/tests/complete_word.rs | 3 +-- lib/src/spec/clause.rs | 31 ++++++++++++++++++++++++++----- lib/src/spec/cmd.rs | 24 ++++++++++++++++++++---- lib/src/spec/mod.rs | 18 +++++++++++++++--- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/cli/tests/complete_word.rs b/cli/tests/complete_word.rs index 6aa010e41..703a314e8 100644 --- a/cli/tests/complete_word.rs +++ b/cli/tests/complete_word.rs @@ -279,8 +279,7 @@ complete "tool" run="printf '%s\\n' {{ words[CURRENT] | shell_quote }}" #[test] fn complete_word_clause_separator_restarts_at_the_first_inner_arg() { - assert_cmd("clause.usage.kdl", &["--", "lint", "--fix", ":::", "t"]) - .stdout("test\n"); + assert_cmd("clause.usage.kdl", &["--", "lint", "--fix", ":::", "t"]).stdout("test\n"); } #[test] diff --git a/lib/src/spec/clause.rs b/lib/src/spec/clause.rs index 1f4de8e7d..6c337b9bb 100644 --- a/lib/src/spec/clause.rs +++ b/lib/src/spec/clause.rs @@ -36,27 +36,46 @@ impl SpecClause { for child in node.children() { match child.name() { "arg" => clause.args.push(SpecArg::parse(ctx, &child)?), - key => bail_parse!(ctx, child.node.name().span(), "unsupported clause child {key}"), + key => bail_parse!( + ctx, + child.node.name().span(), + "unsupported clause child {key}" + ), } } if clause.name.is_empty() { bail_parse!(ctx, node.span(), "a clause needs a name"); } if clause.separator.is_empty() { - bail_parse!(ctx, node.span(), "clause {} needs a non-empty separator", clause.name); + bail_parse!( + ctx, + node.span(), + "clause {} needs a non-empty separator", + clause.name + ); } if clause.separator.starts_with('-') { bail_parse!(ctx, node.span(), "clause separator cannot start with `-`"); } if clause.args.is_empty() { - bail_parse!(ctx, node.span(), "clause {} needs at least one argument", clause.name); + bail_parse!( + ctx, + node.span(), + "clause {} needs at least one argument", + clause.name + ); } clause.usage = clause.usage(); Ok(clause) } pub fn usage(&self) -> String { - let inner = self.args.iter().map(SpecArg::usage).collect::>().join(" "); + let inner = self + .args + .iter() + .map(SpecArg::usage) + .collect::>() + .join(" "); format!("{inner} [{} {inner}]…", self.separator) } } @@ -73,7 +92,9 @@ impl From<&SpecClause> for KdlNode { node.push(string_entry(Some("help_long"), help)); } let children = node.children_mut().get_or_insert_with(KdlDocument::new); - children.nodes_mut().extend(clause.args.iter().map(Into::into)); + children + .nodes_mut() + .extend(clause.args.iter().map(Into::into)); node } } diff --git a/lib/src/spec/cmd.rs b/lib/src/spec/cmd.rs index 51aa654e6..7d645b45a 100644 --- a/lib/src/spec/cmd.rs +++ b/lib/src/spec/cmd.rs @@ -516,7 +516,11 @@ impl SpecCommand { } "clause" => { if cmd.clause.is_some() { - bail_parse!(ctx, child.node.name().span(), "a command may declare at most one clause"); + bail_parse!( + ctx, + child.node.name().span(), + "a command may declare at most one clause" + ); } cmd.clause = Some(SpecClause::parse(ctx, &child)?); } @@ -734,13 +738,25 @@ impl SpecCommand { } if let Some(clause) = &cmd.clause { if !cmd.args.is_empty() { - bail_parse!(ctx, node.span(), "a command cannot declare both top-level arguments and a clause"); + bail_parse!( + ctx, + node.span(), + "a command cannot declare both top-level arguments and a clause" + ); } if cmd.restart_token.is_some() { - bail_parse!(ctx, node.span(), "a command cannot declare both restart_token and a clause"); + bail_parse!( + ctx, + node.span(), + "a command cannot declare both restart_token and a clause" + ); } if clause.args.iter().any(|arg| arg.sigil.is_some()) { - bail_parse!(ctx, node.span(), "sigil arguments are not supported inside clauses"); + bail_parse!( + ctx, + node.span(), + "sigil arguments are not supported inside clauses" + ); } } Ok(cmd) diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index f17c576dc..df36b8236 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -782,13 +782,25 @@ impl Spec { }; if let Some(clause) = &schema.cmd.clause { if !schema.cmd.args.is_empty() { - bail_parse!(ctx, kdl.span(), "a command cannot declare both top-level arguments and a clause"); + bail_parse!( + ctx, + kdl.span(), + "a command cannot declare both top-level arguments and a clause" + ); } if schema.cmd.restart_token.is_some() { - bail_parse!(ctx, kdl.span(), "a command cannot declare both restart_token and a clause"); + bail_parse!( + ctx, + kdl.span(), + "a command cannot declare both restart_token and a clause" + ); } if clause.args.iter().any(|arg| arg.sigil.is_some()) { - bail_parse!(ctx, kdl.span(), "sigil arguments are not supported inside clauses"); + bail_parse!( + ctx, + kdl.span(), + "sigil arguments are not supported inside clauses" + ); } } // Before ancestors are stamped, because expanding a flagset or narrowing a selector can From ef8995d19125c1faf67094edbcaa9b71251b6ce5 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:26:40 +0000 Subject: [PATCH 04/11] fix(complete): reset trailing state at clauses --- cli/src/cli/complete_word.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index 740e91d2f..8393b4457 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -204,10 +204,13 @@ impl CompleteWord { .iter() .rev() .take_while(|token| { - !token - .roles - .iter() - .any(|role| matches!(role, usage::parse::TokenRole::Restart)) + !token.roles.iter().any(|role| { + matches!( + role, + usage::parse::TokenRole::Restart + | usage::parse::TokenRole::ClauseSeparator { .. } + ) + }) }) .flat_map(|token| &token.roles) .any(|role| match role { From 485705e079844884986f9b7891b2959ef162d50f Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:00:35 +0000 Subject: [PATCH 05/11] fix(parse): preserve token role discriminants --- lib/src/parse.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index e3f7116c9..d9063db62 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -345,8 +345,6 @@ pub enum TokenRole { /// over here. Recorded because the words before it are still in the report, and without /// this row they look like they filled arguments that then came back empty. Restart, - /// Ended one instance of a repeatable clause and began the next. - ClauseSeparator { name: String }, /// A flag-like word no declaration matched. `bound_as` is the positional that took it /// under `unknown_flags="value"`, and `None` when the word was refused. UnknownFlag { bound_as: Option> }, @@ -363,6 +361,8 @@ pub enum TokenRole { sigil: String, values: Vec, }, + /// Ended one instance of a repeatable clause and began the next. + ClauseSeparator { name: String }, } /// One word of the command line, and what it became. From 2859852015cc0f2b653c5cc165847c5a8b816651 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:19:03 +0000 Subject: [PATCH 06/11] feat(spec): negotiate clauses as usage 6.6 --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 16 ++++++++-------- argv/Cargo.toml | 2 +- cli/Cargo.toml | 2 +- cli/usage.usage.kdl | 2 +- config/Cargo.toml | 2 +- conformance/tests/clause.rs | 2 +- derive/Cargo.toml | 2 +- docs/cli/reference/commands.json | 2 +- docs/cli/reference/index.md | 2 +- docs/spec/reference/clause.md | 2 +- examples/clause.usage.kdl | 2 +- lib/Cargo.toml | 2 +- test/Cargo.toml | 2 +- usage-dynamic/Cargo.toml | 4 ++-- usage-rs/Cargo.toml | 2 +- validation/Cargo.toml | 2 +- 17 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50367f7d0..411ac5d63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2012,11 +2012,11 @@ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "usage-argv" -version = "6.5.0" +version = "6.6.0" [[package]] name = "usage-cli" -version = "6.5.0" +version = "6.6.0" dependencies = [ "assert_cmd", "ctor", @@ -2043,7 +2043,7 @@ dependencies = [ [[package]] name = "usage-config" -version = "6.5.0" +version = "6.6.0" dependencies = [ "serde_json", "toml", @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "usage-derive" -version = "6.5.0" +version = "6.6.0" dependencies = [ "heck", "proc-macro2", @@ -2079,7 +2079,7 @@ dependencies = [ [[package]] name = "usage-dynamic" -version = "6.5.0" +version = "6.6.0" dependencies = [ "futures", "usage-argv", @@ -2089,7 +2089,7 @@ dependencies = [ [[package]] name = "usage-lib" -version = "6.5.0" +version = "6.6.0" dependencies = [ "clap", "criterion", @@ -2116,7 +2116,7 @@ dependencies = [ [[package]] name = "usage-rs" -version = "6.5.0" +version = "6.6.0" dependencies = [ "serde", "shell-words", @@ -2131,14 +2131,14 @@ dependencies = [ [[package]] name = "usage-test" -version = "6.5.0" +version = "6.6.0" dependencies = [ "usage-argv", ] [[package]] name = "usage-validation" -version = "6.5.0" +version = "6.6.0" dependencies = [ "expr-lang", ] diff --git a/Cargo.toml b/Cargo.toml index caa9a1891..63f9d5754 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,20 +42,20 @@ license = "MIT" [workspace.dependencies] clap_usage = { path = "./clap_usage", version = "5.0.0" } usage-cli = { path = "./cli" } -usage-argv = { path = "./argv", version = "6.5.0" } -usage-config = { path = "./config", version = "6.5.0" } -usage-derive = { path = "./derive", version = "6.5.0" } +usage-argv = { path = "./argv", version = "6.6.0" } +usage-config = { path = "./config", version = "6.6.0" } +usage-derive = { path = "./derive", version = "6.6.0" } # No features, and defaults off. A feature named here is inherited by every member that writes # `workspace = true` and inlines into their published manifests — so `clap` and `validation` # reached members that use neither, one of them an adopter's build script. Defaults # are off rather than absent because cargo *ignores* a member's `default-features = false` unless # the workspace declaration sets it too, and warns that it may become a hard error. Members name # what they need, `docs` included. -usage-lib = { path = "./lib", version = "6.5.0", default-features = false } -usage-rs = { path = "./usage-rs", version = "6.5.0" } -usage-dynamic = { path = "./usage-dynamic", version = "6.5.0" } -usage-test = { path = "./test", version = "6.5.0" } -usage-validation = { path = "./validation", version = "6.5.0" } +usage-lib = { path = "./lib", version = "6.6.0", default-features = false } +usage-rs = { path = "./usage-rs", version = "6.6.0" } +usage-dynamic = { path = "./usage-dynamic", version = "6.6.0" } +usage-test = { path = "./test", version = "6.6.0" } +usage-validation = { path = "./validation", version = "6.6.0" } [workspace.metadata.release] allow-branch = ["main"] diff --git a/argv/Cargo.toml b/argv/Cargo.toml index cf58defaf..67934cfb5 100644 --- a/argv/Cargo.toml +++ b/argv/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "usage-argv" description = "Zero-allocation argv parser for usage specs" -version = "6.5.0" +version = "6.6.0" edition = "2021" rust-version = "1.91" homepage = { workspace = true } diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 39a678259..9526de466 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -2,7 +2,7 @@ name = "usage-cli" edition = "2021" rust-version = "1.91" -version = "6.5.0" +version = "6.6.0" description = "CLI for working with usage-based CLIs" license = { workspace = true } authors = { workspace = true } diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl index 6a0c770a0..1c51b3abf 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -2,7 +2,7 @@ min_usage_version "6.5" name usage bin usage -version "6.5.0" +version "6.6.0" repository "https://github.com/jdx/usage" source_code_link_template #""" {%- set path = path | replace(from='-', to='_') -%} diff --git a/config/Cargo.toml b/config/Cargo.toml index c8ae24369..0e648055b 100644 --- a/config/Cargo.toml +++ b/config/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "usage-config" description = "Layered configuration resolution for usage specs, with provenance" -version = "6.5.0" +version = "6.6.0" edition = "2021" rust-version = "1.91" homepage = { workspace = true } diff --git a/conformance/tests/clause.rs b/conformance/tests/clause.rs index 643993d51..a2da9a910 100644 --- a/conformance/tests/clause.rs +++ b/conformance/tests/clause.rs @@ -3,7 +3,7 @@ use usage::Spec; fn spec() -> Spec { r#" -min_usage_version "6.5" +min_usage_version "6.6" name "clause" bin "clause" clause "tasks" separator=":::" { diff --git a/derive/Cargo.toml b/derive/Cargo.toml index eb9613962..26e577b20 100644 --- a/derive/Cargo.toml +++ b/derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "usage-derive" description = "Derive macro that compiles a CLI definition into parse tables and a usage spec" -version = "6.5.0" +version = "6.6.0" edition = "2021" rust-version = "1.91" homepage = { workspace = true } diff --git a/docs/cli/reference/commands.json b/docs/cli/reference/commands.json index 44b4e737b..723cc2bde 100644 --- a/docs/cli/reference/commands.json +++ b/docs/cli/reference/commands.json @@ -2014,7 +2014,7 @@ "sources": {}, "files": [] }, - "version": "6.5.0", + "version": "6.6.0", "usage": "Usage: usage \n usage --completions \n usage --usage-spec", "complete": {}, "source_code_link_template": "{%- set path = path | replace(from='-', to='_') -%}\n{%- if cmd.subcommands | length > 0 -%}\n{%- set path = path ~ \"/mod.rs\" -%}\n{%- elif path in [\"bash\", \"fish\", \"powershell\", \"zsh\"] -%}\n{%- set path = \"shell.rs\" -%}\n{%- else -%}\n{%- set path = path ~ \".rs\" -%}\n{%- endif -%}\nhttps://github.com/jdx/usage/blob/main/cli/src/cli/{{path}}", diff --git a/docs/cli/reference/index.md b/docs/cli/reference/index.md index 634cf724f..6d4900605 100644 --- a/docs/cli/reference/index.md +++ b/docs/cli/reference/index.md @@ -4,7 +4,7 @@ **Usage:** `usage [--completions ] [--usage-spec] ` -**Version:** 6.5.0 +**Version:** 6.6.0 **Repository:** https://github.com/jdx/usage diff --git a/docs/spec/reference/clause.md b/docs/spec/reference/clause.md index 9fca40637..e84705cc3 100644 --- a/docs/spec/reference/clause.md +++ b/docs/spec/reference/clause.md @@ -4,7 +4,7 @@ A clause is a repeatable group of positional arguments. A separator ends the cur instance and starts another without discarding the values already parsed. ```kdl -min_usage_version "6.5" +min_usage_version "6.6" clause "tasks" separator=":::" { arg "" diff --git a/examples/clause.usage.kdl b/examples/clause.usage.kdl index 89e3aed78..40803d2e8 100644 --- a/examples/clause.usage.kdl +++ b/examples/clause.usage.kdl @@ -1,4 +1,4 @@ -min_usage_version "6.5" +min_usage_version "6.6" name "Clause example" bin "clause" clause "tasks" separator=":::" { diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 4d0d95eb6..cad7e8c66 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "usage-lib" edition = "2021" -version = "6.5.0" +version = "6.6.0" rust-version = "1.91" include = [ "/Cargo.toml", diff --git a/test/Cargo.toml b/test/Cargo.toml index d71513156..763e4bfab 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "usage-test" description = "Test helpers for CLIs built with usage" -version = "6.5.0" +version = "6.6.0" edition = "2021" rust-version = "1.91" homepage = { workspace = true } diff --git a/usage-dynamic/Cargo.toml b/usage-dynamic/Cargo.toml index 85c5fce33..754177a1d 100644 --- a/usage-dynamic/Cargo.toml +++ b/usage-dynamic/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "usage-dynamic" description = "Runtime command catalogs for usage-rs applications" -version = "6.5.0" +version = "6.6.0" edition = "2021" rust-version = "1.91" homepage = { workspace = true } @@ -12,7 +12,7 @@ license = { workspace = true } [dependencies] usage-argv = { workspace = true, features = ["spec", "complete"] } -usage-parser = { package = "usage-lib", path = "../lib", version = "6.5.0", default-features = false, features = ["cli-help"] } +usage-parser = { package = "usage-lib", path = "../lib", version = "6.6.0", default-features = false, features = ["cli-help"] } [package.metadata.release] shared-version = true diff --git a/usage-rs/Cargo.toml b/usage-rs/Cargo.toml index 0a88f8aa4..394b758ef 100644 --- a/usage-rs/Cargo.toml +++ b/usage-rs/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "usage-rs" description = "A compiled CLI parser for Rust, built on usage specs" -version = "6.5.0" +version = "6.6.0" edition = "2021" rust-version = "1.91" homepage = { workspace = true } diff --git a/validation/Cargo.toml b/validation/Cargo.toml index 78f21385d..c5c290c69 100644 --- a/validation/Cargo.toml +++ b/validation/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "usage-validation" description = "Portable expression validation for usage specs" -version = "6.5.0" +version = "6.6.0" edition = "2021" rust-version = "1.91" homepage = { workspace = true } From 83872ea43d001af6ebdf40a9b9563c171c702fc2 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:39:09 +0000 Subject: [PATCH 07/11] fix(parse): include clause args in relationships --- conformance/tests/clause.rs | 43 +++++++++++++++++++++++++++++++++++++ lib/src/parse.rs | 31 ++++++++++++++++++++------ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/conformance/tests/clause.rs b/conformance/tests/clause.rs index a2da9a910..503b881d4 100644 --- a/conformance/tests/clause.rs +++ b/conformance/tests/clause.rs @@ -63,3 +63,46 @@ fn clause_round_trips_through_canonical_kdl() { assert_eq!(clause.separator, ":::"); assert_eq!(clause.args.len(), 2); } + +#[test] +fn clause_arguments_participate_in_relationship_checks() { + for (spec, argv, expected) in [ + ( + r#"name "clause" +bin "clause" +clause "items" separator=":::" { + arg "[output]" requires="input" + arg "[input]" +} +"#, + vec!["clause", "artifact"], + "input", + ), + ( + r#"name "clause" +bin "clause" +flag "--json" conflicts="task" +clause "items" separator=":::" { arg "[task]" } +"#, + vec!["clause", "--json", "lint"], + "conflicts with task", + ), + ( + r#"name "clause" +bin "clause" +clause "items" separator=":::" { + arg "[trigger]" + arg "[dependent]" required_if="trigger" +} +"#, + vec!["clause", "yes"], + "dependent", + ), + ] { + let spec: Spec = spec.parse().expect("valid relationship spec"); + let error = usage::Parser::new(&spec) + .parse(&argv.into_iter().map(str::to_string).collect::>()) + .unwrap_err(); + assert!(format!("{error:?}").contains(expected), "{error:?}"); + } +} diff --git a/lib/src/parse.rs b/lib/src/parse.rs index d9063db62..a2e24ae6a 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -2573,14 +2573,18 @@ fn parse_partial_traced( // Not `skip(out.args.len())`: a `--` may have jumped the cursor past an arg that stayed // empty, so position and fill count can disagree. Ask `out.args` which args it holds. if !exclusive_present { - for arg in out + for (arg, in_clause) in out .cmds .iter() .enumerate() .filter(|(index, _)| requirements_apply(*index)) - .flat_map(|(_, cmd)| &cmd.args) + .flat_map(|(_, cmd)| { + active_args(cmd) + .iter() + .map(move |arg| (arg, cmd.clause.is_some())) + }) { - if out.args.contains_key(arg) { + if arg_is_explicit(arg, &out, custom_env) { continue; } // Already reported as needing a `--`; one mistake should not yield two messages. @@ -2619,7 +2623,7 @@ fn parse_partial_traced( let required_unless = !(unless_any || unless_all || (arg.required_unless.is_empty() && arg.required_unless_all.is_empty())); - if (arg.required + if ((!in_clause && arg.required) || required_if || required_if_eq || required_if_eq_all @@ -2713,7 +2717,7 @@ fn parse_partial_traced( .cmds .iter() .enumerate() - .flat_map(|(index, cmd)| cmd.args.iter().map(move |arg| (index, arg))) + .flat_map(|(index, cmd)| active_args(cmd).iter().map(move |arg| (index, arg))) { let given = arg_is_explicit(arg, &out, custom_env); if !given { @@ -3328,7 +3332,15 @@ fn selector_explicit_has_value( .args .iter() .find(|(given, _)| given.name == arg.name) - .map(|(_, value)| value); + .map(|(_, value)| value) + .or_else(|| { + out.clauses.values().flatten().find_map(|instance| { + instance + .iter() + .find(|(given, _)| given.name == arg.name) + .map(|(_, value)| value) + }) + }); if let Some(value) = parsed { return match value { ParseValue::String(value) => value == expected, @@ -3424,7 +3436,7 @@ fn selector_arg<'a>(selector: &str, out: &'a ParseOutput) -> Option<&'a SpecArg> } out.cmds .iter() - .flat_map(|cmd| &cmd.args) + .flat_map(|cmd| active_args(cmd)) .find(|arg| arg.name == selector) } @@ -3434,6 +3446,11 @@ fn arg_is_explicit( custom_env: Option<&HashMap>, ) -> bool { out.args.keys().any(|given| given.name == arg.name) + || out + .clauses + .values() + .flatten() + .any(|instance| instance.keys().any(|given| given.name == arg.name)) || arg .env .as_ref() From 9e7528e9b59bf2d9c0cecf11fcabd57ab5490918 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:47:47 +0000 Subject: [PATCH 08/11] fix(parse): isolate clause relationships by instance --- conformance/tests/clause.rs | 24 ++++++ lib/src/parse.rs | 150 +++++++++++++++++++++++++++++++++--- 2 files changed, 164 insertions(+), 10 deletions(-) diff --git a/conformance/tests/clause.rs b/conformance/tests/clause.rs index 503b881d4..8606adbc8 100644 --- a/conformance/tests/clause.rs +++ b/conformance/tests/clause.rs @@ -98,6 +98,30 @@ clause "items" separator=":::" { vec!["clause", "yes"], "dependent", ), + ( + r#"name "clause" +bin "clause" +clause "items" separator=":::" { + arg "[output]" requires="input" + arg "[input]" +} +"#, + vec!["clause", "first", ":::", "second", "input"], + "instance 1", + ), + ( + r#"name "clause" +bin "clause" +clause "items" separator=":::" { + arg "[trigger]" + arg "[dependent]" { + required_if_eq "trigger" "yes" + } +} +"#, + vec!["clause", "yes", ":::", "no", "present"], + "instance 1", + ), ] { let spec: Spec = spec.parse().expect("valid relationship spec"); let error = usage::Parser::new(&spec) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index a2e24ae6a..ede5615a6 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -2454,6 +2454,7 @@ fn parse_partial_traced( } record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input); + validate_clause_relationships(&mut out, &overridden_flags, custom_env); // `out.flags` is keyed by `SpecFlag`, whose equality is intentionally name-only. Two // declarations with the same canonical name therefore share one public value entry even @@ -2573,18 +2574,14 @@ fn parse_partial_traced( // Not `skip(out.args.len())`: a `--` may have jumped the cursor past an arg that stayed // empty, so position and fill count can disagree. Ask `out.args` which args it holds. if !exclusive_present { - for (arg, in_clause) in out + for arg in out .cmds .iter() .enumerate() .filter(|(index, _)| requirements_apply(*index)) - .flat_map(|(_, cmd)| { - active_args(cmd) - .iter() - .map(move |arg| (arg, cmd.clause.is_some())) - }) + .flat_map(|(_, cmd)| &cmd.args) { - if arg_is_explicit(arg, &out, custom_env) { + if out.args.contains_key(arg) { continue; } // Already reported as needing a `--`; one mistake should not yield two messages. @@ -2623,7 +2620,7 @@ fn parse_partial_traced( let required_unless = !(unless_any || unless_all || (arg.required_unless.is_empty() && arg.required_unless_all.is_empty())); - if ((!in_clause && arg.required) + if (arg.required || required_if || required_if_eq || required_if_eq_all @@ -2717,7 +2714,7 @@ fn parse_partial_traced( .cmds .iter() .enumerate() - .flat_map(|(index, cmd)| active_args(cmd).iter().map(move |arg| (index, arg))) + .flat_map(|(index, cmd)| cmd.args.iter().map(move |arg| (index, arg))) { let given = arg_is_explicit(arg, &out, custom_env); if !given { @@ -3309,6 +3306,139 @@ fn explicit_flag_has_value( ) } +fn parse_value_has(value: &ParseValue, expected: &str) -> bool { + match value { + ParseValue::Bool(value) => value.to_string() == expected, + ParseValue::String(value) => value == expected, + ParseValue::MultiBool(values) => values.iter().any(|value| value.to_string() == expected), + ParseValue::MultiString(values) => values.iter().any(|value| value == expected), + } +} + +fn validate_clause_relationships( + out: &mut ParseOutput, + overridden_flags: &HashSet, + custom_env: Option<&HashMap>, +) { + let Some(clause) = out.cmd.clause.as_ref() else { + return; + }; + let Some(instances) = out.clauses.get(&clause.name) else { + return; + }; + let flag_is_explicit = |selector: &str| { + out.available_flags + .values() + .chain(out.flags.keys()) + .any(|flag| { + flag_matches_selector(flag, selector) + && !overridden_flags.contains(&flag.name) + && (out.flags.contains_key(flag) || flag_has_env(flag, custom_env)) + }) + }; + let flag_matches_value = |selector: &str, expected: &str| { + out.available_flags + .values() + .chain(out.flags.keys()) + .find(|flag| flag_matches_selector(flag, selector)) + .is_some_and(|flag| { + !overridden_flags.contains(&flag.name) + && explicit_flag_has_value(flag, expected, out, custom_env) + }) + }; + let flag_is_satisfied = |selector: &str| { + out.available_flags + .values() + .chain(out.flags.keys()) + .any(|flag| flag_matches_selector(flag, selector)) + && selector_is_satisfied(selector, out, overridden_flags, custom_env) + }; + let mut errors = Vec::new(); + for (instance_index, instance) in instances.iter().enumerate() { + let arg_is_explicit = |selector: &str| { + instance + .keys() + .any(|arg| !selector.starts_with('-') && arg.name == selector) + }; + let selector_is_explicit = + |selector: &str| flag_is_explicit(selector) || arg_is_explicit(selector); + let selector_has_value = |selector: &str, expected: &str| { + flag_matches_value(selector, expected) + || instance.iter().any(|(arg, value)| { + !selector.starts_with('-') + && arg.name == selector + && parse_value_has(value, expected) + }) + }; + let selector_is_satisfied = + |selector: &str| selector_is_explicit(selector) || flag_is_satisfied(selector); + for arg in &clause.args { + let given = instance.keys().any(|present| present.name == arg.name); + if !given { + let required_if = arg + .required_if + .iter() + .any(|selector| selector_is_explicit(selector)); + let required_if_eq = arg + .required_if_eq + .iter() + .any(|condition| selector_has_value(&condition.selector, &condition.value)); + let required_if_eq_all = !arg.required_if_eq_all.is_empty() + && arg + .required_if_eq_all + .iter() + .all(|condition| selector_has_value(&condition.selector, &condition.value)); + let unless_any = arg + .required_unless + .iter() + .any(|selector| selector_is_explicit(selector)); + let unless_all = !arg.required_unless_all.is_empty() + && arg + .required_unless_all + .iter() + .all(|selector| selector_is_explicit(selector)); + let required_unless = (!arg.required_unless.is_empty() + || !arg.required_unless_all.is_empty()) + && !(unless_any || unless_all); + if required_if || required_if_eq || required_if_eq_all || required_unless { + errors.push(UsageErr::MissingClauseArg { + clause: clause.name.clone(), + instance: instance_index + 1, + arg: arg.name.clone(), + }); + } + continue; + } + for other in &arg.conflicts { + if selector_is_explicit(other) { + errors.push(UsageErr::InvalidFlag { + token: arg.name.clone(), + reason: format!("conflicts with {other}"), + span: (0, 0).into(), + input: format!("{} {other}", arg.name), + }); + } + } + for other in &arg.requires { + if selector_is_satisfied(other) { + continue; + } + if other.starts_with('-') { + let name = selector_flag_name(other, out).unwrap_or_else(|| other.clone()); + errors.push(UsageErr::MissingFlag(name)); + } else { + errors.push(UsageErr::MissingClauseArg { + clause: clause.name.clone(), + instance: instance_index + 1, + arg: other.clone(), + }); + } + } + } + } + out.errors.extend(errors); +} + fn selector_explicit_has_value( selector: &str, expected: &str, @@ -3436,7 +3566,7 @@ fn selector_arg<'a>(selector: &str, out: &'a ParseOutput) -> Option<&'a SpecArg> } out.cmds .iter() - .flat_map(|cmd| active_args(cmd)) + .flat_map(active_args) .find(|arg| arg.name == selector) } From d083c29697af8eb2bf29205521362b223e0c4672 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:59:09 +0000 Subject: [PATCH 09/11] fix(parse): preserve clause double dash values --- conformance/tests/clause.rs | 21 +++++++++++++++++++++ lib/src/parse.rs | 4 +--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/conformance/tests/clause.rs b/conformance/tests/clause.rs index 8606adbc8..3e3a73e7a 100644 --- a/conformance/tests/clause.rs +++ b/conformance/tests/clause.rs @@ -53,6 +53,27 @@ fn explicit_double_dash_protects_a_literal_separator() { assert_eq!(strings(instances[0].iter(), "args"), [":::", "tail"]); } +#[test] +fn clause_variadic_can_preserve_double_dash() { + let spec: Spec = r#" +name "clause" +bin "clause" +clause "tasks" separator=":::" { + arg "" + arg "[args]..." double_dash="preserve" +} +"# + .parse() + .expect("valid clause spec"); + let parsed = usage::Parser::new(&spec) + .parse(&["clause", "lint", "--", "--fix"].map(str::to_string)) + .expect("preserved double dash is clause data"); + assert_eq!( + strings(parsed.clauses["tasks"][0].iter(), "args"), + ["--", "--fix"] + ); +} + #[test] fn clause_round_trips_through_canonical_kdl() { let spec = spec(); diff --git a/lib/src/parse.rs b/lib/src/parse.rs index ede5615a6..9eff0902a 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1765,9 +1765,7 @@ fn parse_partial_traced( // Only preserve the double dash token if we're collecting values for a variadic arg // in double_dash == `preserve` mode - let should_preserve = out - .cmd - .args + let should_preserve = active_args(&out.cmd) .get(next_arg_idx) .map(|arg| arg.var && arg.double_dash == SpecDoubleDashChoices::Preserve) .unwrap_or(false); From 233c87fd124e03fae50dfe9bb680e1400538ce5b Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:26:31 +0000 Subject: [PATCH 10/11] chore(spec): update runtime fixture lockfile --- usage-rs/tests/fixtures/runtime-identity/Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/usage-rs/tests/fixtures/runtime-identity/Cargo.lock b/usage-rs/tests/fixtures/runtime-identity/Cargo.lock index a302137d0..6a68cddfe 100644 --- a/usage-rs/tests/fixtures/runtime-identity/Cargo.lock +++ b/usage-rs/tests/fixtures/runtime-identity/Cargo.lock @@ -39,11 +39,11 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "usage-argv" -version = "6.5.0" +version = "6.6.0" [[package]] name = "usage-derive" -version = "6.5.0" +version = "6.6.0" dependencies = [ "proc-macro2", "quote", @@ -52,7 +52,7 @@ dependencies = [ [[package]] name = "usage-rs" -version = "6.5.0" +version = "6.6.0" dependencies = [ "usage-argv", "usage-derive", From 7db6c6c30649c4be9f8583913baf636ca3eb0dee Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:58:21 +0000 Subject: [PATCH 11/11] style(parse): simplify clause predicate --- lib/src/parse.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 9eff0902a..d3ce79362 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -3395,9 +3395,9 @@ fn validate_clause_relationships( .required_unless_all .iter() .all(|selector| selector_is_explicit(selector)); - let required_unless = (!arg.required_unless.is_empty() - || !arg.required_unless_all.is_empty()) - && !(unless_any || unless_all); + let required_unless = !(unless_any + || unless_all + || (arg.required_unless.is_empty() && arg.required_unless_all.is_empty())); if required_if || required_if_eq || required_if_eq_all || required_unless { errors.push(UsageErr::MissingClauseArg { clause: clause.name.clone(),