Repository: clap-rs/clap
Pull request #5545 (2024-06-21 13:16:42)

feat(clap_complete): add native completion support for zsh
<!--
Thanks for helping out!

Please link the appropriate issue from your PR.

If you don't have an issue, we'd recommend starting with one first so the PR can focus on the
implementation (unless its an obvious bug or documentation fix that will have
little conversation).
-->
Related issue https://github.com/clap-rs/clap/issues/3916

#### Work in this PR
- Add zsh glue script code to support that we could call external native completion.
- Add the same level of tests as fish

epage: > related issue: https://github.com/clap-rs/clap/issues/3916

If this isn't closing out the issue, what is left?
epage: Blockers for merge
- [ ] https://github.com/clap-rs/clap/pull/5545#issuecomment-2182765699
- [ ] CI passing
- [ ] Cleaned up history (likely could be a single commit in this case)

Patch:
diff --git a/clap_complete/src/dynamic/shells/mod.rs b/clap_complete/src/dynamic/shells/mod.rs
index 6cfed97e..8caff617 100644
--- a/clap_complete/src/dynamic/shells/mod.rs
+++ b/clap_complete/src/dynamic/shells/mod.rs
@@ -3,10 +3,12 @@
 mod bash;
 mod fish;
 mod shell;
+mod zsh;
 
 pub use bash::*;
 pub use fish::*;
 pub use shell::*;
+pub use zsh::*;
 
 use std::ffi::OsString;
 use std::io::Write as _;
