From 0f35b2e9beeaf4ba1772370942d7488af328a830 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:48:14 +0000 Subject: [PATCH 01/11] feat(spec,parse): add sigil-classified positional arguments --- argv/src/lib.rs | 47 +++++++++++- argv/src/spec.rs | 36 ++++++++- cli/src/cli/diff.rs | 12 +++ cli/src/cli/explain.rs | 4 + cli/src/cli/mod.rs | 2 +- cli/usage.usage.kdl | 2 +- conformance/src/tables.rs | 1 + conformance/tests/canonical_kdl.rs | 4 + conformance/tests/sigil.rs | 71 +++++++++++++++++ corpus/14-sigil.json | 90 ++++++++++++++++++++++ derive/src/codegen.rs | 7 +- derive/src/model.rs | 56 +++++++++++++- docs/spec/reference/arg.md | 4 + go/argv/argv.go | 2 + go/argv/parser.go | 35 ++++++++- go/internal/spec/spec.go | 2 + lib/src/docs/models.rs | 2 + lib/src/go/mod.rs | 3 + lib/src/parse.rs | 117 ++++++++++++++++++++++++++++- lib/src/sdk/python/mod.rs | 13 +++- lib/src/sdk/typescript/wrappers.rs | 13 +++- lib/src/spec/arg.rs | 47 +++++++++++- lib/src/spec/builder.rs | 6 ++ lib/src/spec/cmd.rs | 12 +++ 24 files changed, 570 insertions(+), 18 deletions(-) create mode 100644 conformance/tests/sigil.rs create mode 100644 corpus/14-sigil.json diff --git a/argv/src/lib.rs b/argv/src/lib.rs index d652470d6..105720c45 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -515,6 +515,9 @@ pub struct Arg<'a> { /// Caller-assigned identifier, echoed back in [`Event::Arg`]. See /// [`Command::key`] on why it is this wide. pub key: u64, + /// Prefix that classifies this positional independently of declaration order. + /// The prefix is removed from the value emitted in [`Event::Arg`]. + pub sigil: ::core::option::Option<&'a [u8]>, /// Whether post-binding requires this positional to have a value. Kept in the hot /// table because `allow_missing_positional` must reserve words for later required args. pub required: bool, @@ -549,6 +552,7 @@ impl Arg<'_> { /// A single-value argument, for use with struct update syntax. pub const REQUIRED: Arg<'static> = Arg { key: 0, + sigil: ::core::option::Option::None, required: true, var: false, var_max: ::core::option::Option::None, @@ -2229,6 +2233,16 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } } + // Known subcommands and the default route keep precedence. A sigil positional + // then claims its classified word before an external-subcommand catch-all can. + if let Some((arg, sigil)) = self.match_sigil_arg(token) { + return Ok(Event::Arg { + arg, + value: &token[sigil.len()..], + delimit: true, + }); + } + // An unmatched word that names no subcommand is forwarded as an external // command: this word, then every token after it, including flags. Known // subcommands already won above, and a default_subcommand already caught. @@ -2245,6 +2259,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } } + self.skip_sigil_args(); self.reserve_for_required_positionals(); let Some(arg) = self.next_arg() else { return Err(Error::UnexpectedArg { token }); @@ -2315,8 +2330,10 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { /// Move to the next positional, forgetting what the last one took. fn advance_arg(&mut self) { + self.skip_sigil_args(); self.arg_pos += 1; self.arg_taken = 0; + self.skip_sigil_args(); } /// A variadic flag occurrence begins, counting from zero. @@ -2342,7 +2359,35 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } fn next_arg(&self) -> Option<&'t Arg<'t>> { - self.cmd.args.get(self.arg_pos).copied() + self.cmd.args[self.arg_pos..] + .iter() + .find(|arg| arg.sigil.is_none()) + .copied() + } + + fn skip_sigil_args(&mut self) { + while self + .cmd + .args + .get(self.arg_pos) + .is_some_and(|arg| arg.sigil.is_some()) + { + self.arg_pos += 1; + } + } + + fn match_sigil_arg(&self, token: &[u8]) -> Option<(&'t Arg<'t>, &'t [u8])> { + if self.flags_stopped { + return None; + } + self.cmd + .args + .iter() + .filter_map(|arg| { + let sigil = arg.sigil?; + (token.len() > sigil.len() && token.starts_with(sigil)).then_some((*arg, sigil)) + }) + .max_by_key(|(_, sigil)| sigil.len()) } /// Skip empty optional positionals when every remaining value is needed by a later diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 3fefe98e9..f8cf423a9 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -129,6 +129,9 @@ fn duplicate_group_name(meta: &CommandMeta<'_>) -> Option { fn unfillable_arg<'a>(cmd: &Command<'a>) -> Option<&'a str> { let mut variadic: Option<&Arg<'_>> = None; for arg in cmd.args { + if arg.sigil.is_some() { + continue; + } // A `--` stops the collecting, so an argument behind one is still reachable — but only // one separator exists, so nothing can follow *that*. let stopped_by_separator = arg.double_dash == DoubleDash::Required; @@ -2778,10 +2781,25 @@ fn write_arg(out: &mut String, meta: &ArgMeta<'_>, depth: usize) -> core::fmt::R meta.arg.name }; write!(out, "arg {}", quoted(&arg_placeholder(name, meta)))?; + if let Some(sigil) = meta.arg.sigil { + write!( + out, + " sigil={}", + quoted(::core::str::from_utf8(sigil).unwrap_or_default()) + )?; + } if let Some(help) = meta.help { write!(out, " help={}", quoted(help))?; } + if meta.arg.sigil.is_some() { + if !meta.required { + out.push_str(" required=#false"); + } + if meta.arg.var { + out.push_str(" var=#true"); + } + } if meta.hide { out.push_str(" hide=#true"); } @@ -3191,11 +3209,17 @@ fn exact_arity(min: Option, max: Option) -> Option { /// A positional's placeholder: angle brackets when required, square when not. fn arg_placeholder(name: &str, meta: &ArgMeta<'_>) -> String { + let sigil = meta + .arg + .sigil + .and_then(|value| ::core::str::from_utf8(value).ok()) + .unwrap_or_default(); + let name = format!("{sigil}{name}"); if let Some(arity) = exact_arity(meta.var_min, meta.var_max).filter(|n| *n > 1 && meta.value_names.len() <= 1) { let values = (0..arity) - .map(|_| placeholder(name, false, !meta.required)) + .map(|_| placeholder(&name, false, !meta.required)) .collect::>() .join(" "); return values; @@ -3209,7 +3233,7 @@ fn arg_placeholder(name: &str, meta: &ArgMeta<'_>) -> String { let values = meta .value_names .iter() - .map(|name| format!("{open}{name}{close}")) + .map(|value_name| format!("{open}{sigil}{value_name}{close}")) .collect::>() .join(" "); return if meta.arg.double_dash == DoubleDash::Required { @@ -3218,7 +3242,13 @@ fn arg_placeholder(name: &str, meta: &ArgMeta<'_>) -> String { values }; } - let ellipsis = if meta.arg.var { "..." } else { "" }; + let ellipsis = if meta.arg.var && meta.arg.sigil.is_some() { + "…" + } else if meta.arg.var { + "..." + } else { + "" + }; if meta.required { format!("<{name}>{ellipsis}") } else { diff --git a/cli/src/cli/diff.rs b/cli/src/cli/diff.rs index 4003784ee..ba8594937 100644 --- a/cli/src/cli/diff.rs +++ b/cli/src/cli/diff.rs @@ -1164,6 +1164,18 @@ fn diff_arg(old: &SpecArg, new: &SpecArg, path: &str, subject: &str, c: &mut Cha ); } + if old.sigil != new.sigil { + c.breaking( + "sigil-changed", + path, + format!( + "{subject} sigil changed from {} to {}", + option(&old.sigil), + option(&new.sigil) + ), + ); + } + if old.allow_negative_numbers && !new.allow_negative_numbers { c.breaking( "allow-negative-numbers-removed", diff --git a/cli/src/cli/explain.rs b/cli/src/cli/explain.rs index 91e1da593..415853c36 100644 --- a/cli/src/cli/explain.rs +++ b/cli/src/cli/explain.rs @@ -535,6 +535,10 @@ impl RoleRow { name: arg.name.clone(), values: values.clone(), }, + TokenRole::Sigil { arg, values, .. } => Self::Arg { + name: arg.name.clone(), + values: values.clone(), + }, TokenRole::Separator => Self::Separator, TokenRole::ValueTerminator { ends } => Self::ValueTerminator { ends: ends.clone() }, TokenRole::Restart => Self::Restart, diff --git a/cli/src/cli/mod.rs b/cli/src/cli/mod.rs index bfb019813..35ee4cb39 100644 --- a/cli/src/cli/mod.rs +++ b/cli/src/cli/mod.rs @@ -32,7 +32,7 @@ mod sponsors; #[usage( bin = "usage", version, - min_usage_version = "4.0", + min_usage_version = "6.5", repository = "https://github.com/jdx/usage", // The command path is not the file path: command names are hyphenated where the files // that implement them are snake_case, a command with subcommands lives in its directory's diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl index 51370b628..a7a20fa3f 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -1,5 +1,5 @@ // @generated by usage-cli from its own parse tables -min_usage_version "4.0" +min_usage_version "6.5" name usage bin usage version "6.4.1" diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 4850e67d2..7aa24fad8 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -351,6 +351,7 @@ fn build_flag(f: &SpecFlag) -> &'static Flag<'static> { fn build_arg(a: &SpecArg) -> &'static Arg<'static> { Box::leak(Box::new(Arg { key: 0, + sigil: a.sigil.as_deref().map(|value| leak(value).as_bytes()), required: a.required, name: leak(&a.name), var: a.var, diff --git a/conformance/tests/canonical_kdl.rs b/conformance/tests/canonical_kdl.rs index b507d0ef2..776471eed 100644 --- a/conformance/tests/canonical_kdl.rs +++ b/conformance/tests/canonical_kdl.rs @@ -136,6 +136,10 @@ struct MaximalRun { #[usage(long, help_heading = "Task Options")] config: Option, + /// Temporary tool selections. + #[usage(sigil = "+")] + tools: Vec, + /// Task name. task: String, } diff --git a/conformance/tests/sigil.rs b/conformance/tests/sigil.rs new file mode 100644 index 000000000..e0b4cc049 --- /dev/null +++ b/conformance/tests/sigil.rs @@ -0,0 +1,71 @@ +//! Sigil-classified positional arguments agree between the typed and interpreted parsers. + +use std::ffi::OsStr; + +use usage::parse::ParseValue; +use usage::Spec as LibSpec; +use usage_derive::Cli; + +#[derive(Debug, Cli)] +#[usage(bin = "overlay")] +struct Overlay { + /// Temporary tool selections. + #[usage(sigil = "+")] + tools: Vec, + /// Command to execute. + command: String, + /// Arguments passed to the command. + args: Vec, +} + +fn interpreted_values(spec: &LibSpec, argv: &[&str], name: &str) -> Vec { + let argv = std::iter::once("overlay") + .chain(argv.iter().copied()) + .map(str::to_string) + .collect::>(); + let parsed = usage::Parser::new(spec).parse(&argv).expect("valid invocation"); + parsed + .args + .iter() + .find(|(arg, _)| arg.name.eq_ignore_ascii_case(name)) + .map(|(_, value)| match value { + ParseValue::String(value) => vec![value.clone()], + ParseValue::MultiString(values) => values.clone(), + other => panic!("unexpected value for {name}: {other:?}"), + }) + .unwrap_or_default() +} + +#[test] +fn sigils_strip_and_do_not_advance_the_positional_cursor() { + let argv = [ + OsStr::new("+node@27"), + OsStr::new("+python@3.14"), + OsStr::new("node"), + OsStr::new("-v"), + ]; + let typed = Overlay::parse_from(&argv).expect("typed parser accepts overlays"); + assert_eq!(typed.tools, ["node@27", "python@3.14"]); + assert_eq!(typed.command, "node"); + assert_eq!(typed.args, ["-v"]); + + let spec: LibSpec = Overlay::to_kdl().parse().expect("derived KDL is valid"); + assert_eq!(spec.cmd.args[0].sigil.as_deref(), Some("+")); + assert_eq!(interpreted_values(&spec, &["+node@27", "node"], "tools"), ["node@27"]); + assert_eq!(interpreted_values(&spec, &["+node@27", "node"], "command"), ["node"]); +} + +#[test] +fn double_dash_protects_a_literal_sigil_word() { + let argv = [OsStr::new("node"), OsStr::new("--"), OsStr::new("+literal")]; + let typed = Overlay::parse_from(&argv).expect("literal reaches ordinary args"); + assert!(typed.tools.is_empty()); + assert_eq!(typed.args, ["+literal"]); + + let spec: LibSpec = Overlay::to_kdl().parse().expect("derived KDL is valid"); + assert!(interpreted_values(&spec, &["node", "--", "+literal"], "tools").is_empty()); + assert_eq!( + interpreted_values(&spec, &["node", "--", "+literal"], "args"), + ["+literal"] + ); +} diff --git a/corpus/14-sigil.json b/corpus/14-sigil.json new file mode 100644 index 000000000..9f6703381 --- /dev/null +++ b/corpus/14-sigil.json @@ -0,0 +1,90 @@ +{ + "section": "sigil arguments", + "about": "A sigil classifies a leading-segment positional by prefix, strips that prefix from its value, and leaves the ordinary positional cursor untouched.", + "vectors": [ + { + "id": "sigil-values-accumulate-without-advancing", + "doc": "Several classified values may surround flags before the first ordinary positional.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--quiet\"\narg \"[tools]...\" sigil=\"+\"\narg \"\"\narg \"[args]...\"\n", + "argv": ["+node@27", "--quiet", "+python@3.14", "node", "-v"], + "expect": { + "ok": { + "flags": { "quiet": true }, + "args": { + "tools": ["node@27", "python@3.14"], + "command": "node", + "args": ["-v"] + } + } + } + }, + { + "id": "longest-sigil-prefix-wins", + "doc": "Overlapping sigils select the longest matching declaration.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"[short]...\" sigil=\"+\"\narg \"[long]...\" sigil=\"++\"\narg \"\"\n", + "argv": ["++edge", "+stable", "run"], + "expect": { + "ok": { + "args": { + "short": ["stable"], + "long": ["edge"], + "command": "run" + } + } + } + }, + { + "id": "double-dash-protects-sigil", + "doc": "After an explicit separator the same prefix is ordinary data.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"[tools]...\" sigil=\"+\"\narg \"\"\narg \"[args]...\"\n", + "argv": ["run", "--", "+literal"], + "expect": { + "ok": { + "args": { "command": "run", "args": ["+literal"] } + } + } + }, + { + "id": "bare-sigil-falls-through", + "doc": "A prefix with no value is not a classified argument.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"[tools]...\" sigil=\"+\"\narg \"\"\n", + "argv": ["+"], + "expect": { "ok": { "args": { "command": "+" } } } + }, + { + "id": "flag-awaiting-value-outranks-sigil", + "doc": "A detached flag value owns the next token before sigil classification runs.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--label