Visitar URL original
fix(zsh): remove trailing space from completions and add directory sl… · jdx/usage@ae67b38 · GitHub
Skip to content

Commit ae67b38

Browse files
jdxclaudeautofix-ci[bot]
authored
fix(zsh): remove trailing space from completions and add directory slash (#556)
## Summary - Use `_describe` with `-S ''` instead of `_arguments` in zsh completion template to prevent unwanted trailing spaces after completions (e.g., `node@` no longer gets a space appended) - Append trailing `/` to directory entries in `complete_path` so directory completions show `/opt/homebrew/` instead of `/opt/homebrew` - Change zsh `complete-word` output format from `'val'\:'desc'` to `val:desc` to match `_describe` expectations Closes #67 ## Test plan - [x] Added `test_zsh_complete_word_output_format` — verifies new `name:description` output format - [x] Added `test_complete_path_adds_trailing_slash_for_directories` — verifies dirs get `/` suffix, files don't - [x] Updated zsh completion snapshots - [x] All existing tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes zsh completion generation and `complete-word` output formatting, which can break shell completion behavior if the new format is not accepted across zsh setups. Also tweaks path completion output (adds `/` for directories), affecting user-visible CLI suggestions. > > **Overview** > Fixes zsh completions to avoid unwanted trailing spaces by switching the generated zsh completion scripts from `_arguments` command substitution to collecting candidates and feeding them into `_describe` with `-S ''`. > > Updates `usage complete-word --shell zsh` to emit `value:description` (with `:` escaped) to match `_describe` expectations, and enhances path completion to append `/` to directory candidates. Adds integration tests covering the new zsh output format and directory-slash behavior, and updates zsh completion snapshots accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fc5ec7a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 057b589 commit ae67b38

7 files changed

Lines changed: 146 additions & 15 deletions

File tree

‎cli/assets/completions/_usage‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ _usage() {
2525

2626
local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_usage.spec"
2727
usage --usage-spec >| "$spec_file"
28-
_arguments "*: :(($(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}" )))"
28+
local -a completions=()
29+
while IFS= read -r line; do
30+
completions+=("$line")
31+
done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}")
32+
_describe 'completions' completions -S ''
2933
return 0
3034
}
3135

