Visitar URL original
fix(complete): keep root subcommands and flags with default_subcomman… · jdx/usage@01d7aeb · GitHub
Skip to content

Commit 01d7aeb

Browse files
jdxclaude
andauthored
fix(complete): keep root subcommands and flags with default_subcommand_on_empty (#1631)
With `default_subcommand_on_empty #true`, completion at the root stopped offering the root's subcommands and flags. It offered the default command's candidates instead, even though that option only describes an otherwise empty invocation. Given this spec: ```kdl name "x" bin "x" flag "--rootf" default_subcommand "def" default_subcommand_on_empty #true cmd "def" { flag "--x <x>" arg "[a]" } cmd "other" ``` Before, `usage complete-word -- x o` returned nothing and `x --` returned `--x` instead of `--rootf`. Now `x ''` offers `def` and `other`, `x o` offers `other`, and `x --` offers `--help` and `--rootf`, the same as the spec without the option. The word under the cursor is still to be typed, so the words before it are not an empty invocation. Completion now skips the empty-default route in both implementations: `parse_partial_for_completion` in usage-lib (used by `complete-word` and usage-dynamic), and the completion parser in usage-argv. Invocation parsing, `usage explain` and help are unchanged and still select the default command. Two vectors in `corpus/complete/06-default-subcommand-on-empty.json` cover subcommand and flag candidates against both implementations. `cargo test --all --all-features` passes; the shell integration tests were not run. Fixes #1630 🤖 Generated with [Claude Code](https://claude.com/claude-code) *AI-assisted — Tool: Claude Code; model: anthropic/claude-sonnet-5-5; version: unavailable.* <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Changes are scoped to completion parsing and candidate generation; invocation behavior is explicitly unchanged and covered by new tests. > > **Overview** > Completion no longer treats a partial line as an empty invocation when `default_subcommand_on_empty` is set, so **root subcommands and flags stay on the candidate list** instead of jumping into the default command. > > **usage-lib** adds `parse_partial_for_completion`, which turns off empty-default routing for completion; **`complete-word`** and **usage-dynamic** call it instead of `parse_partial`. **usage-argv** mirrors this with a `completing` flag on the completion parser so `default_subcommand_on_empty` does not fire there either. Actual invocation parsing, help, and explain are unchanged. > > For plugin/runtime completion, **usage-dynamic** re-applies the pieces that empty-default used to imply: `default_subcommand_flags` at the root, and optional first-argument **choices** from the default subcommand, with guards when the root already took a positional, after `--`, or after clause separators. > > New corpus vectors and catalog tests lock in root subcommand/flag completion and the plugin edge cases. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b90e6dd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Bug Fixes** * Command-line completion now suggests root-level subcommands and flags when a default subcommand is configured, including for plugin commands. * At plugin roots, completion can suggest matching choices for the default subcommand’s first argument and its flags when enabled. These suggestions are omitted after a positional argument has been consumed, after `--`, or when a required `--` separator has not been provided. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 76478e0 commit 01d7aeb

6 files changed

Lines changed: 345 additions & 4 deletions

File tree

‎argv/src/lib.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1961,6 +1961,10 @@ pub struct Parser<'t, 'a, 'v> {
19611961
/// Once, per parse: a default subcommand that itself declares one would otherwise
19621962
/// descend on every word until the tree ran out.
19631963
default_taken: bool,
1964+
/// Whether this parse reads the words before a cursor. The word under the cursor is still
1965+
/// to be typed, so the line is not an empty invocation and `default_subcommand_on_empty`
1966+
/// must not route away from the root's own subcommands and flags.
1967+
completing: bool,
19641968
/// First default-only flag, when lookahead found no explicit sibling.
19651969
default_flag_at: Option<usize>,
19661970
/// Cursor just after the implicit boundary bundle, whose shorts keep parent ownership.
@@ -1995,7 +1999,9 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
19951999
/// Begin a non-executing parse for completion walking.
19962000
#[cfg(feature = "complete")]
19972001
pub(crate) fn for_completion(root: &'t Command<'t>, argv: &'a [&'v OsStr]) -> Self {
1998-
Self::with_action_errors(root, argv, false)
2002+
let mut parser = Self::with_action_errors(root, argv, false);
2003+
parser.completing = true;
2004+
parser
19992005
}
20002006

20012007
fn with_action_errors(
@@ -2034,6 +2040,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
20342040
separator_seen: false,
20352041
clause_separator_seen: false,
20362042
default_taken: false,
2043+
completing: false,
20372044
default_flag_at: None,
20382045
default_bundle_end: 0,
20392046
done: false,
@@ -2310,6 +2317,7 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
23102317
&& self.depth == 0
23112318
&& self.cmd.default_subcommand_on_empty
23122319
&& !self.default_taken
2320+
&& !self.completing
23132321
&& !self.positional_arg_found
23142322
&& !self.separator_seen
23152323
&& !self.clause_separator_seen

‎cli/src/cli/complete_word.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ impl CompleteWord {
153153
ctx.insert("PREV", &(cword - 1));
154154
}
155155

156-
let parsed = usage::parse::parse_partial(spec, &words)?;
156+
let parsed = usage::parse::parse_partial_for_completion(spec, &words)?;
157157
debug!("parsed cmd: {}", parsed.cmd.full_cmd.join(" "));
158158

159159
// Past an `external_subcommand` catch-all the cursor is inside another program's line.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"section": "default-subcommand-on-empty",
3+
"about": "An empty-invocation default does not hide the root's own candidates from completion: the word under the cursor is still to be typed.",
4+
"vectors": [
5+
{
6+
"id": "empty-default-offers-all-root-subcommands",
7+
"doc": "An empty word at the root still lists every subcommand.",
8+
"spec": "name \"x\"\nbin \"x\"\nflag \"--rootf\"\ndefault_subcommand \"def\"\ndefault_subcommand_on_empty #true\ncmd \"def\" {\n flag \"--x <x>\"\n arg \"[a]\"\n}\ncmd \"other\"\n",
9+
"line": "x ",
10+
"expect": {
11+
"candidates": ["def", "other"]
12+
}
13+
},
14+
{
15+
"id": "empty-default-offers-root-subcommands",
16+
"doc": "At the root, completion offers the subcommands, not only the default command's arguments.",
17+
"spec": "name \"x\"\nbin \"x\"\nflag \"--rootf\"\ndefault_subcommand \"def\"\ndefault_subcommand_on_empty #true\ncmd \"def\" {\n flag \"--x <x>\"\n arg \"[a]\"\n}\ncmd \"other\"\n",
18+
"line": "x o",
19+
"expect": {
20+
"candidates": ["other"]
21+
}
22+
},
23+
{
24+
"id": "empty-default-offers-root-flags",
25+
"doc": "At the root, completion offers the root's flags rather than the default command's.",
26+
"spec": "name \"x\"\nbin \"x\"\nflag \"--rootf\"\ndefault_subcommand \"def\"\ndefault_subcommand_on_empty #true\ncmd \"def\" {\n flag \"--x <x>\"\n arg \"[a]\"\n}\ncmd \"other\"\n",
27+
"line": "x --ro",
28+
"expect": {
29+
"candidates": ["--rootf"]
30+
}
31+
}
32+
]
33+
}

‎lib/src/parse.rs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,25 @@ pub fn parse(spec: &Spec, input: &[String]) -> Result<ParseOutput, miette::Error
12531253
Parser::new(spec).parse(input)
12541254
}
12551255

1256+
/// Parse the words before the cursor for completion.
1257+
///
1258+
/// Like [`parse_partial`], except that `default_subcommand_on_empty` is not applied. The word
1259+
/// under the cursor is still to be typed, so the line is not an empty invocation: the root's
1260+
/// subcommands and flags are all still valid next words, and routing into the default command
1261+
/// would hide them.
1262+
#[must_use = "parsing result should be used"]
1263+
pub fn parse_partial_for_completion(
1264+
spec: &Spec,
1265+
input: &[String],
1266+
) -> Result<ParseOutput, miette::Error> {
1267+
if spec.default_subcommand_on_empty {
1268+
let mut spec = spec.clone();
1269+
spec.default_subcommand_on_empty = false;
1270+
return parse_partial(&spec, input);
1271+
}
1272+
parse_partial(spec, input)
1273+
}
1274+
12561275
/// Parse command-line arguments without applying defaults.
12571276
///
12581277
/// Use this for help text generation or when you need the raw parsed values.

