Skip to content
Merged
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
8 changes: 7 additions & 1 deletion argv/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ fn walk_inner<'t>(
}

let next_arg = parser.pending_arg();
let mut flags: Vec<_> = parser.flags_in_scope().collect();
if parser.command().default_subcommand_flags {
if let Some(default) = parser.command().default_subcommand {
flags.extend_from_slice(default.flags);
}
}
Position {
path: parser.command_path(),
cmd: parser.command(),
Expand All @@ -197,7 +203,7 @@ fn walk_inner<'t>(
separator_seen: parser.double_dash_seen(),
command_start: parser.command_start(),
help_topic: false,
flags: parser.flags_in_scope().collect(),
flags,
external,
}
}
Expand Down
210 changes: 208 additions & 2 deletions argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ pub struct Command<'a> {
/// Resolve it with [`find_subcommand`], which turns a name that no subcommand answers to
/// into a compile error.
pub default_subcommand: ::core::option::Option<&'a Command<'a>>,
/// Look ahead past parent/default flags before implicitly selecting the default.
/// Explicit siblings win; parent-only flags retain their ordinary meaning.
pub default_subcommand_flags: bool,
/// Whether an unmatched word is forwarded as an external command plus the rest of argv.
///
/// clap's `allow_external_subcommands`. Known subcommands still win; a
Expand Down Expand Up @@ -290,6 +293,7 @@ impl Command<'_> {
clause: ::core::option::Option::None,
subcommands: &[],
default_subcommand: ::core::option::Option::None,
default_subcommand_flags: false,
external_subcommand: false,
arg_required_else_help: false,
subcommand_negates_reqs: false,
Expand Down Expand Up @@ -1622,7 +1626,10 @@ fn os_values_given<'t, 'v, T: From<OsString>>(
Ok(out)
}

/// A single-pass parse over `argv`.
/// A single binding pass over `argv`.
///
/// [`Command::default_subcommand_flags`] adds a read-only lookahead before binding
/// to choose an implicit command boundary.
///
/// Created with [`Parser::new`] and driven with [`Parser::next_event`].
pub struct Parser<'t, 'a, 'v> {
Expand Down Expand Up @@ -1689,6 +1696,10 @@ pub struct Parser<'t, 'a, 'v> {
/// Once, per parse: a default subcommand that itself declares one would otherwise
/// descend on every word until the tree ran out.
default_taken: bool,
/// First default-only flag, when lookahead found no explicit sibling.
default_flag_at: Option<usize>,
/// Cursor just after the implicit boundary bundle, whose shorts keep parent ownership.
default_bundle_end: usize,
/// Set once a fatal error has been reported, so iteration stops.
done: bool,
/// Whether declared built-in actions stop parsing with their action error.
Expand Down Expand Up @@ -1727,7 +1738,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
argv: &'a [&'v OsStr],
action_errors: bool,
) -> Self {
Parser {
let mut parser = Parser {
argv,
pos: 0,
cmd: root,
Expand Down Expand Up @@ -1756,11 +1767,154 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
flags_stopped: false,
separator_seen: false,
default_taken: false,
default_flag_at: None,
default_bundle_end: 0,
done: false,
action_errors,
help_span: (0, 0),
pending_clause_boundary: ::core::option::Option::None,
};
if root.default_subcommand_flags {
parser.default_flag_at = parser.default_flag_route();
}
parser
}

/// Find the implicit boundary without binding anything. Parent spellings win while
/// scanning the prefix; after the boundary the ordinary child/global scope applies.
/// Unknown flags stop lookahead because their value arity cannot be guessed.
fn default_flag_route(&self) -> Option<usize> {
let default = self.cmd.default_subcommand?;
let mut at = None;
let mut i = 0;
while let Some(token) = self.argv.get(i).map(bytes) {
let scope = if at.is_some() { default } else { self.cmd };
if token == b"--"
|| token == b"-"
|| scope.clause.is_some_and(|c| c.separator == Some(token))
{
return at;
}
if !is_flag_like(token) {
return if self.find_subcommand(token).is_some()
|| (token == b"help" && !self.cmd.disable_help_subcommand)
{
None
} else {
at
};
}
let mut value_flag = None;
let mut attached = None;
if let Some(body) = token.strip_prefix(b"--") {
let end = body.iter().position(|b| *b == b'=').unwrap_or(body.len());
let name = &body[..end];
let parent = self.find_long(name).or_else(|| self.find_negation(name));
if parent.is_none()
&& ((name == b"help" && !self.cmd.disable_help_flag)
|| (name == b"version"
&& self.cmd.version
&& !self.cmd.disable_version_flag))
{
i += 1;
continue;
}
let flag = parent.or_else(|| {
default.flags.iter().copied().find(|f| {
f.longs.iter().any(|l| l.as_bytes() == name)
|| f.negate.is_some_and(|n| n.as_bytes() == name)
})
});
let Some(flag) = flag else {
return at;
};
if parent.is_none() {
at.get_or_insert(i);
}
if flag.takes_value && flag.negate.is_none_or(|n| n.as_bytes() != name) {
value_flag = Some(flag);
attached = (end < body.len()).then(|| &body[end + 1..]);
}
} else {
for (offset, byte) in token[1..].iter().enumerate() {
let parent = self.find_short(*byte);
let Some(flag) = parent.or_else(|| {
default
.flags
.iter()
.copied()
.find(|f| f.shorts.contains(byte))
}) else {
return at;
};
if parent.is_none() {
at.get_or_insert(i);
}
if flag.takes_value {
value_flag = Some(flag);
let rest = &token[offset + 2..];
attached =
(!rest.is_empty()).then_some(rest.strip_prefix(b"=").unwrap_or(rest));
break;
}
}
}
i += 1;
if let Some(flag) = value_flag {
let scope = if at.is_some() { default } else { self.cmd };
let is_separator =
|next: &[u8]| scope.clause.is_some_and(|c| c.separator == Some(next));
let first = if let Some(value) = attached {
Some(value)
} else if !flag.require_equals {
self.argv
.get(i)
.map(bytes)
.filter(|next| {
!is_separator(next)
&& (flag.allow_hyphen_values
|| !is_flag_like(next)
|| (flag.allow_negative_numbers && is_negative_number(next)))
})
.inspect(|_| i += 1)
} else {
None
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if first.is_none() && !flag.value_optional && flag.default_missing.is_none() {
return at;
}
if flag.variadic {
let mut count = first.map_or(0, |v| values_in(v, flag.delimiter));
while flag.var_max.is_none_or(|max| count < max) {
let Some(next) = self.argv.get(i).map(bytes) else {
break;
};
if is_separator(next) {
break;
}
if next == b"--" || flag.value_terminator.is_some_and(|end| end == next) {
if next != b"--" {
i += 1;
}
break;
}
if is_flag_like(next)
&& !(flag.allow_negative_numbers && is_negative_number(next))
{
break;
}
if self.cmd.subcommand_precedence_over_arg
&& self.find_subcommand(next).is_some()
{
return None;
}
count += values_in(next, flag.delimiter);
i += 1;
}
}
}
}
at
}

/// Restrict inherited root globals to those carried by an executable view.
Expand Down Expand Up @@ -1880,7 +2034,47 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
event
}

/// Count parent flags in the shared boundary token before entering the child.
fn default_bundle_has_parent_flag(&self, default: &Command<'_>) -> bool {
let Some(token) = self.argv.get(self.pos).map(bytes) else {
return false;
};
if !token.starts_with(b"-") || token.starts_with(b"--") {
return false;
}
for byte in &token[1..] {
if self.find_short(*byte).is_some() {
return true;
}
match default.flags.iter().find(|f| f.shorts.contains(byte)) {
Some(flag) if !flag.takes_value => {}
_ => break,
}
}
false
}

fn step(&mut self) -> Option<Result<Event<'t, 'a, 'v>, Error<'t, 'v>>> {
if self.default_flag_at == Some(self.pos) && self.bundle.is_empty() {
self.default_flag_at = None;
if let Some(default) = self.cmd.default_subcommand {
if self.cmd.args_conflicts_with_subcommands
&& (self.command_arg_found || self.default_bundle_has_parent_flag(default))
{
return Some(Err(Error::SubcommandConflict {
subcommand: default,
}));
}
if self.argv.get(self.pos).is_some_and(|word| {
let token = bytes(word);
token.starts_with(b"-") && !token.starts_with(b"--")
}) {
self.default_bundle_end = self.pos + 1;
}
self.default_taken = true;
return Some(self.descend(default).map(|()| Event::Command(default)));
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
if let Some(clause) = self.pending_clause_boundary.take() {
self.arg_pos = 0;
self.arg_taken = 0;
Expand Down Expand Up @@ -2684,6 +2878,18 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
}

fn find_short(&self, byte: u8) -> Option<&'t Flag<'t>> {
// Only the boundary token is shared. Later tokens use ordinary child/global scope.
if self.default_taken && self.pos == self.default_bundle_end {
if let Some(flag) = self.ancestors[0].and_then(|parent| {
parent
.flags
.iter()
.copied()
.find(|f| f.shorts.contains(&byte))
}) {
return Some(flag);
}
}
self.in_scope()
.find(|f| f.shorts.contains(&byte))
// As for `--help`: supplied by the parser, and only where the command has not
Expand Down
3 changes: 3 additions & 0 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1593,6 +1593,9 @@ impl Spec<'_> {
if let Some(default_subcommand) = self.default_subcommand {
prop(out, "default_subcommand", default_subcommand)?;
}
if self.root.cmd.default_subcommand_flags {
writeln!(out, "default_subcommand_flags #true")?;
}
if self.multicall {
writeln!(out, "multicall #true")?;
}
Expand Down
22 changes: 21 additions & 1 deletion cli/src/cli/complete_word.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,27 @@ impl CompleteWord {
let mut has_explicit_choices = false;
// Not `available_flags`: inside a mounted command, the mounting CLI's flags stay
// recognized for parsing but are not accepted there, so they must not be offered.
let flags = parsed.completion_flags();
let mut flags = parsed.completion_flags();
if spec.default_subcommand_flags && parsed.cmds.len() == 1 {
if let Some(default) = spec
.default_subcommand
.as_deref()
.and_then(|name| spec.cmd.find_subcommand(name))
{
for flag in &default.flags {
let flag = Arc::new(flag.clone());
for key in flag
.long
.iter()
.map(|name| format!("--{name}"))
.chain(flag.short.iter().map(|name| format!("-{name}")))
.chain(flag.negate.iter().cloned())
{
flags.entry(key).or_insert_with(|| Arc::clone(&flag));
}
}
}
}
// An explicit `--` stops the parser reading flags, so past one there is no such thing
// as a flag to complete — a dash-prefixed word is a positional value.
let restart_seen = parsed.tokens.iter().any(|token| {
Expand Down
9 changes: 9 additions & 0 deletions cli/src/cli/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ impl usage_rs::Run for Lint {
pub fn lint_spec(spec: &Spec, opts: LintOptions) -> Vec<LintIssue> {
let mut issues = Vec::new();

if spec.default_subcommand_flags && spec.default_subcommand.is_none() {
issues.push(LintIssue {
severity: Severity::Error,
code: "invalid-default-subcommand-flags".to_string(),
message: "default_subcommand_flags requires default_subcommand".to_string(),
location: None,
});
}

// Check default_subcommand reference
if let Some(default_subcmd) = &spec.default_subcommand {
// Resolved the way a typed word is, rather than by canonical key alone: the name may
Expand Down
1 change: 1 addition & 0 deletions conformance/src/argv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ pub fn run(vector: &Vector) -> Outcome {
.or_else(|| subcommands().find(|sub| sub.aliases.contains(&name)));
Box::leak(Box::new(Command {
default_subcommand: default,
default_subcommand_flags: spec.default_subcommand_flags,
..*root
}))
}
Expand Down
6 changes: 6 additions & 0 deletions conformance/src/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ pub fn build(
// Both filled in by the caller for the root, which is the only place a spec declares
// either.
default_subcommand: None,
default_subcommand_flags: false,
version: false,
disable_help_flag: cmd.disable_help_flag,
disable_help_subcommand: cmd.disable_help_subcommand,
Expand Down Expand Up @@ -268,6 +269,11 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> {
// describing a flag that never binds.
let root_cmd: &'static Command<'static> = Box::leak(Box::new(Command {
version: spec.version.is_some(),
default_subcommand_flags: spec.default_subcommand_flags,
default_subcommand: spec
.default_subcommand
.as_deref()
.map(|name| usage_argv::find_subcommand(root.cmd.subcommands, name)),
..*root.cmd
}));
let mut root_examples = root.meta.examples.to_vec();
Expand Down
Loading