diff --git a/clap_complete/src/dynamic/shells/shell.rs b/clap_complete/src/dynamic/shells/shell.rs
index 51386514..c7f0a970 100644
--- a/clap_complete/src/dynamic/shells/shell.rs
+++ b/clap_complete/src/dynamic/shells/shell.rs
@@ -12,6 +12,8 @@ pub enum Shell {
     Bash,
     /// Friendly Interactive `SHell` (fish)
     Fish,
+    /// Z shell (zsh)
+    Zsh,
 }
 
 impl Display for Shell {
@@ -39,13 +41,14 @@ impl FromStr for Shell {
 // Hand-rolled so it can work even when `derive` feature is disabled
 impl ValueEnum for Shell {
     fn value_variants<'a>() -> &'a [Self] {
-        &[Shell::Bash, Shell::Fish]
+        &[Shell::Bash, Shell::Fish, Shell::Zsh]
     }
 
     fn to_possible_value(&self) -> Option<PossibleValue> {
         Some(match self {
             Shell::Bash => PossibleValue::new("bash"),
             Shell::Fish => PossibleValue::new("fish"),
+            Shell::Zsh => PossibleValue::new("zsh"),
         })
     }
 }
@@ -55,6 +58,7 @@ impl Shell {
         match self {
             Self::Bash => &super::Bash,
             Self::Fish => &super::Fish,
+            Self::Zsh => &super::Zsh,
         }
     }
 }
diff --git a/clap_complete/src/dynamic/shells/zsh.rs b/clap_complete/src/dynamic/shells/zsh.rs
new file mode 100644
index 00000000..d6e699f7
--- /dev/null
+++ b/clap_complete/src/dynamic/shells/zsh.rs
@@ -0,0 +1,65 @@
+/// Completion support for zsh
+#[derive(Copy, Clone, PartialEq, Eq, Debug)]
+pub struct Zsh;
+
+impl crate::dynamic::Completer for Zsh {
+    fn file_name(&self, name: &str) -> String {
+        format!("{name}.zsh")
+    }
+    fn write_registration(
+        &self,
+        _name: &str,
+        bin: &str,
+        completer: &str,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error> {
+        let bin = shlex::quote(bin);
+        let completer = shlex::quote(completer);
+        let script = r#"#compdef BIN
+function _clap_dynamic_completer() {
+    export _CLAP_COMPLETE_INDEX=$(expr $CURRENT - 1)
+    export _CLAP_IFS=$'\n'
+
+    local completions=("${(@f)$(COMPLETER complete --shell zsh -- ${words} 2>/dev/null)}")
+
+    if [[ -n $completions ]]; then
+        compadd -a completions
+    fi
+}
+
+compdef _clap_dynamic_completer BIN"#
+            .replace("COMPLETER", &completer)
+            .replace("BIN", &bin);
+
+        writeln!(buf, "{script}")?;
+        Ok(())
+    }
+    fn write_complete(
+        &self,
+        cmd: &mut clap::Command,
+        args: Vec<std::ffi::OsString>,
+        current_dir: Option<&std::path::Path>,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error> {
+        let index: usize = std::env::var("_CLAP_COMPLETE_INDEX")
+            .ok()
+            .and_then(|i| i.parse().ok())
+            .unwrap_or_default();
+        let ifs: Option<String> = std::env::var("_CLAP_IFS").ok().and_then(|i| i.parse().ok());
+
+        // If the current word is empty, add an empty string to the args
+        let mut args = args.clone();
+        if args.len() == index {
+            args.push("".into());
+        }
+        let completions = crate::dynamic::complete(cmd, args, index, current_dir)?;
+
+        for (i, (completion, _)) in completions.iter().enumerate() {
+            if i != 0 {
+                write!(buf, "{}", ifs.as_deref().unwrap_or("\n"))?;
+            }
+            write!(buf, "{}", completion.to_string_lossy())?;
+        }
+        Ok(())
+    }
+}


Test patch:
diff --git a/clap_complete/tests/snapshots/home/dynamic/exhaustive/zsh/.zshenv b/clap_complete/tests/snapshots/home/dynamic/exhaustive/zsh/.zshenv
new file mode 100644
index 00000000..6d309f24
--- /dev/null
+++ b/clap_complete/tests/snapshots/home/dynamic/exhaustive/zsh/.zshenv
@@ -0,0 +1,5 @@
+fpath=($fpath $ZDOTDIR/zsh)
+autoload -U +X compinit && compinit -u # bypass compaudit security checking
+precmd_functions=""  # avoid the prompt being overwritten
+PS1='%% '
+PROMPT='%% '
diff --git a/clap_complete/tests/snapshots/home/dynamic/exhaustive/zsh/zsh/_exhaustive b/clap_complete/tests/snapshots/home/dynamic/exhaustive/zsh/zsh/_exhaustive
new file mode 100644
index 00000000..97d2d1ab
--- /dev/null
+++ b/clap_complete/tests/snapshots/home/dynamic/exhaustive/zsh/zsh/_exhaustive
@@ -0,0 +1,13 @@
+#compdef exhaustive
+function _clap_dynamic_completer() {
+    export _CLAP_COMPLETE_INDEX=$(expr $CURRENT - 1)
+    export _CLAP_IFS=$'\n'
+
+    local completions=("${(@f)$(exhaustive complete --shell zsh -- ${words} 2>/dev/null)}")
+
+    if [[ -n $completions ]]; then
+        compadd -a completions
+    fi
+}
+
+compdef _clap_dynamic_completer exhaustive
diff --git a/clap_complete/tests/snapshots/home/static/exhaustive/bash/.bashrc b/clap_complete/tests/snapshots/home/static/exhaustive/bash/.bashrc
index bfbaec6f..52999c45 100644
--- a/clap_complete/tests/snapshots/home/static/exhaustive/bash/.bashrc
+++ b/clap_complete/tests/snapshots/home/static/exhaustive/bash/.bashrc
@@ -245,7 +245,7 @@ _exhaustive() {
             fi
             case "${prev}" in
                 --shell)
-                    COMPREPLY=($(compgen -W "bash fish" -- "${cur}"))
+                    COMPREPLY=($(compgen -W "bash fish zsh" -- "${cur}"))
                     return 0
                     ;;
                 --register)
diff --git a/clap_complete/tests/snapshots/home/static/exhaustive/fish/fish/completions/exhaustive.fish b/clap_complete/tests/snapshots/home/static/exhaustive/fish/fish/completions/exhaustive.fish
index a5bb92fb..dd52a170 100644
--- a/clap_complete/tests/snapshots/home/static/exhaustive/fish/fish/completions/exhaustive.fish
+++ b/clap_complete/tests/snapshots/home/static/exhaustive/fish/fish/completions/exhaustive.fish
@@ -136,7 +136,7 @@ complete -c exhaustive -n "__fish_exhaustive_using_subcommand hint" -l email -r
 complete -c exhaustive -n "__fish_exhaustive_using_subcommand hint" -l global -d 'everywhere'
 complete -c exhaustive -n "__fish_exhaustive_using_subcommand hint" -s h -l help -d 'Print help'
 complete -c exhaustive -n "__fish_exhaustive_using_subcommand hint" -s V -l version -d 'Print version'
-complete -c exhaustive -n "__fish_exhaustive_using_subcommand complete" -l shell -d 'Specify shell to complete for' -r -f -a "{bash\t'',fish\t''}"
+complete -c exhaustive -n "__fish_exhaustive_using_subcommand complete" -l shell -d 'Specify shell to complete for' -r -f -a "{bash\t'',fish\t'',zsh\t''}"
 complete -c exhaustive -n "__fish_exhaustive_using_subcommand complete" -l register -d 'Path to write completion-registration to' -r -F
 complete -c exhaustive -n "__fish_exhaustive_using_subcommand complete" -l global -d 'everywhere'
 complete -c exhaustive -n "__fish_exhaustive_using_subcommand complete" -s h -l help -d 'Print help (see more with \'--help\')'
diff --git a/clap_complete/tests/snapshots/home/static/exhaustive/zsh/zsh/_exhaustive b/clap_complete/tests/snapshots/home/static/exhaustive/zsh/zsh/_exhaustive
index a49a847f..d6329ade 100644
--- a/clap_complete/tests/snapshots/home/static/exhaustive/zsh/zsh/_exhaustive
+++ b/clap_complete/tests/snapshots/home/static/exhaustive/zsh/zsh/_exhaustive
@@ -325,7 +325,7 @@ _arguments "${_arguments_options[@]}" : \
 ;;
 (complete)
 _arguments "${_arguments_options[@]}" : \
-'--shell=[Specify shell to complete for]:SHELL:(bash fish)' \
+'--shell=[Specify shell to complete for]:SHELL:(bash fish zsh)' \
 '--register=[Path to write completion-registration to]:REGISTER:_files' \
 '--global[everywhere]' \
 '-h[Print help (see more with '\''--help'\'')]' \
diff --git a/clap_complete/tests/testsuite/zsh.rs b/clap_complete/tests/testsuite/zsh.rs
index 8acb42a6..e483e7fd 100644
--- a/clap_complete/tests/testsuite/zsh.rs
+++ b/clap_complete/tests/testsuite/zsh.rs
@@ -162,3 +162,42 @@ pacman    action  alias  value  quote  hint  last  --
     let actual = runtime.complete(input, &term).unwrap();
     assert_data_eq!(actual, expected);
 }
