From 9c174aaecf318f53538f4c7854fa26eead2fc9e3 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:16:40 +0000 Subject: [PATCH 1/4] fix(zsh): remove trailing space from completions and add directory slash Use _describe with -S '' instead of _arguments to prevent zsh from adding unwanted trailing spaces after completions. This fixes partial completions like `node@` and directory paths like `/opt/homebrew/`. Also append trailing `/` to directory entries in complete_path. Closes #67 Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/cli/complete_word.rs | 17 ++- cli/tests/shell_completions_integration.rs | 106 +++++++++++++++++- ..._complete__zsh__tests__complete_zsh-2.snap | 6 +- ..._complete__zsh__tests__complete_zsh-3.snap | 6 +- ...e__complete__zsh__tests__complete_zsh.snap | 6 +- lib/src/complete/zsh.rs | 6 +- 6 files changed, 136 insertions(+), 11 deletions(-) diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index 971be347..3ffd4e22 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -59,12 +59,12 @@ impl CompleteWord { } } "zsh" => { - let c = c.replace(":", "\\\\:"); + let c = c.replace(':', "\\:"); if any_descriptions { - let description = description.replace("'", "'\\''"); - println!("'{c}'\\:'{description}'") + let description = description.replace(':', "\\:"); + println!("{c}:{description}") } else { - println!("'{c}'") + println!("{c}") } } _ => miette::bail!("unsupported shell: {}", shell), @@ -338,10 +338,15 @@ impl CompleteWord { .map(|de| de.path()) .filter(|p| filter(p)) .map(|p| { - p.strip_prefix(base) + let mut s = p + .strip_prefix(base) .unwrap_or(&p) .to_string_lossy() - .to_string() + .to_string(); + if p.is_dir() { + s.push('/'); + } + s }) .sorted() .collect() diff --git a/cli/tests/shell_completions_integration.rs b/cli/tests/shell_completions_integration.rs index 2030ef62..532dfc0c 100644 --- a/cli/tests/shell_completions_integration.rs +++ b/cli/tests/shell_completions_integration.rs @@ -1,8 +1,28 @@ use std::env; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; +/// Helper to run usage complete-word and return stdout +fn run_complete_word(usage_bin: &Path, shell: &str, spec_file: &Path, words: &[&str]) -> String { + let mut args = vec![ + "complete-word".to_string(), + "--shell".to_string(), + shell.to_string(), + "-f".to_string(), + spec_file.to_str().unwrap().to_string(), + "--".to_string(), + ]; + args.extend(words.iter().map(|w| w.to_string())); + + let output = Command::new(usage_bin) + .args(&args) + .output() + .expect("Failed to run usage complete-word"); + + String::from_utf8_lossy(&output.stdout).to_string() +} + /// Build the usage binary and return its path fn build_usage_binary() -> PathBuf { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -673,3 +693,87 @@ Write-Host "COMPLETION_TEST_DONE" // Cleanup let _ = fs::remove_dir_all(&temp_dir); } + +#[test] +fn test_zsh_complete_word_output_format() { + let usage_bin = build_usage_binary(); + + let temp_dir = env::temp_dir().join(format!("usage_zsh_fmt_test_{}", std::process::id())); + fs::create_dir_all(&temp_dir).unwrap(); + + // Spec with subcommands (which have descriptions) + let usage_spec = r#"name testcli +bin testcli +flag "-v --verbose" help="Verbose output" +arg help="Input file" +cmd sub help="A subcommand" +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 + 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 + ); + assert!( + lines + .iter() + .any(|l| l.contains("other:Another subcommand")), + "Expected 'other:Another subcommand' in zsh output, got: {:?}", + lines + ); + + // Cleanup + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_complete_path_adds_trailing_slash_for_directories() { + let usage_bin = build_usage_binary(); + + let temp_dir = env::temp_dir().join(format!("usage_path_test_{}", std::process::id())); + fs::create_dir_all(&temp_dir).unwrap(); + + // Create a test directory structure + let test_dir = temp_dir.join("testdir"); + fs::create_dir_all(test_dir.join("subdir")).unwrap(); + fs::write(test_dir.join("file.txt"), "hello").unwrap(); + + // Spec with a path-type arg + let usage_spec = r#"name testcli +bin testcli +arg +complete path type="path" +"#; + let spec_file = temp_dir.join("test.spec"); + fs::write(&spec_file, usage_spec).unwrap(); + + // Complete with a path prefix pointing to our test directory + let test_dir_str = format!("{}/", test_dir.to_str().unwrap()); + let output = run_complete_word(&usage_bin, "bash", &spec_file, &["testcli", &test_dir_str]); + let lines: Vec<&str> = output.lines().collect(); + + // Directory should have trailing slash + assert!( + lines.iter().any(|l| l.ends_with("subdir/")), + "Expected directory completion to end with '/', got: {:?}", + lines + ); + + // File should NOT have trailing slash + assert!( + lines.iter().any(|l| l.ends_with("file.txt")), + "Expected file completion without trailing '/', got: {:?}", + lines + ); + + // Cleanup + let _ = fs::remove_dir_all(&temp_dir); +} 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 869f4879..2411ae27 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,7 +31,11 @@ _mycli() { if [[ ! -f "$spec_file" ]]; then mycli complete --usage >| "$spec_file" fi - _arguments "*: :(($(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}" )))" + 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 '' 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 0dbf8361..e0bb5884 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,7 +54,11 @@ cmd plugin { } } __USAGE_EOF__ - _arguments "*: :(($(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}" )))" + 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 '' 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 12f6ad03..f1083fb1 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,7 +29,11 @@ _mycli() { local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mycli.spec" mycli complete --usage >| "$spec_file" - _arguments "*: :(($(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}" )))" + 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 '' return 0 } diff --git a/lib/src/complete/zsh.rs b/lib/src/complete/zsh.rs index b72cfd1e..5dd852b0 100644 --- a/lib/src/complete/zsh.rs +++ b/lib/src/complete/zsh.rs @@ -86,7 +86,11 @@ fi"# r#" local spec_file="${{TMPDIR:-/tmp}}/usage_{spec_variable}.spec" {file_write_logic} - _arguments "*: :(($(command {usage_bin} complete-word --shell zsh -f "$spec_file" -- "${{words[@]}}" )))" + 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 '' return 0 }} From 566845293c5fd3d1b09bb31a8c82578d6690bcf9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:19:55 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- cli/assets/completions/_usage | 6 +++++- cli/tests/shell_completions_integration.rs | 4 +--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cli/assets/completions/_usage b/cli/assets/completions/_usage index dc6ee837..7b484972 100644 --- a/cli/assets/completions/_usage +++ b/cli/assets/completions/_usage @@ -25,7 +25,11 @@ _usage() { local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_usage.spec" usage --usage-spec >| "$spec_file" - _arguments "*: :(($(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}" )))" + 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 '' return 0 } diff --git a/cli/tests/shell_completions_integration.rs b/cli/tests/shell_completions_integration.rs index 532dfc0c..815c44cc 100644 --- a/cli/tests/shell_completions_integration.rs +++ b/cli/tests/shell_completions_integration.rs @@ -723,9 +723,7 @@ cmd other help="Another subcommand" lines ); assert!( - lines - .iter() - .any(|l| l.contains("other:Another subcommand")), + lines.iter().any(|l| l.contains("other:Another subcommand")), "Expected 'other:Another subcommand' in zsh output, got: {:?}", lines ); From fc5ec7a9690d6ab69d1e5c188c623a0ac4bf628d Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:28:36 +0000 Subject: [PATCH 3/4] fix(zsh): address PR review feedback - Remove `--` before `-S ''` in _describe call so the option is correctly forwarded to compadd - Use DirEntry::file_type() instead of Path::is_dir() to avoid extra stat syscalls in complete_path - Move colon escaping inside the descriptions branch only Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/cli/complete_word.rs | 14 +++++++++----- ...sage__complete__zsh__tests__complete_zsh-2.snap | 2 +- ...sage__complete__zsh__tests__complete_zsh-3.snap | 2 +- .../usage__complete__zsh__tests__complete_zsh.snap | 2 +- lib/src/complete/zsh.rs | 2 +- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index 3ffd4e22..917a63b4 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -59,8 +59,8 @@ impl CompleteWord { } } "zsh" => { - let c = c.replace(':', "\\:"); if any_descriptions { + let c = c.replace(':', "\\:"); let description = description.replace(':', "\\:"); println!("{c}:{description}") } else { @@ -335,15 +335,19 @@ impl CompleteWord { let name = name.to_string_lossy(); !name.starts_with('.') && name.starts_with(&prefix) }) - .map(|de| de.path()) - .filter(|p| filter(p)) - .map(|p| { + .filter(|de| filter(&de.path())) + .map(|de| { + let p = de.path(); + let is_dir = de + .file_type() + .map(|ft| ft.is_dir()) + .unwrap_or_else(|_| p.is_dir()); let mut s = p .strip_prefix(base) .unwrap_or(&p) .to_string_lossy() .to_string(); - if p.is_dir() { + if is_dir { s.push('/'); } s 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 2411ae27..38eabe3e 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 @@ -35,7 +35,7 @@ _mycli() { while IFS= read -r line; do completions+=("$line") done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -- -S '' + _describe 'completions' completions -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 e0bb5884..f5abd836 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 @@ -58,7 +58,7 @@ __USAGE_EOF__ while IFS= read -r line; do completions+=("$line") done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -- -S '' + _describe 'completions' completions -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 f1083fb1..7ba3e9f6 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 @@ -33,7 +33,7 @@ _mycli() { while IFS= read -r line; do completions+=("$line") done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -- -S '' + _describe 'completions' completions -S '' return 0 } diff --git a/lib/src/complete/zsh.rs b/lib/src/complete/zsh.rs index 5dd852b0..f89342e6 100644 --- a/lib/src/complete/zsh.rs +++ b/lib/src/complete/zsh.rs @@ -90,7 +90,7 @@ fi"# while IFS= read -r line; do completions+=("$line") done < <(command {usage_bin} complete-word --shell zsh -f "$spec_file" -- "${{words[@]}}") - _describe 'completions' completions -- -S '' + _describe 'completions' completions -S '' return 0 }} From a5097c9fa58ea1782a0069a11b5aeecdf9892f6e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:29:40 +0000 Subject: [PATCH 4/4] [autofix.ci] apply automated fixes --- cli/assets/completions/_usage | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/assets/completions/_usage b/cli/assets/completions/_usage index 7b484972..4c97636a 100644 --- a/cli/assets/completions/_usage +++ b/cli/assets/completions/_usage @@ -29,7 +29,7 @@ _usage() { while IFS= read -r line; do completions+=("$line") done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}") - _describe 'completions' completions -- -S '' + _describe 'completions' completions -S '' return 0 }