‎cli/src/cli/complete_word.rs‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ impl CompleteWord {
5959
}
6060
}
6161
"zsh" => {
62-
let c = c.replace(":", "\\\\:");
6362
if any_descriptions {
64-
let description = description.replace("'", "'\\''");
65-
println!("'{c}'\\:'{description}'")
63+
let c = c.replace(':', "\\:");
64+
let description = description.replace(':', "\\:");
65+
println!("{c}:{description}")
6666
} else {
67-
println!("'{c}'")
67+
println!("{c}")
6868
}
6969
}
7070
_ => miette::bail!("unsupported shell: {}", shell),
@@ -335,13 +335,22 @@ impl CompleteWord {
335335
let name = name.to_string_lossy();
336336
!name.starts_with('.') && name.starts_with(&prefix)
337337
})
338-
.map(|de| de.path())
339-
.filter(|p| filter(p))
340-
.map(|p| {
341-
p.strip_prefix(base)
338+
.filter(|de| filter(&de.path()))
339+
.map(|de| {
340+
let p = de.path();
341+
let is_dir = de
342+
.file_type()
343+
.map(|ft| ft.is_dir())
344+
.unwrap_or_else(|_| p.is_dir());
345+
let mut s = p
346+
.strip_prefix(base)
342347
.unwrap_or(&p)
343348
.to_string_lossy()
344-
.to_string()
349+
.to_string();
350+
if is_dir {
351+
s.push('/');
352+
}
353+
s
345354
})
346355
.sorted()
347356
.collect()

‎cli/tests/shell_completions_integration.rs‎

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,28 @@
11
use std::env;
22
use std::fs;
3-
use std::path::PathBuf;
3+
use std::path::{Path, PathBuf};
44
use std::process::Command;
55

6+
/// Helper to run usage complete-word and return stdout
7+
fn run_complete_word(usage_bin: &Path, shell: &str, spec_file: &Path, words: &[&str]) -> String {
8+
let mut args = vec![
9+
"complete-word".to_string(),
10+
"--shell".to_string(),
11+
shell.to_string(),
12+
"-f".to_string(),
13+
spec_file.to_str().unwrap().to_string(),
14+
"--".to_string(),
15+
];
16+
args.extend(words.iter().map(|w| w.to_string()));
17+
18+
let output = Command::new(usage_bin)
19+
.args(&args)
20+
.output()
21+
.expect("Failed to run usage complete-word");
22+
23+
String::from_utf8_lossy(&output.stdout).to_string()
24+
}
25+
626
/// Build the usage binary and return its path
727
fn build_usage_binary() -> PathBuf {
828
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
@@ -673,3 +693,85 @@ Write-Host "COMPLETION_TEST_DONE"
673693
// Cleanup
674694
let _ = fs::remove_dir_all(&temp_dir);
675695
}
696+
697+
#[test]
698+
fn test_zsh_complete_word_output_format() {
699+
let usage_bin = build_usage_binary();
700+
701+
let temp_dir = env::temp_dir().join(format!("usage_zsh_fmt_test_{}", std::process::id()));
702+
fs::create_dir_all(&temp_dir).unwrap();
703+
704+
// Spec with subcommands (which have descriptions)
705+
let usage_spec = r#"name testcli
706+
bin testcli
707+
flag "-v --verbose" help="Verbose output"
708+
arg <file> help="Input file"
709+
cmd sub help="A subcommand"
710+
cmd other help="Another subcommand"
711+
"#;
712+
let spec_file = temp_dir.join("test.spec");
713+
fs::write(&spec_file, usage_spec).unwrap();
714+
715+
// Test zsh output format: should be `name:description` for _describe
716+
let output = run_complete_word(&usage_bin, "zsh", &spec_file, &["testcli", ""]);
717+
let lines: Vec<&str> = output.lines().collect();
718+
719+
// Should have completions with description format "name:description"
720+
assert!(
721+
lines.iter().any(|l| l.contains("sub:A subcommand")),
722+
"Expected 'sub:A subcommand' in zsh output, got: {:?}",
723+
lines
724+
);
725+
assert!(
726+
lines.iter().any(|l| l.contains("other:Another subcommand")),
727+
"Expected 'other:Another subcommand' in zsh output, got: {:?}",
728+
lines
729+
);
730+
731+
// Cleanup
732+
let _ = fs::remove_dir_all(&temp_dir);
733+
}
734+
735+
#[test]
736+
fn test_complete_path_adds_trailing_slash_for_directories() {
737+
let usage_bin = build_usage_binary();
738+
739+
let temp_dir = env::temp_dir().join(format!("usage_path_test_{}", std::process::id()));
740+
fs::create_dir_all(&temp_dir).unwrap();
741+
742+
// Create a test directory structure
743+
let test_dir = temp_dir.join("testdir");
744+
fs::create_dir_all(test_dir.join("subdir")).unwrap();
745+
fs::write(test_dir.join("file.txt"), "hello").unwrap();
746+
747+
// Spec with a path-type arg
748+
let usage_spec = r#"name testcli
749+
bin testcli
750+
arg <path>
751+
complete path type="path"
752+
"#;
753+
let spec_file = temp_dir.join("test.spec");
754+
fs::write(&spec_file, usage_spec).unwrap();
755+
756+
// Complete with a path prefix pointing to our test directory
757+
let test_dir_str = format!("{}/", test_dir.to_str().unwrap());
758+
let output = run_complete_word(&usage_bin, "bash", &spec_file, &["testcli", &test_dir_str]);
759+
let lines: Vec<&str> = output.lines().collect();
760+
761+
// Directory should have trailing slash
762+
assert!(
763+
lines.iter().any(|l| l.ends_with("subdir/")),
764+
"Expected directory completion to end with '/', got: {:?}",
765+
lines
766+
);
767+
768+
// File should NOT have trailing slash
769+
assert!(
770+
lines.iter().any(|l| l.ends_with("file.txt")),
771+
"Expected file completion without trailing '/', got: {:?}",
772+
lines
773+
);
774+
775+
// Cleanup
776+
let _ = fs::remove_dir_all(&temp_dir);
777+
}

‎lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh-2.snap‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ _mycli() {
3131
if [[ ! -f "$spec_file" ]]; then
3232
mycli complete --usage >| "$spec_file"
3333
fi
34-
_arguments "*: :(($(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}" )))"
34+
local -a completions=()
35+
while IFS= read -r line; do
36+
completions+=("$line")
37+
done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}")
38+
_describe 'completions' completions -S ''
3539
return 0
3640
}
3741

‎lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh-3.snap‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ cmd plugin {
5454
}
5555
}
5656
__USAGE_EOF__
57-
_arguments "*: :(($(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}" )))"
57+
local -a completions=()
58+
while IFS= read -r line; do
59+
completions+=("$line")
60+
done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}")
61+
_describe 'completions' completions -S ''
5862
return 0
5963
}
6064

‎lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh.snap‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ _mycli() {
2929

3030
local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mycli.spec"
3131
mycli complete --usage >| "$spec_file"
32-
_arguments "*: :(($(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}" )))"
32+
local -a completions=()
33+
while IFS= read -r line; do
34+
completions+=("$line")
35+
done < <(command usage complete-word --shell zsh -f "$spec_file" -- "${words[@]}")
36+
_describe 'completions' completions -S ''
3337
return 0
3438
}
3539

‎lib/src/complete/zsh.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ fi"#
8686
r#"
8787
local spec_file="${{TMPDIR:-/tmp}}/usage_{spec_variable}.spec"
8888
{file_write_logic}
89-
_arguments "*: :(($(command {usage_bin} complete-word --shell zsh -f "$spec_file" -- "${{words[@]}}" )))"
89+
local -a completions=()
90+
while IFS= read -r line; do
91+
completions+=("$line")
92+
done < <(command {usage_bin} complete-word --shell zsh -f "$spec_file" -- "${{words[@]}}")
93+
_describe 'completions' completions -S ''
9094
return 0
9195
}}
9296

0 commit comments

Comments
 (0)