diff --git a/cli/assets/completions/_usage b/cli/assets/completions/_usage index 4c97636a..4a26c589 100644 --- a/cli/assets/completions/_usage +++ b/cli/assets/completions/_usage @@ -25,11 +25,15 @@ _usage() { local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_usage.spec" usage --usage-spec >| "$spec_file" - local -a completions=() - while IFS= read -r line; do - completions+=("$line") - done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -S '' + local -a completions=() inserts=() + local needs_menu=0 display insert + while IFS=$'\t' read -r display insert; do + completions+=("$display") + inserts+=("$insert") + [[ "$insert" == "'"* ]] && needs_menu=1 + done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${(Q)words[@]}") + (( needs_menu )) && compstate[insert]=menu + _describe 'completions' completions inserts -U -Q -S '' return 0 } diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index 19578b70..168e96ff 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -59,13 +59,20 @@ impl CompleteWord { } } "zsh" => { - let c = zsh_escape(&c); - if any_descriptions { - let description = zsh_escape(&description); - println!("{c}:{description}") + // Two tab-separated columns per line: + // 1. `value:description` (or just `value`) for _describe's + // menu rendering and prefix matching, with `:` `(` `)` + // `[` `]` escaped as `_describe` requires. + // 2. The shell-quoted form that `compadd -Q` should insert + // verbatim — wrapped in single quotes when the value + // contains shell metacharacters, raw otherwise. + let display = if any_descriptions { + format!("{}:{}", zsh_escape(&c), zsh_escape(&description)) } else { - println!("{c}") - } + zsh_escape(&c) + }; + let insert = zsh_shell_quote(&c); + println!("{display}\t{insert}") } _ => miette::bail!("unsupported shell: {}", shell), } @@ -372,6 +379,25 @@ fn zsh_escape(s: &str) -> String { .replace(']', "\\]") } +/// Wrap a completion value in single quotes if any character would otherwise +/// be interpreted by the shell. The result is meant to be inserted by +/// `compadd -Q` verbatim, so the user sees consistent single-quote quoting +/// instead of zsh's default mix of backslash and single-quote styles. +fn zsh_shell_quote(s: &str) -> String { + fn safe(c: char) -> bool { + matches!(c, + 'a'..='z' | 'A'..='Z' | '0'..='9' + | '_' | '-' | '.' | '/' | ':' | '@' | '+' | '=' | '%' | ',' + ) + } + if !s.is_empty() && s.chars().all(safe) { + return s.to_string(); + } + // Wrap in single quotes; close-open dance escapes any internal apostrophes. + let escaped = s.replace('\'', "'\\''"); + format!("'{escaped}'") +} + fn sh(script: &str) -> XXResult { let output = Command::new("sh") .arg("-c") diff --git a/cli/tests/complete_word.rs b/cli/tests/complete_word.rs index 293cf3f2..90ad7a63 100644 --- a/cli/tests/complete_word.rs +++ b/cli/tests/complete_word.rs @@ -293,20 +293,27 @@ fn complete_word_escaped_colons_in_completions() { #[test] fn complete_word_zsh_escapes_parens_and_brackets() { // zsh's _describe interprets parentheses as glob qualifiers and brackets - // as character classes, so they must be escaped in completion output. + // as character classes, so they must be escaped in the display column. + // The second (insert) column is shell-quoted for `compadd -Q`. // See: https://github.com/jdx/usage/issues/558 let mut c = cmd("parens-in-descriptions.usage.kdl", Some("zsh")); c.args(["--", "run", ""]); c.assert().success().stdout( - "connect\\:server:Connect server \\(Hot Reload\\)\ntest\\:unit:Run tests \\[fast\\]\nbuild:Build project\n", + "connect\\:server:Connect server \\(Hot Reload\\)\tconnect:server\n\ + test\\:unit:Run tests \\[fast\\]\ttest:unit\n\ + build:Build project\tbuild\n", ); } #[test] fn complete_word_zsh_escapes_colons_without_descriptions() { + // Display column has `\:` escapes for `_describe`; insert column has the + // raw value (no shell-quoting needed — colons aren't shell-special). let mut c = cmd("zsh-colons-without-descriptions.usage.kdl", Some("zsh")); c.args(["--", "run", ""]); - c.assert().success().stdout("test\\:git\ntest\\:nvim\n"); + c.assert() + .success() + .stdout("test\\:git\ttest:git\ntest\\:nvim\ttest:nvim\n"); } fn cmd(example: &str, shell: Option<&str>) -> Command { diff --git a/cli/tests/shell_completions_integration.rs b/cli/tests/shell_completions_integration.rs index 7f92851f..49d60ae0 100644 --- a/cli/tests/shell_completions_integration.rs +++ b/cli/tests/shell_completions_integration.rs @@ -565,6 +565,96 @@ echo "COMPLETION_TEST_DONE" let _ = fs::remove_dir_all(&temp_dir); } +/// Regression test for https://github.com/jdx/usage/issues/634 +/// +/// `usage complete-word --shell zsh` emits two tab-separated columns per +/// match — `\t` — and the generated completion script feeds +/// those to `_describe ... -U -Q -S ''` so values with spaces are inserted +/// with consistent single-quote quoting (e.g. `'Alice Alice'`) instead of +/// zsh's default mix of backslash and single-quote styles. +#[test] +fn test_zsh_completion_quotes_choices_with_spaces() { + if skip_if_shell_missing("zsh") { + return; + } + + let usage_bin = build_usage_binary(); + + let temp_dir = env::temp_dir().join(format!( + "usage_zsh_choices_quote_test_{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).unwrap(); + + let spec = r#" +arg "" required { + choices "A B & C" +} +arg "" required { + choices "Alice Alice" "Bob Bob" "Carol Carol" +} +"#; + let spec_kdl_file = temp_dir.join("testcli.kdl"); + fs::write(&spec_kdl_file, spec).unwrap(); + + // 1. The raw `complete-word --shell zsh` output for the recipient arg + // should be tab-separated, with the insert column pre-quoted. + let raw = Command::new(&usage_bin) + .args(["complete-word", "--shell", "zsh", "-f"]) + .arg(spec_kdl_file.to_str().unwrap()) + .args(["--", "testcli", "A B & C", ""]) + .output() + .expect("Failed to run complete-word"); + let raw_stdout = String::from_utf8_lossy(&raw.stdout); + let expected_lines = [ + "Alice Alice\t'Alice Alice'", + "Bob Bob\t'Bob Bob'", + "Carol Carol\t'Carol Carol'", + ]; + for line in expected_lines { + assert!( + raw_stdout.lines().any(|l| l == line), + "Expected `{line}` in complete-word output, got:\n{raw_stdout}" + ); + } + + // 2. The simple unquoted case still emits a tab + the raw value as the + // insert column (no surrounding quotes). + let raw_simple = Command::new(&usage_bin) + .args(["complete-word", "--shell", "zsh", "-s"]) + .arg(r#"arg "" { choices "dev" "prod" }"#) + .args(["--", "testcli", ""]) + .output() + .expect("Failed to run complete-word"); + let simple_stdout = String::from_utf8_lossy(&raw_simple.stdout); + assert!( + simple_stdout.lines().any(|l| l == "dev\tdev"), + "Expected `dev\\tdev` in complete-word output, got:\n{simple_stdout}" + ); + + // 3. The generated zsh completion script wires these two columns into + // `_describe` with `-U -Q` so zsh inserts the pre-quoted value verbatim. + let gen = Command::new(&usage_bin) + .args(["generate", "completion", "zsh", "testcli", "-f"]) + .arg(spec_kdl_file.to_str().unwrap()) + .output() + .expect("Failed to generate zsh completion"); + let script = String::from_utf8_lossy(&gen.stdout); + let expected_fragments = [ + "(Q)words", // unquote user input + r#"IFS=$'\t' read -r display insert"#, // tab-separated + "_describe 'completions' completions inserts -U -Q", // -U -Q on _describe + ]; + for fragment in expected_fragments { + assert!( + script.contains(fragment), + "Expected `{fragment}` in generated script:\n{script}" + ); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + #[test] fn test_powershell_completion_integration() { if skip_if_shell_missing("pwsh") { @@ -726,19 +816,23 @@ cmd other help="Another subcommand" let spec_file = temp_dir.join("test.spec"); fs::write(&spec_file, usage_spec).unwrap(); - // Test zsh output format: should be `name:description` for _describe + // Test zsh output format: each line is `\t` where + // is `name:description` (or `name`) for `_describe`'s menu + // rendering, and is the shell-quoted form to insert verbatim + // via `compadd -Q`. let output = run_complete_word(&usage_bin, "zsh", &spec_file, &["testcli", ""]); let lines: Vec<&str> = output.lines().collect(); - // Should have completions with description format "name:description" assert!( - lines.iter().any(|l| l.contains("sub:A subcommand")), - "Expected 'sub:A subcommand' in zsh output, got: {:?}", + lines.iter().any(|l| *l == "sub:A subcommand\tsub"), + "Expected 'sub:A subcommand\\tsub' in zsh output, got: {:?}", lines ); assert!( - lines.iter().any(|l| l.contains("other:Another subcommand")), - "Expected 'other:Another subcommand' in zsh output, got: {:?}", + lines + .iter() + .any(|l| *l == "other:Another subcommand\tother"), + "Expected 'other:Another subcommand\\tother' in zsh output, got: {:?}", lines ); diff --git a/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh-2.snap b/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh-2.snap index 38eabe3e..9e52f375 100644 --- a/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh-2.snap +++ b/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh-2.snap @@ -31,11 +31,15 @@ _mycli() { if [[ ! -f "$spec_file" ]]; then mycli complete --usage >| "$spec_file" fi - local -a completions=() - while IFS= read -r line; do - completions+=("$line") - done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -S '' + local -a completions=() inserts=() + local needs_menu=0 display insert + while IFS=$'\t' read -r display insert; do + completions+=("$display") + inserts+=("$insert") + [[ "$insert" == "'"* ]] && needs_menu=1 + done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${(Q)words[@]}") + (( needs_menu )) && compstate[insert]=menu + _describe 'completions' completions inserts -U -Q -S '' return 0 } diff --git a/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh-3.snap b/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh-3.snap index f5abd836..afbe39e5 100644 --- a/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh-3.snap +++ b/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh-3.snap @@ -54,11 +54,15 @@ cmd plugin { } } __USAGE_EOF__ - local -a completions=() - while IFS= read -r line; do - completions+=("$line") - done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -S '' + local -a completions=() inserts=() + local needs_menu=0 display insert + while IFS=$'\t' read -r display insert; do + completions+=("$display") + inserts+=("$insert") + [[ "$insert" == "'"* ]] && needs_menu=1 + done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${(Q)words[@]}") + (( needs_menu )) && compstate[insert]=menu + _describe 'completions' completions inserts -U -Q -S '' return 0 } diff --git a/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh.snap b/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh.snap index 7ba3e9f6..830a92b3 100644 --- a/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh.snap +++ b/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh.snap @@ -29,11 +29,15 @@ _mycli() { local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mycli.spec" mycli complete --usage >| "$spec_file" - local -a completions=() - while IFS= read -r line; do - completions+=("$line") - done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -S '' + local -a completions=() inserts=() + local needs_menu=0 display insert + while IFS=$'\t' read -r display insert; do + completions+=("$display") + inserts+=("$insert") + [[ "$insert" == "'"* ]] && needs_menu=1 + done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${(Q)words[@]}") + (( needs_menu )) && compstate[insert]=menu + _describe 'completions' completions inserts -U -Q -S '' return 0 } diff --git a/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh_init.snap b/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh_init.snap index 65fd804f..091a58b0 100644 --- a/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh_init.snap +++ b/lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh_init.snap @@ -19,12 +19,15 @@ _usage_default_complete() { local first if IFS= read -r first < "$cmdpath" 2>/dev/null && [[ "$first" == "#!"*"usage"* ]]; then if (( ${+commands[usage]} )); then - local -a completions=() - local line - while IFS= read -r line; do - completions+=("$line") - done < <(command usage complete-word --shell zsh -f "$cmdpath" --cword=$((CURRENT - 1)) -- "${words[@]}") - _describe 'completions' completions -S '' + local -a completions=() inserts=() + local needs_menu=0 display insert + while IFS=$'\t' read -r display insert; do + completions+=("$display") + inserts+=("$insert") + [[ "$insert" == "'"* ]] && needs_menu=1 + done < <(command usage complete-word --shell zsh -f "$cmdpath" --cword=$((CURRENT - 1)) -- "${(Q)words[@]}") + (( needs_menu )) && compstate[insert]=menu + _describe 'completions' completions inserts -U -Q -S '' return $? fi fi diff --git a/lib/src/complete/zsh.rs b/lib/src/complete/zsh.rs index 43b279ff..5f874ed1 100644 --- a/lib/src/complete/zsh.rs +++ b/lib/src/complete/zsh.rs @@ -1,6 +1,39 @@ use crate::complete::CompleteOptions; use heck::ToSnakeCase; +/// The completion loop that both `complete_zsh` (per-bin script) and +/// `complete_zsh_init` (shebang-fallback handler) need to emit. +/// +/// `complete-word --shell zsh` emits two tab-separated columns per match: +/// the display string (`value:description` for `_describe`) and the +/// shell-quoted insert string. `usage complete-word` already filters by the +/// typed prefix, so `-U` tells `compadd` not to re-filter (which would +/// discard our pre-quoted matches whose literal text starts with `'`). +/// `compstate[insert]=menu` skips longest-common-prefix insertion when +/// values share a leading quote. +/// +/// `cw_extra_args` is the extra `complete-word` arguments specific to each +/// caller (e.g. `-f "$spec_file"` vs `-f "$cmdpath" --cword=$((CURRENT - 1))`). +/// `indent` is prepended to every emitted line. +fn render_completion_loop(usage_bin: &str, indent: &str, cw_extra_args: &str) -> String { + let template = r#"local -a completions=() inserts=() +local needs_menu=0 display insert +while IFS=$'\t' read -r display insert; do + completions+=("$display") + inserts+=("$insert") + [[ "$insert" == "'"* ]] && needs_menu=1 +done < <(command __USAGE_BIN__ complete-word --shell zsh __CW_EXTRA__ -- "${(Q)words[@]}") +(( needs_menu )) && compstate[insert]=menu +_describe 'completions' completions inserts -U -Q -S ''"#; + template + .replace("__USAGE_BIN__", usage_bin) + .replace("__CW_EXTRA__", cw_extra_args) + .lines() + .map(|l| format!("{indent}{l}")) + .collect::>() + .join("\n") +} + pub fn complete_zsh(opts: &CompleteOptions) -> String { let usage_bin = &opts.usage_bin; let bin = &opts.bin; @@ -82,15 +115,13 @@ fi"# String::new() }; + let completion_loop = render_completion_loop(usage_bin, " ", r#"-f "$spec_file""#); + out.push(format!( r#" local spec_file="${{TMPDIR:-/tmp}}/usage_{spec_variable}.spec" {file_write_logic} - local -a completions=() - while IFS= read -r line; do - completions+=("$line") - done < <(command {usage_bin} complete-word --shell zsh -f "$spec_file" -- "${{words[@]}}") - _describe 'completions' completions -S '' +{completion_loop} return 0 }} @@ -120,6 +151,11 @@ fi /// command path, peeks the first line, and dispatches to `usage complete-word` /// when it's a usage shebang. Otherwise it falls back to `_files`. pub fn complete_zsh_init(usage_bin: &str) -> String { + let completion_loop = render_completion_loop( + usage_bin, + " ", + r#"-f "$cmdpath" --cword=$((CURRENT - 1))"#, + ); format!( r##"# @generated by usage-cli — auto-completion for usage shebang scripts # Source this file from your zshrc to enable completion for any command @@ -138,12 +174,7 @@ _usage_default_complete() {{ local first if IFS= read -r first < "$cmdpath" 2>/dev/null && [[ "$first" == "#!"*"usage"* ]]; then if (( ${{+commands[{usage_bin}]}} )); then - local -a completions=() - local line - while IFS= read -r line; do - completions+=("$line") - done < <(command {usage_bin} complete-word --shell zsh -f "$cmdpath" --cword=$((CURRENT - 1)) -- "${{words[@]}}") - _describe 'completions' completions -S '' +{completion_loop} return $? fi fi