diff --git a/cli/assets/completions/_usage b/cli/assets/completions/_usage index a2ee4dd6..1bde4f2f 100644 --- a/cli/assets/completions/_usage +++ b/cli/assets/completions/_usage @@ -13,6 +13,7 @@ _usage_usage_cache_policy() { } _usage() { + emulate -L zsh typeset -A opt_args local curcontext="$curcontext" cache_policy diff --git a/cli/tests/shell_completions_integration.rs b/cli/tests/shell_completions_integration.rs index 44bab75c..7ec400ea 100644 --- a/cli/tests/shell_completions_integration.rs +++ b/cli/tests/shell_completions_integration.rs @@ -755,6 +755,83 @@ _testcli let _ = fs::remove_dir_all(&temp_dir); } +/// Regression test for user zsh options leaking into generated completions. +/// +/// With KSH_ARRAYS enabled, zsh arrays become zero-indexed. The generated +/// completion script indexes tab-split completion rows as zsh arrays, so it +/// must enter local zsh emulation before parsing `complete-word` output. +#[test] +fn test_zsh_completion_ignores_ksh_arrays_option() { + if skip_if_shell_missing("zsh") { + return; + } + + let usage_bin = build_usage_binary(); + let temp_dir = + env::temp_dir().join(format!("usage_zsh_ksh_arrays_test_{}", std::process::id())); + fs::create_dir_all(&temp_dir).unwrap(); + + let spec = r#" +bin "testcli" +cmd "doctor" help="Check installation" +"#; + let spec_kdl_file = temp_dir.join("testcli.kdl"); + fs::write(&spec_kdl_file, spec).unwrap(); + + 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 comp_file = temp_dir.join("_testcli"); + fs::write(&comp_file, &gen.stdout).unwrap(); + + let test_script = format!( + r#"#!/usr/bin/env zsh +set -e +setopt KSH_ARRAYS +export PATH="{usage_dir}:$PATH" +export TMPDIR="{tmp}" + +autoload -U compinit +compinit -u +source {comp} + +compadd() {{ + print -r -- "[compadd:inserts] ${{inserts[*]}}" +}} + +words=(testcli d) +CURRENT=2 +_testcli +"#, + usage_dir = usage_bin.parent().unwrap().to_str().unwrap(), + tmp = temp_dir.to_str().unwrap(), + comp = comp_file.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); + assert!( + result.status.success(), + "zsh completion script exited non-zero ({}).\nstdout:\n{stdout}\nstderr:\n{stderr}", + result.status + ); + assert!( + stdout.contains("[compadd:inserts] doctor"), + "Expected only the insert value in compadd inserts.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + 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 c04c969d..39149567 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 @@ -17,6 +17,7 @@ _usage_mycli_cache_policy() { } _mycli() { + emulate -L zsh typeset -A opt_args local curcontext="$curcontext" cache_policy 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 400cf313..55002a74 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 @@ -7,6 +7,7 @@ expression: "complete_zsh(&CompleteOptions\n{\n usage_bin: \"usage\".to_strin local curcontext="$curcontext" _mycli() { + emulate -L zsh typeset -A opt_args local curcontext="$curcontext" cache_policy 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 bd98ccc4..2b1e6f49 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 @@ -17,6 +17,7 @@ _usage_mycli_cache_policy() { } _mycli() { + emulate -L zsh typeset -A opt_args local curcontext="$curcontext" cache_policy 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 4ca71ad9..bb0a1ef8 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 @@ -7,6 +7,7 @@ expression: "complete_zsh_init(\"usage\")" # on $PATH whose first line is a `usage` shebang. _usage_default_complete() { + emulate -L zsh local cmd cmdpath cmd="${words[1]}" if [[ "$cmd" == */* ]]; then diff --git a/lib/src/complete/zsh.rs b/lib/src/complete/zsh.rs index f1da0e15..ed5e25e5 100644 --- a/lib/src/complete/zsh.rs +++ b/lib/src/complete/zsh.rs @@ -98,6 +98,7 @@ _usage_{bin_snake}_cache_policy() {{ out.push(format!( r#" _{bin_snake}() {{ + emulate -L zsh typeset -A opt_args local curcontext="$curcontext" cache_policy @@ -187,6 +188,7 @@ pub fn complete_zsh_init(usage_bin: &str) -> String { # on $PATH whose first line is a `usage` shebang. _usage_default_complete() {{ + emulate -L zsh local cmd cmdpath cmd="${{words[1]}}" if [[ "$cmd" == */* ]]; then