diff --git a/Cargo.lock b/Cargo.lock index 65eb36e9a..50367f7d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2012,11 +2012,11 @@ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "usage-argv" -version = "6.4.1" +version = "6.5.0" [[package]] name = "usage-cli" -version = "6.4.1" +version = "6.5.0" dependencies = [ "assert_cmd", "ctor", @@ -2043,7 +2043,7 @@ dependencies = [ [[package]] name = "usage-config" -version = "6.4.1" +version = "6.5.0" dependencies = [ "serde_json", "toml", @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "usage-derive" -version = "6.4.1" +version = "6.5.0" dependencies = [ "heck", "proc-macro2", @@ -2079,7 +2079,7 @@ dependencies = [ [[package]] name = "usage-dynamic" -version = "6.4.1" +version = "6.5.0" dependencies = [ "futures", "usage-argv", @@ -2089,7 +2089,7 @@ dependencies = [ [[package]] name = "usage-lib" -version = "6.4.1" +version = "6.5.0" dependencies = [ "clap", "criterion", @@ -2116,7 +2116,7 @@ dependencies = [ [[package]] name = "usage-rs" -version = "6.4.1" +version = "6.5.0" dependencies = [ "serde", "shell-words", @@ -2131,14 +2131,14 @@ dependencies = [ [[package]] name = "usage-test" -version = "6.4.1" +version = "6.5.0" dependencies = [ "usage-argv", ] [[package]] name = "usage-validation" -version = "6.4.1" +version = "6.5.0" dependencies = [ "expr-lang", ] diff --git a/Cargo.toml b/Cargo.toml index 373540ab4..caa9a1891 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.4.1" } -usage-config = { path = "./config", version = "6.4.1" } -usage-derive = { path = "./derive", version = "6.4.1" } +usage-argv = { path = "./argv", version = "6.5.0" } +usage-config = { path = "./config", version = "6.5.0" } +usage-derive = { path = "./derive", version = "6.5.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.4.1", default-features = false } -usage-rs = { path = "./usage-rs", version = "6.4.1" } -usage-dynamic = { path = "./usage-dynamic", version = "6.4.1" } -usage-test = { path = "./test", version = "6.4.1" } -usage-validation = { path = "./validation", version = "6.4.1" } +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" } [workspace.metadata.release] allow-branch = ["main"] diff --git a/argv/Cargo.toml b/argv/Cargo.toml index 5bd3fb229..cf58defaf 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.4.1" +version = "6.5.0" edition = "2021" rust-version = "1.91" homepage = { workspace = true } diff --git a/argv/src/lib.rs b/argv/src/lib.rs index d652470d6..e09f97b30 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,26 @@ 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) { + if token.len() == sigil.len() { + return Err(invalid_value_error( + arg.name, + as_str(token).unwrap_or_default().to_string(), + format!( + "expected a value after sigil {:?}", + as_str(sigil).unwrap_or_default() + ), + )); + } + 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 +2269,27 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } } + if self.arg_filled && !self.flags_stopped { + if let Some((arg, sigil)) = self.match_sigil_arg(token) { + if token.len() == sigil.len() { + return Err(invalid_value_error( + arg.name, + as_str(token).unwrap_or_default().to_string(), + format!( + "expected a value after sigil {:?}", + as_str(sigil).unwrap_or_default() + ), + )); + } + return Ok(Event::Arg { + arg, + value: &token[sigil.len()..], + delimit: true, + }); + } + } + + self.skip_sigil_args(); self.reserve_for_required_positionals(); let Some(arg) = self.next_arg() else { return Err(Error::UnexpectedArg { token }); @@ -2315,8 +2360,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 +2389,39 @@ 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; + } + let own = self.cmd.args.iter().copied(); + let inherited = self.ancestors[..self.depth] + .iter() + .rev() + .filter_map(|cmd| *cmd) + .flat_map(|cmd| cmd.args.iter().copied()); + own.chain(inherited) + .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 @@ -2360,14 +2439,17 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } let required_after = self.cmd.args[self.arg_pos + 1..] .iter() - .filter(|arg| arg.required) + .filter(|arg| arg.required && arg.sigil.is_none()) .count(); if required_after == 0 { return; } let remaining_values = 1 + self.argv[self.pos..] .iter() - .filter(|word| self.flags_stopped || !is_flag_like(bytes(word))) + .filter(|word| { + (self.flags_stopped || !is_flag_like(bytes(word))) + && self.match_sigil_arg(bytes(word)).is_none() + }) .count(); if remaining_values > required_after { return; 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/Cargo.toml b/cli/Cargo.toml index d1527dcc2..39a678259 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -2,7 +2,7 @@ name = "usage-cli" edition = "2021" rust-version = "1.91" -version = "6.4.1" +version = "6.5.0" description = "CLI for working with usage-based CLIs" license = { workspace = true } authors = { workspace = true } 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..00ed5dacc 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, @@ -663,7 +667,11 @@ fn arg_origins(out: &ParseOutput, arg: &SpecArg) -> Vec { for token in &out.tokens { for role in &token.roles { match role { - TokenRole::Arg { arg: a, .. } if a.name == arg.name => tokens.push(token.index), + TokenRole::Arg { arg: a, .. } | TokenRole::Sigil { arg: a, .. } + if a.name == arg.name => + { + tokens.push(token.index) + } TokenRole::UnknownFlag { bound_as: Some(a) } if a.name == arg.name => { tokens.push(token.index) } 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..6a0c770a0 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -1,8 +1,8 @@ // @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" +version "6.5.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 e3f01104f..c8ae24369 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.4.1" +version = "6.5.0" edition = "2021" rust-version = "1.91" homepage = { workspace = true } diff --git a/conformance/src/argv.rs b/conformance/src/argv.rs index ca8e67d53..da6e99e97 100644 --- a/conformance/src/argv.rs +++ b/conformance/src/argv.rs @@ -208,6 +208,7 @@ fn code(err: Error<'_, '_>) -> ErrorCode { Error::MissingFlagValue { .. } => ErrorCode::MissingFlagValue, Error::UnexpectedArg { .. } => ErrorCode::UnexpectedArg, Error::ArgRequiresDoubleDash { .. } => ErrorCode::ArgRequiresDoubleDash, + Error::InvalidValue(_) => ErrorCode::InvalidValue, // Binding does raise this one, and only this one of the bounds: a `var_max` stops a // collection rather than judging it, so it can only be *exceeded* when a delimiter // makes one word several values — which is a question about where a word lands, and diff --git a/conformance/src/lib.rs b/conformance/src/lib.rs index 1015b6088..e465bdffc 100644 --- a/conformance/src/lib.rs +++ b/conformance/src/lib.rs @@ -170,6 +170,8 @@ pub enum ErrorCode { UnexpectedArg, /// A value was given that is not among the declared choices. InvalidChoice, + /// A value was rejected before typed conversion. + InvalidValue, /// A positional declared `double_dash="required"` was given before `--`. ArgRequiresDoubleDash, /// A variadic got fewer values than `var_min`. diff --git a/conformance/src/reference.rs b/conformance/src/reference.rs index 86bba2643..c16904672 100644 --- a/conformance/src/reference.rs +++ b/conformance/src/reference.rs @@ -141,6 +141,8 @@ fn classify(msg: &str) -> Observed { } } else if msg.contains("Invalid choice") || msg.contains("expected one of") { ErrorCode::InvalidChoice + } else if msg.contains("Invalid value") { + ErrorCode::InvalidValue } else if msg.contains("Unexpected argument") || msg.contains("unexpected") { ErrorCode::UnexpectedArg } else { 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..f801fb8a3 --- /dev/null +++ b/conformance/tests/sigil.rs @@ -0,0 +1,79 @@ +//! 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..9c7f0d862 --- /dev/null +++ b/corpus/14-sigil.json @@ -0,0 +1,160 @@ +{ + "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": "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-is-invalid", + "doc": "A bare classifier is an empty sigil value, not ordinary positional data.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"[tools]...\" sigil=\"+\"\narg \"\"\n", + "argv": ["+"], + "expect": { "error": "invalid_value" } + }, + { + "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