Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 1 addition & 1 deletion argv/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down
88 changes: 85 additions & 3 deletions argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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.
Expand All @@ -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 });
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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;
Expand Down
36 changes: 33 additions & 3 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ fn duplicate_group_name(meta: &CommandMeta<'_>) -> Option<std::string::String> {
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;
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -3191,11 +3209,17 @@ fn exact_arity(min: Option<usize>, max: Option<usize>) -> Option<usize> {

/// 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::<Vec<_>>()
.join(" ");
return values;
Expand All @@ -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::<Vec<_>>()
.join(" ");
return if meta.arg.double_dash == DoubleDash::Required {
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
12 changes: 12 additions & 0 deletions cli/src/cli/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion cli/src/cli/explain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
Comment thread
jdx marked this conversation as resolved.
TokenRole::Separator => Self::Separator,
TokenRole::ValueTerminator { ends } => Self::ValueTerminator { ends: ends.clone() },
TokenRole::Restart => Self::Restart,
Expand Down Expand Up @@ -663,7 +667,11 @@ fn arg_origins(out: &ParseOutput, arg: &SpecArg) -> Vec<OriginRow> {
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)
}
Expand Down
2 changes: 1 addition & 1 deletion cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading