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
14 changes: 9 additions & 5 deletions cli/assets/completions/_usage
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
38 changes: 32 additions & 6 deletions cli/src/cli/complete_word.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down Expand Up @@ -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<String> {
let output = Command::new("sh")
.arg("-c")
Expand Down
13 changes: 10 additions & 3 deletions cli/tests/complete_word.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
106 changes: 100 additions & 6 deletions cli/tests/shell_completions_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `<display>\t<insert>` — 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 "<course>" required {
choices "A B & C"
}
arg "<recipient>" 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 "<env>" { 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") {
Expand Down Expand Up @@ -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 `<display>\t<insert>` where
// <display> is `name:description` (or `name`) for `_describe`'s menu
// rendering, and <insert> 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
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading