diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index 168e96ff..41454a7f 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -98,7 +98,19 @@ impl CompleteWord { ctx.insert("PREV", &(cword - 1)); } - let parsed = usage::parse::parse_partial(spec, &words)?; + // The words before the cursor may not parse (e.g. an unexpected word + // earlier on the line). A completion helper must stay silent rather than + // surface a hard parse error to stderr, which garbles the shell prompt + // mid-completion — emit no candidates instead. (#596) + let parsed = match usage::parse::parse_partial(spec, &words) { + Ok(parsed) => parsed, + Err(e) => { + debug!( + "parse_partial failed during completion (words: {words:?}), no candidates: {e}" + ); + return Ok(vec![]); + } + }; debug!("parsed cmd: {}", parsed.cmd.full_cmd.join(" ")); // Check if previous token was a restart_token - if so, complete from first arg diff --git a/cli/tests/complete_word.rs b/cli/tests/complete_word.rs index 370f7260..5d26c468 100644 --- a/cli/tests/complete_word.rs +++ b/cli/tests/complete_word.rs @@ -15,6 +15,21 @@ fn complete_word_completer() { .stdout("plugin-1\tdesc\nplugin-2\tdesc\nplugin-3\tdesc\n"); } +#[test] +fn complete_word_after_unexpected_word_is_silent() { + // #596: completion after an unexpected word must NOT print a hard parse error + // to stderr (which garbles the shell prompt) — the helper should exit 0 with + // no candidates instead. + let spec = "name \"mycli\"\ncmd \"build\"\ncmd \"test\"\n"; + Command::new(cargo::cargo_bin!("usage")) + .args([ + "cw", "--shell", "bash", "--spec", spec, "--", "mycli", "help", "", + ]) + .assert() + .success() + .stdout(""); +} + #[test] fn complete_word_subcommands() { assert_cmd("basic.usage.kdl", &["plugins", "install"]).stdout(contains("install")); @@ -258,11 +273,14 @@ fn complete_word_non_global_flags_stop_search() { path.insert(0, env::current_dir().unwrap().join("..").join("examples")); env::set_var("PATH", env::join_paths(path).unwrap()); - // Non-global flag --local should stop subcommand search - // The parser will fail to recognize 'run' as a subcommand and error + // Non-global flag --local stops the subcommand search, so 'run' is not + // recognized as a subcommand. Completion must still exit cleanly with no + // candidates — NOT surface a hard parse error to stderr, which would garble + // the shell prompt (#596). (Previously this asserted .failure() with + // "unexpected word", which was the bug.) let mut cmd = cmd("test-boolean-flags.sh", Some("fish")); cmd.args(["--", "--local", "run", ""]); - cmd.assert().failure().stderr(contains("unexpected word")); + cmd.assert().success().stdout(""); } #[test]