+
+
+#[cfg(all(unix, feature = "unstable-dynamic"))]
+#[test]
+fn register_dynamic() {
+    common::register_example::<completest_pty::ZshRuntimeBuilder>("dynamic", "exhaustive");
+}
+
+#[test]
+#[cfg(all(unix, feature = "unstable-dynamic"))]
+fn complete_dynamic() {
+    if !common::has_command("zsh") {
+        return;
+    }
+
+    let term = completest::Term::new();
+    let mut runtime =
+        common::load_runtime::<completest_pty::ZshRuntimeBuilder>("dynamic", "exhaustive");
+
+    let input = "exhaustive \t\t";
+    let expected = snapbox::str![
+        r#"% exhaustive
+--generate  --help      -V          action      complete    hint        pacman      value       
+--global    --version   -h          alias       help        last        quote       "#
+    ];
+    let actual = runtime.complete(input, &term).unwrap();
+    assert_data_eq!(actual, expected);
+
+    let input = "exhaustive quote \t\t";
+    let expected = snapbox::str![
+        r#"% exhaustive quote
+--backslash        --double-quotes    --single-quotes    cmd-backslash      cmd-expansions     
+--backticks        --expansions       --version          cmd-backticks      cmd-single-quotes  
+--brackets         --global           -V                 cmd-brackets       escape-help        
+--choice           --help             -h                 cmd-double-quotes  help               "#
+    ];
+    let actual = runtime.complete(input, &term).unwrap();
+    assert_data_eq!(actual, expected);
+}
\ No newline at end of file