From b07823f089548f162e104462770149e14ce8ae9e Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Wed, 13 May 2026 09:48:05 -0500 Subject: [PATCH 1/4] fix(zsh): consistently single-quote choice values containing spaces Previously, zsh's auto-quoting heuristic produced inconsistent quoting styles: a choice value containing shell metacharacters like `A B & C` was inserted as `'A B & C'`, but a value containing only spaces like `Alice Alice` was inserted as `Alice\ Alice`. Both are valid, but the inconsistency is jarring. Now the generated zsh completion script: - Strips user-supplied quoting from `words` via `${(Q)...}` before invoking `usage complete-word`, so previously-typed args like `'A B & C'` match their choice definitions. - Builds a parallel `inserts` array where each value is pre-quoted with `${(q-)val}` (single quotes for values needing quoting, raw otherwise), then passes it to `_describe` with `-Q` to bypass zsh's built-in auto-quoting. - Forces `compstate[insert]=menu` when any value is pre-quoted, so the longest-common-prefix heuristic doesn't insert just a stray opening quote. Fixes #634 --- cli/tests/shell_completions_integration.rs | 134 ++++++++++++++++++ ..._complete__zsh__tests__complete_zsh-2.snap | 19 ++- ..._complete__zsh__tests__complete_zsh-3.snap | 19 ++- ...e__complete__zsh__tests__complete_zsh.snap | 19 ++- ...mplete__zsh__tests__complete_zsh_init.snap | 19 ++- lib/src/complete/zsh.rs | 38 ++++- 6 files changed, 230 insertions(+), 18 deletions(-) diff --git a/cli/tests/shell_completions_integration.rs b/cli/tests/shell_completions_integration.rs index 7f92851f..602b6ed4 100644 --- a/cli/tests/shell_completions_integration.rs +++ b/cli/tests/shell_completions_integration.rs @@ -565,6 +565,140 @@ echo "COMPLETION_TEST_DONE" let _ = fs::remove_dir_all(&temp_dir); } +/// Regression test for https://github.com/jdx/usage/issues/634 +/// Verifies that the zsh completion script builds an `inserts` array where +/// choice values with spaces are wrapped in single quotes (so menu-selecting +/// one inserts `'Alice Alice'`, not `Alice\ Alice`), and that the script +/// passes its words through `${(Q)...}` to strip user-supplied quoting before +/// invoking `usage complete-word`. +#[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(); + + let output = Command::new(&usage_bin) + .args(["generate", "completion", "zsh", "testcli"]) + .arg("-f") + .arg(spec_kdl_file.to_str().unwrap()) + .output() + .expect("Failed to generate zsh completion"); + let completion_script = String::from_utf8_lossy(&output.stdout); + + // Sanity checks on the generated script + assert!( + completion_script.contains("(Q)words"), + "Completion script should unquote `words` via (Q) flag, script:\n{completion_script}" + ); + assert!( + completion_script.contains("(q-)val"), + "Completion script should pre-quote each choice via (q-) flag, script:\n{completion_script}" + ); + assert!( + completion_script.contains("compstate[insert]=menu"), + "Completion script should force menu mode when values need quoting, script:\n{completion_script}" + ); + assert!( + completion_script.contains("_describe 'completions' completions inserts -Q -S ''"), + "Completion script should call _describe with both display and inserts arrays and -Q, script:\n{completion_script}" + ); + + // Drive the parsing/quoting logic with a stub `usage` that mimics what + // `usage complete-word --shell zsh` would print for the recipient arg, + // then inspect the resulting `inserts` array. + let test_script = format!( + r#"#!/bin/zsh +set -u + +# Stub `usage` so we can exercise the completion-script's parsing logic +# without depending on a real completion-context (zpty etc). +PATH="{tmp}/stubbin:$PATH" +mkdir -p "{tmp}/stubbin" +cat > "{tmp}/stubbin/usage" <<'STUB' +#!/bin/sh +# Mimic `usage complete-word --shell zsh` output for the recipient arg +printf '%s\n' 'Alice Alice' 'Bob Bob' 'Carol Carol' +STUB +chmod +x "{tmp}/stubbin/usage" + +# Replicate the inner loop the completion script runs. +local -a completions=() inserts=() +local needs_menu=0 +while IFS= read -r line; do + completions+=("$line") + local marker=$'\x01' + local subst="${{line//\\:/$marker}}" + local val="${{subst%%:*}}" + val="${{val//$marker/:}}" + val="${{val//\\\(/(}}" + val="${{val//\\\)/)}}" + val="${{val//\\\[/[}}" + val="${{val//\\\]/]}}" + local quoted="${{(q-)val}}" + inserts+=("$quoted") + [[ "$quoted" != "$val" ]] && needs_menu=1 +done < <(usage complete-word --shell zsh) + +print -r -- "needs_menu=$needs_menu" +for i in {{1..3}}; do + print -r -- "inserts[$i]=${{inserts[$i]}}" +done +"#, + tmp = temp_dir.to_str().unwrap(), + ); + + let script_file = temp_dir.join("test.zsh"); + fs::write(&script_file, &test_script).unwrap(); + + let result = Command::new("zsh") + .arg(script_file.to_str().unwrap()) + .output() + .expect("Failed to run zsh test"); + + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + println!("stdout:\n{stdout}"); + println!("stderr:\n{stderr}"); + + assert!( + stdout.contains("needs_menu=1"), + "Expected needs_menu=1 for choices with spaces, got: {stdout}" + ); + assert!( + stdout.contains("inserts[1]='Alice Alice'"), + "Expected inserts[1]='Alice Alice', got: {stdout}" + ); + assert!( + stdout.contains("inserts[2]='Bob Bob'"), + "Expected inserts[2]='Bob Bob', got: {stdout}" + ); + assert!( + stdout.contains("inserts[3]='Carol Carol'"), + "Expected inserts[3]='Carol Carol', got: {stdout}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + #[test] fn test_powershell_completion_integration() { if skip_if_shell_missing("pwsh") { 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..45c21d46 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,24 @@ _mycli() { if [[ ! -f "$spec_file" ]]; then mycli complete --usage >| "$spec_file" fi - local -a completions=() + local -a completions=() inserts=() + local needs_menu=0 while IFS= read -r line; do completions+=("$line") - done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -S '' + local marker=$'\x01' + local subst="${line//\\:/$marker}" + local val="${subst%%:*}" + val="${val//$marker/:}" + val="${val//\\\(/(}" + val="${val//\\\)/)}" + val="${val//\\\[/[}" + val="${val//\\\]/]}" + local quoted="${(q-)val}" + inserts+=("$quoted") + [[ "$quoted" != "$val" ]] && needs_menu=1 + done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${(Q)words[@]}") + (( needs_menu )) && compstate[insert]=menu + _describe 'completions' completions inserts -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..af9e0fab 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,24 @@ cmd plugin { } } __USAGE_EOF__ - local -a completions=() + local -a completions=() inserts=() + local needs_menu=0 while IFS= read -r line; do completions+=("$line") - done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -S '' + local marker=$'\x01' + local subst="${line//\\:/$marker}" + local val="${subst%%:*}" + val="${val//$marker/:}" + val="${val//\\\(/(}" + val="${val//\\\)/)}" + val="${val//\\\[/[}" + val="${val//\\\]/]}" + local quoted="${(q-)val}" + inserts+=("$quoted") + [[ "$quoted" != "$val" ]] && needs_menu=1 + done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${(Q)words[@]}") + (( needs_menu )) && compstate[insert]=menu + _describe 'completions' completions inserts -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..b31d2e62 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,24 @@ _mycli() { local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mycli.spec" mycli complete --usage >| "$spec_file" - local -a completions=() + local -a completions=() inserts=() + local needs_menu=0 while IFS= read -r line; do completions+=("$line") - done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -S '' + local marker=$'\x01' + local subst="${line//\\:/$marker}" + local val="${subst%%:*}" + val="${val//$marker/:}" + val="${val//\\\(/(}" + val="${val//\\\)/)}" + val="${val//\\\[/[}" + val="${val//\\\]/]}" + local quoted="${(q-)val}" + inserts+=("$quoted") + [[ "$quoted" != "$val" ]] && needs_menu=1 + done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${(Q)words[@]}") + (( needs_menu )) && compstate[insert]=menu + _describe 'completions' completions inserts -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..9dd2f358 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,25 @@ _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 -a completions=() inserts=() + local needs_menu=0 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 marker=$'\x01' + local subst="${line//\\:/$marker}" + local val="${subst%%:*}" + val="${val//$marker/:}" + val="${val//\\\(/(}" + val="${val//\\\)/)}" + val="${val//\\\[/[}" + val="${val//\\\]/]}" + local quoted="${(q-)val}" + inserts+=("$quoted") + [[ "$quoted" != "$val" ]] && 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 -Q -S '' return $? fi fi diff --git a/lib/src/complete/zsh.rs b/lib/src/complete/zsh.rs index 43b279ff..2e692851 100644 --- a/lib/src/complete/zsh.rs +++ b/lib/src/complete/zsh.rs @@ -86,11 +86,24 @@ fi"# r#" local spec_file="${{TMPDIR:-/tmp}}/usage_{spec_variable}.spec" {file_write_logic} - local -a completions=() + local -a completions=() inserts=() + local needs_menu=0 while IFS= read -r line; do completions+=("$line") - done < <(command {usage_bin} complete-word --shell zsh -f "$spec_file" -- "${{words[@]}}") - _describe 'completions' completions -S '' + local marker=$'\x01' + local subst="${{line//\\:/$marker}}" + local val="${{subst%%:*}}" + val="${{val//$marker/:}}" + val="${{val//\\\(/(}}" + val="${{val//\\\)/)}}" + val="${{val//\\\[/[}}" + val="${{val//\\\]/]}}" + local quoted="${{(q-)val}}" + inserts+=("$quoted") + [[ "$quoted" != "$val" ]] && needs_menu=1 + done < <(command {usage_bin} complete-word --shell zsh -f "$spec_file" -- "${{(Q)words[@]}}") + (( needs_menu )) && compstate[insert]=menu + _describe 'completions' completions inserts -Q -S '' return 0 }} @@ -138,12 +151,25 @@ _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 -a completions=() inserts=() + local needs_menu=0 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 '' + local marker=$'\x01' + local subst="${{line//\\:/$marker}}" + local val="${{subst%%:*}}" + val="${{val//$marker/:}}" + val="${{val//\\\(/(}}" + val="${{val//\\\)/)}}" + val="${{val//\\\[/[}}" + val="${{val//\\\]/]}}" + local quoted="${{(q-)val}}" + inserts+=("$quoted") + [[ "$quoted" != "$val" ]] && needs_menu=1 + done < <(command {usage_bin} complete-word --shell zsh -f "$cmdpath" --cword=$((CURRENT - 1)) -- "${{(Q)words[@]}}") + (( needs_menu )) && compstate[insert]=menu + _describe 'completions' completions inserts -Q -S '' return $? fi fi From 8b5a2e4697fd080793152b208aa4b4c93c4b1dc6 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Wed, 13 May 2026 10:05:59 -0500 Subject: [PATCH 2/4] refactor(zsh): move completion quoting into Rust, simplify shell template Replaces the inline shell parser that hand-rolled escape-aware splitting of `value:description` strings with a tab-separated `\t` format emitted directly by `usage complete-word --shell zsh`. Now the Rust side decides how to shell-quote each value (single quotes when needed, raw otherwise) and the shell side is reduced to a four-line loop that pushes the two columns into parallel arrays. `-U` is added to `_describe` so it doesn't re-filter the already-prefix-filtered matches returned by `complete-word`, which would otherwise discard them because their literal text starts with `'`. Behavior is unchanged from the previous commit's fix for #634; this is purely a structural cleanup. --- cli/src/cli/complete_word.rs | 38 +++- cli/tests/complete_word.rs | 13 +- cli/tests/shell_completions_integration.rs | 166 +++++++----------- ..._complete__zsh__tests__complete_zsh-2.snap | 28 ++- ..._complete__zsh__tests__complete_zsh-3.snap | 28 ++- ...e__complete__zsh__tests__complete_zsh.snap | 28 ++- ...mplete__zsh__tests__complete_zsh_init.snap | 22 +-- lib/src/complete/zsh.rs | 50 ++---- 8 files changed, 169 insertions(+), 204 deletions(-) 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 602b6ed4..49d60ae0 100644 --- a/cli/tests/shell_completions_integration.rs +++ b/cli/tests/shell_completions_integration.rs @@ -566,11 +566,12 @@ echo "COMPLETION_TEST_DONE" } /// Regression test for https://github.com/jdx/usage/issues/634 -/// Verifies that the zsh completion script builds an `inserts` array where -/// choice values with spaces are wrapped in single quotes (so menu-selecting -/// one inserts `'Alice Alice'`, not `Alice\ Alice`), and that the script -/// passes its words through `${(Q)...}` to strip user-supplied quoting before -/// invoking `usage complete-word`. +/// +/// `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") { @@ -596,105 +597,60 @@ arg "" required { let spec_kdl_file = temp_dir.join("testcli.kdl"); fs::write(&spec_kdl_file, spec).unwrap(); - let output = Command::new(&usage_bin) - .args(["generate", "completion", "zsh", "testcli"]) - .arg("-f") + // 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 generate zsh completion"); - let completion_script = String::from_utf8_lossy(&output.stdout); + .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}" + ); + } - // Sanity checks on the generated script - assert!( - completion_script.contains("(Q)words"), - "Completion script should unquote `words` via (Q) flag, script:\n{completion_script}" - ); - assert!( - completion_script.contains("(q-)val"), - "Completion script should pre-quote each choice via (q-) flag, script:\n{completion_script}" - ); - assert!( - completion_script.contains("compstate[insert]=menu"), - "Completion script should force menu mode when values need quoting, script:\n{completion_script}" - ); + // 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!( - completion_script.contains("_describe 'completions' completions inserts -Q -S ''"), - "Completion script should call _describe with both display and inserts arrays and -Q, script:\n{completion_script}" + simple_stdout.lines().any(|l| l == "dev\tdev"), + "Expected `dev\\tdev` in complete-word output, got:\n{simple_stdout}" ); - // Drive the parsing/quoting logic with a stub `usage` that mimics what - // `usage complete-word --shell zsh` would print for the recipient arg, - // then inspect the resulting `inserts` array. - let test_script = format!( - r#"#!/bin/zsh -set -u - -# Stub `usage` so we can exercise the completion-script's parsing logic -# without depending on a real completion-context (zpty etc). -PATH="{tmp}/stubbin:$PATH" -mkdir -p "{tmp}/stubbin" -cat > "{tmp}/stubbin/usage" <<'STUB' -#!/bin/sh -# Mimic `usage complete-word --shell zsh` output for the recipient arg -printf '%s\n' 'Alice Alice' 'Bob Bob' 'Carol Carol' -STUB -chmod +x "{tmp}/stubbin/usage" - -# Replicate the inner loop the completion script runs. -local -a completions=() inserts=() -local needs_menu=0 -while IFS= read -r line; do - completions+=("$line") - local marker=$'\x01' - local subst="${{line//\\:/$marker}}" - local val="${{subst%%:*}}" - val="${{val//$marker/:}}" - val="${{val//\\\(/(}}" - val="${{val//\\\)/)}}" - val="${{val//\\\[/[}}" - val="${{val//\\\]/]}}" - local quoted="${{(q-)val}}" - inserts+=("$quoted") - [[ "$quoted" != "$val" ]] && needs_menu=1 -done < <(usage complete-word --shell zsh) - -print -r -- "needs_menu=$needs_menu" -for i in {{1..3}}; do - print -r -- "inserts[$i]=${{inserts[$i]}}" -done -"#, - tmp = temp_dir.to_str().unwrap(), - ); - - let script_file = temp_dir.join("test.zsh"); - fs::write(&script_file, &test_script).unwrap(); - - let result = Command::new("zsh") - .arg(script_file.to_str().unwrap()) + // 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 run zsh test"); - - let stdout = String::from_utf8_lossy(&result.stdout); - let stderr = String::from_utf8_lossy(&result.stderr); - println!("stdout:\n{stdout}"); - println!("stderr:\n{stderr}"); - - assert!( - stdout.contains("needs_menu=1"), - "Expected needs_menu=1 for choices with spaces, got: {stdout}" - ); - assert!( - stdout.contains("inserts[1]='Alice Alice'"), - "Expected inserts[1]='Alice Alice', got: {stdout}" - ); - assert!( - stdout.contains("inserts[2]='Bob Bob'"), - "Expected inserts[2]='Bob Bob', got: {stdout}" - ); - assert!( - stdout.contains("inserts[3]='Carol Carol'"), - "Expected inserts[3]='Carol Carol', got: {stdout}" - ); + .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); } @@ -860,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 45c21d46..a7e7bfa4 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,24 +31,22 @@ _mycli() { if [[ ! -f "$spec_file" ]]; then mycli complete --usage >| "$spec_file" fi + # Each line from `complete-word --shell zsh` is two tab-separated columns: + # 1. value:description (or just value) for _describe's menu rendering + # 2. the shell-quoted form for compadd -Q to insert verbatim + # `usage complete-word` 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. local -a completions=() inserts=() - local needs_menu=0 - while IFS= read -r line; do - completions+=("$line") - local marker=$'\x01' - local subst="${line//\\:/$marker}" - local val="${subst%%:*}" - val="${val//$marker/:}" - val="${val//\\\(/(}" - val="${val//\\\)/)}" - val="${val//\\\[/[}" - val="${val//\\\]/]}" - local quoted="${(q-)val}" - inserts+=("$quoted") - [[ "$quoted" != "$val" ]] && needs_menu=1 + 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 -Q -S '' + _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 af9e0fab..8275855c 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,24 +54,22 @@ cmd plugin { } } __USAGE_EOF__ + # Each line from `complete-word --shell zsh` is two tab-separated columns: + # 1. value:description (or just value) for _describe's menu rendering + # 2. the shell-quoted form for compadd -Q to insert verbatim + # `usage complete-word` 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. local -a completions=() inserts=() - local needs_menu=0 - while IFS= read -r line; do - completions+=("$line") - local marker=$'\x01' - local subst="${line//\\:/$marker}" - local val="${subst%%:*}" - val="${val//$marker/:}" - val="${val//\\\(/(}" - val="${val//\\\)/)}" - val="${val//\\\[/[}" - val="${val//\\\]/]}" - local quoted="${(q-)val}" - inserts+=("$quoted") - [[ "$quoted" != "$val" ]] && needs_menu=1 + 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 -Q -S '' + _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 b31d2e62..b30b2b06 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,24 +29,22 @@ _mycli() { local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mycli.spec" mycli complete --usage >| "$spec_file" + # Each line from `complete-word --shell zsh` is two tab-separated columns: + # 1. value:description (or just value) for _describe's menu rendering + # 2. the shell-quoted form for compadd -Q to insert verbatim + # `usage complete-word` 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. local -a completions=() inserts=() - local needs_menu=0 - while IFS= read -r line; do - completions+=("$line") - local marker=$'\x01' - local subst="${line//\\:/$marker}" - local val="${subst%%:*}" - val="${val//$marker/:}" - val="${val//\\\(/(}" - val="${val//\\\)/)}" - val="${val//\\\[/[}" - val="${val//\\\]/]}" - local quoted="${(q-)val}" - inserts+=("$quoted") - [[ "$quoted" != "$val" ]] && needs_menu=1 + 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 -Q -S '' + _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 9dd2f358..093640f3 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 @@ -20,24 +20,14 @@ _usage_default_complete() { if IFS= read -r first < "$cmdpath" 2>/dev/null && [[ "$first" == "#!"*"usage"* ]]; then if (( ${+commands[usage]} )); then local -a completions=() inserts=() - local needs_menu=0 - local line - while IFS= read -r line; do - completions+=("$line") - local marker=$'\x01' - local subst="${line//\\:/$marker}" - local val="${subst%%:*}" - val="${val//$marker/:}" - val="${val//\\\(/(}" - val="${val//\\\)/)}" - val="${val//\\\[/[}" - val="${val//\\\]/]}" - local quoted="${(q-)val}" - inserts+=("$quoted") - [[ "$quoted" != "$val" ]] && needs_menu=1 + 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 -Q -S '' + _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 2e692851..a7d783b5 100644 --- a/lib/src/complete/zsh.rs +++ b/lib/src/complete/zsh.rs @@ -86,24 +86,22 @@ fi"# r#" local spec_file="${{TMPDIR:-/tmp}}/usage_{spec_variable}.spec" {file_write_logic} + # Each line from `complete-word --shell zsh` is two tab-separated columns: + # 1. value:description (or just value) for _describe's menu rendering + # 2. the shell-quoted form for compadd -Q to insert verbatim + # `usage complete-word` 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. local -a completions=() inserts=() - local needs_menu=0 - while IFS= read -r line; do - completions+=("$line") - local marker=$'\x01' - local subst="${{line//\\:/$marker}}" - local val="${{subst%%:*}}" - val="${{val//$marker/:}}" - val="${{val//\\\(/(}}" - val="${{val//\\\)/)}}" - val="${{val//\\\[/[}}" - val="${{val//\\\]/]}}" - local quoted="${{(q-)val}}" - inserts+=("$quoted") - [[ "$quoted" != "$val" ]] && needs_menu=1 + 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 -f "$spec_file" -- "${{(Q)words[@]}}") (( needs_menu )) && compstate[insert]=menu - _describe 'completions' completions inserts -Q -S '' + _describe 'completions' completions inserts -U -Q -S '' return 0 }} @@ -152,24 +150,14 @@ _usage_default_complete() {{ if IFS= read -r first < "$cmdpath" 2>/dev/null && [[ "$first" == "#!"*"usage"* ]]; then if (( ${{+commands[{usage_bin}]}} )); then local -a completions=() inserts=() - local needs_menu=0 - local line - while IFS= read -r line; do - completions+=("$line") - local marker=$'\x01' - local subst="${{line//\\:/$marker}}" - local val="${{subst%%:*}}" - val="${{val//$marker/:}}" - val="${{val//\\\(/(}}" - val="${{val//\\\)/)}}" - val="${{val//\\\[/[}}" - val="${{val//\\\]/]}}" - local quoted="${{(q-)val}}" - inserts+=("$quoted") - [[ "$quoted" != "$val" ]] && needs_menu=1 + 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 -f "$cmdpath" --cword=$((CURRENT - 1)) -- "${{(Q)words[@]}}") (( needs_menu )) && compstate[insert]=menu - _describe 'completions' completions inserts -Q -S '' + _describe 'completions' completions inserts -U -Q -S '' return $? fi fi From e0fb91ae35a4bf9294e2c06e834dd18d18b50d33 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Wed, 13 May 2026 10:09:30 -0500 Subject: [PATCH 3/4] refactor(zsh): extract shared completion loop into a helper The per-bin completion script (`complete_zsh`) and the shebang-fallback handler (`complete_zsh_init`) both emit the same loop that reads `\t` columns and hands them to `_describe`. Factor that into `render_completion_loop` so a future change to the matching or quoting logic only has to be made in one place. Behavior unchanged. --- ..._complete__zsh__tests__complete_zsh-2.snap | 7 -- ..._complete__zsh__tests__complete_zsh-3.snap | 7 -- ...e__complete__zsh__tests__complete_zsh.snap | 7 -- ...mplete__zsh__tests__complete_zsh_init.snap | 6 +- lib/src/complete/zsh.rs | 67 ++++++++++++------- 5 files changed, 45 insertions(+), 49 deletions(-) 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 a7e7bfa4..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,13 +31,6 @@ _mycli() { if [[ ! -f "$spec_file" ]]; then mycli complete --usage >| "$spec_file" fi - # Each line from `complete-word --shell zsh` is two tab-separated columns: - # 1. value:description (or just value) for _describe's menu rendering - # 2. the shell-quoted form for compadd -Q to insert verbatim - # `usage complete-word` 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. local -a completions=() inserts=() local needs_menu=0 display insert while IFS=$'\t' read -r display insert; do 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 8275855c..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,13 +54,6 @@ cmd plugin { } } __USAGE_EOF__ - # Each line from `complete-word --shell zsh` is two tab-separated columns: - # 1. value:description (or just value) for _describe's menu rendering - # 2. the shell-quoted form for compadd -Q to insert verbatim - # `usage complete-word` 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. local -a completions=() inserts=() local needs_menu=0 display insert while IFS=$'\t' read -r display insert; do 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 b30b2b06..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,13 +29,6 @@ _mycli() { local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mycli.spec" mycli complete --usage >| "$spec_file" - # Each line from `complete-word --shell zsh` is two tab-separated columns: - # 1. value:description (or just value) for _describe's menu rendering - # 2. the shell-quoted form for compadd -Q to insert verbatim - # `usage complete-word` 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. local -a completions=() inserts=() local needs_menu=0 display insert while IFS=$'\t' read -r display insert; do 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 093640f3..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 @@ -22,9 +22,9 @@ _usage_default_complete() { 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 + 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 '' diff --git a/lib/src/complete/zsh.rs b/lib/src/complete/zsh.rs index a7d783b5..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,26 +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} - # Each line from `complete-word --shell zsh` is two tab-separated columns: - # 1. value:description (or just value) for _describe's menu rendering - # 2. the shell-quoted form for compadd -Q to insert verbatim - # `usage complete-word` 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. - 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 -f "$spec_file" -- "${{(Q)words[@]}}") - (( needs_menu )) && compstate[insert]=menu - _describe 'completions' completions inserts -U -Q -S '' +{completion_loop} return 0 }} @@ -131,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 @@ -149,15 +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=() 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 -f "$cmdpath" --cword=$((CURRENT - 1)) -- "${{(Q)words[@]}}") - (( needs_menu )) && compstate[insert]=menu - _describe 'completions' completions inserts -U -Q -S '' +{completion_loop} return $? fi fi From d1f53e2ddec3404ab27839fd7929d4ecd99198bd Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Wed, 13 May 2026 10:13:47 -0500 Subject: [PATCH 4/4] chore: regenerate cli/assets/completions/_usage with new zsh template --- cli/assets/completions/_usage | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 }