‎usage-dynamic/src/lib.rs‎

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ fn complete_subtree(
429429
input: &[String],
430430
prefix: &str,
431431
) -> Result<Completions<'static>, Error> {
432-
let parsed = usage_parser::parse::parse_partial(spec, input)
432+
let parsed = usage_parser::parse::parse_partial_for_completion(spec, input)
433433
.map_err(|error| Error::Parse(error.to_string()))?;
434434
// A plugin may itself forward to a program of its own. One level down, the same rule: these
435435
// words describe nothing this spec knows about.
@@ -457,7 +457,33 @@ fn complete_subtree(
457457
files = completion_files(spec, &parsed.cmd, arg);
458458
}
459459
} else if flag_like {
460-
for (form, flag) in parsed.completion_flags() {
460+
let mut flags = parsed.completion_flags();
461+
// The completion parse no longer routes an empty root into the default command, so the
462+
// flags `default_subcommand_flags` lends from it are added here, as `complete-word` does.
463+
if spec.default_subcommand_flags
464+
&& parsed.cmds.len() == 1
465+
&& !root_took_positional(&parsed)
466+
&& let Some(default) = spec
467+
.default_subcommand
468+
.as_deref()
469+
.and_then(|name| parsed.cmd.find_subcommand(name))
470+
{
471+
for flag in &default.flags {
472+
let flag = std::sync::Arc::new(flag.clone());
473+
for key in flag
474+
.long
475+
.iter()
476+
.map(|name| format!("--{name}"))
477+
.chain(flag.short.iter().map(|name| format!("-{name}")))
478+
.chain(flag.negate.iter().cloned())
479+
{
480+
flags
481+
.entry(key)
482+
.or_insert_with(|| std::sync::Arc::clone(&flag));
483+
}
484+
}
485+
}
486+
for (form, flag) in flags {
461487
if flag.hide || !form.starts_with(prefix) || hidden_flag_form(&form, &flag) {
462488
continue;
463489
}
@@ -492,6 +518,22 @@ fn complete_subtree(
492518
}
493519
}
494520
}
521+
// At the root with an empty-invocation default, the user may mean to elide the default
522+
// command's name, so its first argument's choices are offered too. The choices stay
523+
// out of `closed`: they belong to a different command and are only a guess.
524+
if spec.default_subcommand_on_empty
525+
&& parsed.cmds.len() == 1
526+
&& !parsed.double_dash_seen
527+
&& !root_took_positional(&parsed)
528+
&& let Some(default) = spec
529+
.default_subcommand
530+
.as_deref()
531+
.and_then(|name| parsed.cmd.find_subcommand(name))
532+
&& let Some(arg) = default.args.iter().find(|arg| arg.sigil.is_none())
533+
&& arg.double_dash != usage_parser::SpecDoubleDashChoices::Required
534+
{
535+
candidates.extend(choice_candidates(arg, prefix));
536+
}
495537
}
496538
sort_and_dedup(&mut candidates);
497539

@@ -510,6 +552,24 @@ fn complete_subtree(
510552
Ok(Completions { candidates, files })
511553
}
512554

555+
/// Whether the root already took a positional, so the default command can no longer be
556+
/// selected by the next word. An unknown flag a positional absorbed counts: it filled one.
557+
fn root_took_positional(parsed: &usage_parser::parse::ParseOutput) -> bool {
558+
use usage_parser::parse::TokenRole;
559+
!parsed.args.is_empty()
560+
|| parsed.tokens.iter().any(|token| {
561+
token.roles.iter().any(|role| {
562+
matches!(
563+
role,
564+
TokenRole::Arg { .. }
565+
| TokenRole::Sigil { .. }
566+
| TokenRole::ClauseSeparator { .. }
567+
| TokenRole::UnknownFlag { bound_as: Some(_) }
568+
)
569+
})
570+
})
571+
}
572+
513573
/// Whether this position states what it accepts, so an unmatched prefix means "no matches".
514574
///
515575
/// Choices are the obvious case. A declared `complete` block is one too — unless it says its

0 commit comments

Comments
 (0)