Repository: clap-rs/clap (language: rust)

Issue:
Switch native completions to encourage env var initialization
Maintainer's notes

We should document each solution (flag, subcommand, env var) and the trade offs.  We should probably make env variable the default.

---

### Discussed in https://github.com/clap-rs/clap/discussions/3926

<div type='discussions-op-text'>

<sup>Originally posted by **AndreasBackx** July 13, 2022</sup>
We have a nice macro that automatically adds autocomplete to your Clap CLI. We are currently doing this via a subcommand `generate-completions`. Though we realise that this approach makes it impossible to automatically generate completions for certain CLIs:

```rust
#[derive(Parser, Debug)]
pub struct Args {
    foo: String,
}
```

Imagine we have that very simple CLI and we want to add a subcommand. `foo` is a required argument so if we want to automate the autocomplete generation by adding `eval $(cli generate-completions)` or by exporting it to `/usr/share/bash-completion/completions/cli` for Bash, the CLI will complain that `foo` wasn't passed.

Therefore it seems right now that we should avoid depending on Clap parsing as part of autocomplete generation. Perhaps the people at Click realised this earlier as that is what they are doing: https://click.palletsprojects.com/en/8.1.x/shell-completion/.

So I have a few questions that spring from this:
1. Is my understanding correct? Would love to hear there might be a mistake.
2. What do you think of the solution, can you perhaps think of another solution?
3. Should the documentations surrounding this perhaps be updated?
4. With this in mind, perhaps we could integrate clap_complete into clap as a feature? If enabled, you can get autocomplete automatically in your CLI in a similar way to Click.


_Funnily enough we didn't initially face this problem because of how `command_for_update` sets required to false, see #3924._</div>


Discussion / hints:
We now offer a subcommand for rust-native completions.  Closing in favor of that.  Stabilizing this is tracked in #3166
As we are no longer documenting but providing a solution, I'm going to repurpose this to change native completions to use the env variable approach
- Good for performance:; don't need to go through application initialization, `Command` initialization, etc
- Good for build times / implementation complexity: we don't need to use the derive or hand implement the derive traits

What we could do is offer both approaches, each behind a feature.

Steps
1. Re-organize `shells/` where all shells are in `shells.rs` and the trait, trait impls, complete args, complete commands are in a `cmd.rs`
2. Add a `impl<F: Fn() -> Command> CommandFactory for F`
3. Add an `env.rs` with a builder for env var handling that takes an env var and a `CommandFactory` type, and a `try_eval` and a `eval`
4. Put Each of the above behind a feature

Fix patch:
diff --git a/clap_complete/Cargo.toml b/clap_complete/Cargo.toml
index 9461771e6c5..e3b2dc832a9 100644
--- a/clap_complete/Cargo.toml
+++ b/clap_complete/Cargo.toml
@@ -57,8 +57,8 @@ required-features = ["unstable-dynamic", "unstable-command"]
 [features]
 default = []
 unstable-doc = ["unstable-dynamic", "unstable-command"] # for docs.rs
-unstable-dynamic = ["dep:clap_lex", "dep:is_executable", "dep:pathdiff", "clap/unstable-ext"]
-unstable-command = ["unstable-dynamic", "dep:shlex", "dep:unicode-xid", "clap/derive", "dep:is_executable", "dep:pathdiff", "clap/unstable-ext"]
+unstable-dynamic = ["dep:clap_lex", "dep:shlex", "dep:is_executable", "dep:pathdiff", "clap/unstable-ext"]
+unstable-command = ["unstable-dynamic", "dep:unicode-xid", "clap/derive", "dep:is_executable", "dep:pathdiff", "clap/unstable-ext"]
 debug = ["clap/debug"]
 
 [lints]
diff --git a/clap_complete/examples/dynamic.rs b/clap_complete/examples/dynamic.rs
index 9054fcac0f5..f22b7adac3b 100644
--- a/clap_complete/examples/dynamic.rs
+++ b/clap_complete/examples/dynamic.rs
@@ -20,6 +20,8 @@ fn command() -> clap::Command {
 }
 
 fn main() {
+    clap_complete::dynamic::CompleteEnv::with_factory(command).complete();
+
     let cmd = command();
     let matches = cmd.get_matches();
     if let Ok(completions) = clap_complete::dynamic::CompleteCommand::from_arg_matches(&matches) {
diff --git a/clap_complete/examples/exhaustive.rs b/clap_complete/examples/exhaustive.rs
index ce7d313fd30..1f63cadb5c8 100644
--- a/clap_complete/examples/exhaustive.rs
+++ b/clap_complete/examples/exhaustive.rs
@@ -4,6 +4,9 @@ use clap::{FromArgMatches, Subcommand};
 use clap_complete::{generate, Generator, Shell};
 
 fn main() {
+    #[cfg(feature = "unstable-dynamic")]
+    clap_complete::dynamic::CompleteEnv::with_factory(cli).complete();
+
     let matches = cli().get_matches();
     if let Some(generator) = matches.get_one::<Shell>("generate") {
         let mut cmd = cli();
diff --git a/clap_complete/src/dynamic/env/mod.rs b/clap_complete/src/dynamic/env/mod.rs
new file mode 100644
index 00000000000..7c7257cf340
--- /dev/null
+++ b/clap_complete/src/dynamic/env/mod.rs
@@ -0,0 +1,295 @@
+//! [`COMPLETE=$SHELL <bin>`][CompleteEnv] completion integration
+//!
+//! See [`CompleteEnv`]:
+//! ```rust
+//! # use clap_complete::dynamic::CompleteEnv;
+//! fn cli() -> clap::Command {
+//!     // ...
+//! #   clap::Command::new("empty")
+//! }
+//!
+//! fn main() {
+//!     CompleteEnv::with_factory(cli)
+//!         .complete();
+//!
+//!     // ... rest of application logic
+//! }
+//! ```
+//!
+//! To source your completions:
+//!
+//! Bash
+//! ```bash
+//! echo "source <(COMPLETE=bash your_program complete)" >> ~/.bashrc
+//! ```
+//!
+//! Elvish
+//! ```elvish
+//! echo "eval (COMPLETE=elvish your_program complete)" >> ~/.elvish/rc.elv
+//! ```
+//!
+//! Fish
+//! ```fish
+//! echo "source (COMPLETE=fish your_program complete | psub)" >> ~/.config/fish/config.fish
+//! ```
+//!
+//! Powershell
+//! ```powershell
+//! echo "COMPLETE=powershell your_program complete | Invoke-Expression" >> $PROFILE
+//! ```
+//!
+//! Zsh
+//! ```zsh
+//! echo "source <(COMPLETE=zsh your_program complete)" >> ~/.zshrc
+//! ```
+
+mod shells;
+
+use std::ffi::OsString;
+use std::io::Write as _;
+
+pub use shells::*;
+
+/// Environment-activated completions for your CLI
+///
+/// Benefits over CLI a completion argument or subcommand
+/// - Performance: we don't need to general [`clap::Command`] twice or parse arguments
+/// - Flexibility: there is no concern over it interfering with other CLI logic
+///
+/// ```rust
+/// # use clap_complete::dynamic::CompleteEnv;
+/// fn cli() -> clap::Command {
+///     // ...
+/// #   clap::Command::new("empty")
+/// }
+///
+/// fn main() {
+///     CompleteEnv::with_factory(cli)
+///         .complete()
+///
+///     // ... rest of application logic
+/// }
+/// ```
+pub struct CompleteEnv<'s, F> {
+    factory: F,
+    var: &'static str,
+    shells: Shells<'s>,
+}
+
+impl<'s, F: FnOnce() -> clap::Command> CompleteEnv<'s, F> {
+    /// Complete a [`clap::Command`]
+    ///
+    /// # Example
+    ///
+    /// Builder:
+    /// ```rust
+    /// # use clap_complete::dynamic::CompleteEnv;
+    /// fn cli() -> clap::Command {
+    ///     // ...
+    /// #   clap::Command::new("empty")
+    /// }
+    ///
+    /// fn main() {
+    ///     CompleteEnv::with_factory(cli)
+    ///         .complete()
+    ///
+    ///     // ... rest of application logic
+    /// }
+    /// ```
+    ///
+    /// Derive:
+    /// ```
+    /// # use clap::Parser;
+    /// # use clap_complete::dynamic::CompleteEnv;
+    /// use clap::CommandFactory as _;
+    ///
+    /// #[derive(Debug, Parser)]
+    /// struct Cli {
+    ///     custom: Option<String>,
+    /// }
+    ///
+    /// fn main() {
+    ///     CompleteEnv::with_factory(|| Cli::command())
+    ///         .complete()
+    ///
+    ///     // ... rest of application logic
+    /// }
+    /// ```
+    pub fn with_factory(factory: F) -> Self {
+        Self {
+            factory,
+            var: "COMPLETE",
+            shells: Shells::builtins(),
+        }
+    }
+
+    /// Override the environment variable used for enabling completions
+    pub fn var(mut self, var: &'static str) -> Self {
+        self.var = var;
+        self
+    }
+
+    /// Override the shells supported for completions
+    pub fn shells(mut self, shells: Shells<'s>) -> Self {
+        self.shells = shells;
+        self
+    }
+}
+
+impl<'s, F: FnOnce() -> clap::Command> CompleteEnv<'s, F> {
+    /// Process the completion request and exit
+    ///
+    /// **Warning:** `stdout` should not be written to before this has had a
+    /// chance to run.
+    pub fn complete(self) {
+        let args = std::env::args_os();
+        let current_dir = std::env::current_dir().ok();
+        if self
+            .try_complete(args, current_dir.as_deref())
+            .unwrap_or_else(|e| e.exit())
+        {
+            std::process::exit(0)
+        }
+    }
+
+    /// Process the completion request
+    ///
+    /// **Warning:** `stdout` should not be written to before or after this has run.
+    ///
+    /// Returns `true` if a command was completed and `false` if this is a regular run of your
+    /// application
+    pub fn try_complete(
+        self,
+        args: impl IntoIterator<Item = impl Into<OsString>>,
+        current_dir: Option<&std::path::Path>,
+    ) -> clap::error::Result<bool> {
+        self.try_complete_(args.into_iter().map(|a| a.into()).collect(), current_dir)
+    }
+
+    fn try_complete_(
+        self,
+        mut args: Vec<OsString>,
+        current_dir: Option<&std::path::Path>,
+    ) -> clap::error::Result<bool> {
+        let Some(name) = std::env::var_os(self.var) else {
+            return Ok(false);
+        };
+
+        // Ensure any child processes called for custom completers don't activate their own
+        // completion logic.
+        std::env::remove_var(self.var);
+
+        // Strip off the parent dir in case `$SHELL` was used
+        let name = std::path::Path::new(&name).file_stem().unwrap_or(&name);
+        // lossy won't match but this will delegate to unknown
+        // error
+        let name = name.to_string_lossy();
+
+        let shell = self.shells.completer(&name).ok_or_else(|| {
+            let shells = self
+                .shells
+                .names()
+                .enumerate()
+                .map(|(i, name)| {
+                    let prefix = if i == 0 { "" } else { ", " };
+                    format!("{prefix}`{name}`")
+                })
+                .collect::<String>();
+            std::io::Error::new(
+                std::io::ErrorKind::Other,
+                format!("unknown shell `{name}`, expected one of {shells}"),
+            )
+        })?;
+
+        let mut cmd = (self.factory)();
+        cmd.build();
+
+        let escape_index = args
+            .iter()
+            .position(|a| *a == "--")
+            .map(|i| i + 1)
+            .unwrap_or(args.len());
+        args.drain(0..escape_index);
+        if args.is_empty() {
+            let name = cmd.get_name();
+            let bin = cmd.get_bin_name().unwrap_or_else(|| cmd.get_name());
+
+            let mut buf = Vec::new();
+            shell.write_registration(self.var, name, bin, bin, &mut buf)?;
+            std::io::stdout().write_all(&buf)?;
+        } else {
+            let mut buf = Vec::new();
+            shell.write_complete(&mut cmd, args, current_dir, &mut buf)?;
+            std::io::stdout().write_all(&buf)?;
+        }
+
+        Ok(true)
+    }
+}
+
+/// Collection of shell-specific completers
+pub struct Shells<'s>(pub &'s [&'s dyn EnvCompleter]);
+
+impl<'s> Shells<'s> {
+    /// Select all of the built-in shells
+    pub fn builtins() -> Self {
+        Self(&[&Bash, &Elvish, &Fish, &Powershell, &Zsh])
+    }
+
+    /// Find the specified [`EnvCompleter`]
+    pub fn completer(&self, name: &str) -> Option<&dyn EnvCompleter> {
+        self.0.iter().copied().find(|c| c.is(name))
+    }
+
+    /// Collect all [`EnvCompleter::name`]s
+    pub fn names(&self) -> impl Iterator<Item = &'static str> + 's {
+        self.0.iter().map(|c| c.name())
+    }
+}
+
+/// Shell-integration for completions
+///
+/// This will generally be called by [`CompleteEnv`].
+///
+/// This handles adapting between the shell and [`completer`][crate::dynamic::complete()].
+/// A `EnvCompleter` can choose how much of that lives within the registration script or
+/// lives in [`EnvCompleter::write_complete`].
+pub trait EnvCompleter {
+    /// Canonical name for this shell
+    ///
+    /// **Post-conditions:**
+    /// ```rust,ignore
+    /// assert!(completer.is(completer.name()));
+    /// ```
+    fn name(&self) -> &'static str;
+    /// Whether the name matches this shell
+    ///
+    /// This should match [`EnvCompleter::name`] and any alternative names, particularly used by
+    /// `$SHELL`.
+    fn is(&self, name: &str) -> bool;
+    /// Register for completions
+    ///
+    /// Write the `buf` the logic needed for calling into `<VAR>=<shell> <cmd> --`, passing needed
+    /// arguments to [`EnvCompleter::write_complete`] through the environment.
+    fn write_registration(
+        &self,
+        var: &str,
+        name: &str,
+        bin: &str,
+        completer: &str,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error>;
+    /// Complete the given command
+    ///
+    /// Adapt information from arguments and [`EnvCompleter::write_registration`]-defined env
+    /// variables to what is needed for [`completer`][crate::dynamic::complete()].
+    ///
+    /// Write out the [`CompletionCandidate`][crate::dynamic::CompletionCandidate]s in a way the shell will understand.
+    fn write_complete(
+        &self,
+        cmd: &mut clap::Command,
+        args: Vec<OsString>,
+        current_dir: Option<&std::path::Path>,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error>;
+}
diff --git a/clap_complete/src/dynamic/env/shells.rs b/clap_complete/src/dynamic/env/shells.rs
new file mode 100644
index 00000000000..46a3ee376c2
--- /dev/null
+++ b/clap_complete/src/dynamic/env/shells.rs
@@ -0,0 +1,397 @@
+use std::ffi::OsString;
+use std::str::FromStr;
+
+use super::EnvCompleter;
+
+/// Bash completion adapter
+#[derive(Copy, Clone, PartialEq, Eq, Debug)]
+pub struct Bash;
+
+impl EnvCompleter for Bash {
+    fn name(&self) -> &'static str {
+        "bash"
+    }
+    fn is(&self, name: &str) -> bool {
+        name == "bash"
+    }
+    fn write_registration(
+        &self,
+        var: &str,
+        name: &str,
+        bin: &str,
+        completer: &str,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error> {
+        let escaped_name = name.replace('-', "_");
+        let mut upper_name = escaped_name.clone();
+        upper_name.make_ascii_uppercase();
+
+        let completer =
+            shlex::try_quote(completer).unwrap_or(std::borrow::Cow::Borrowed(completer));
+
+        let script = r#"
+_clap_complete_NAME() {
+    export IFS=$'\013'
+    export _CLAP_COMPLETE_INDEX=${COMP_CWORD}
+    export _CLAP_COMPLETE_COMP_TYPE=${COMP_TYPE}
+    if compopt +o nospace 2> /dev/null; then
+        export _CLAP_COMPLETE_SPACE=false
+    else
+        export _CLAP_COMPLETE_SPACE=true
+    fi
+    export VAR="bash"
+    COMPREPLY=( $("COMPLETER" -- "${COMP_WORDS[@]}") )
+    if [[ $? != 0 ]]; then
+        unset COMPREPLY
+    elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then
+        compopt -o nospace
+    fi
+}
+if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then
+    complete -o nospace -o bashdefault -o nosort -F _clap_complete_NAME BIN
+else
+    complete -o nospace -o bashdefault -F _clap_complete_NAME BIN
+fi
+"#
+        .replace("NAME", &escaped_name)
+        .replace("BIN", bin)
+        .replace("COMPLETER", &completer)
+        .replace("UPPER", &upper_name)
+        .replace("VAR", var);
+
+        writeln!(buf, "{script}")?;
+        Ok(())
+    }
+    fn write_complete(
+        &self,
+        cmd: &mut clap::Command,
+        args: Vec<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 _comp_type: CompType = std::env::var("_CLAP_COMPLETE_COMP_TYPE")
+            .ok()
+            .and_then(|i| i.parse().ok())
+            .unwrap_or_default();
+        let _space: Option<bool> = std::env::var("_CLAP_COMPLETE_SPACE")
+            .ok()
+            .and_then(|i| i.parse().ok());
+        let ifs: Option<String> = std::env::var("IFS").ok().and_then(|i| i.parse().ok());
+        let completions = crate::dynamic::complete(cmd, args, index, current_dir)?;
+
+        for (i, candidate) in completions.iter().enumerate() {
+            if i != 0 {
+                write!(buf, "{}", ifs.as_deref().unwrap_or("\n"))?;
+            }
+            write!(buf, "{}", candidate.get_content().to_string_lossy())?;
+        }
+        Ok(())
+    }
+}
+
+/// Type of completion attempted that caused a completion function to be called
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+#[non_exhaustive]
+enum CompType {
+    /// Normal completion
+    Normal,
+    /// List completions after successive tabs
+    Successive,
+    /// List alternatives on partial word completion
+    Alternatives,
+    /// List completions if the word is not unmodified
+    Unmodified,
+    /// Menu completion
+    Menu,
+}
+
+impl FromStr for CompType {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "9" => Ok(Self::Normal),
+            "63" => Ok(Self::Successive),
+            "33" => Ok(Self::Alternatives),
+            "64" => Ok(Self::Unmodified),
+            "37" => Ok(Self::Menu),
+            _ => Err(format!("unsupported COMP_TYPE `{}`", s)),
+        }
+    }
+}
+
+impl Default for CompType {
+    fn default() -> Self {
+        Self::Normal
+    }
+}
+
+/// Elvish completion adapter
+#[derive(Copy, Clone, PartialEq, Eq, Debug)]
+pub struct Elvish;
+
+impl EnvCompleter for Elvish {
+    fn name(&self) -> &'static str {
+        "elvish"
+    }
+    fn is(&self, name: &str) -> bool {
+        name == "elvish"
+    }
+    fn write_registration(
+        &self,
+        var: &str,
+        _name: &str,
+        bin: &str,
+        completer: &str,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error> {
+        let bin = shlex::try_quote(bin).unwrap_or(std::borrow::Cow::Borrowed(bin));
+        let completer =
+            shlex::try_quote(completer).unwrap_or(std::borrow::Cow::Borrowed(completer));
+
+        let script = r#"
+set edit:completion:arg-completer[BIN] = { |@words|
+    set E:_CLAP_IFS = "\n"
+
+    var index = (count $words)
+    set index = (- $index 1)
+    set E:_CLAP_COMPLETE_INDEX = (to-string $index)
+    set E:VAR = "elvish"
+
+    put (COMPLETER -- $@words) | to-lines
+}
+"#
+        .replace("COMPLETER", &completer)
+        .replace("BIN", &bin)
+        .replace("VAR", var);
+
+        writeln!(buf, "{script}")?;
+        Ok(())
+    }
+    fn write_complete(
+        &self,
+        cmd: &mut clap::Command,
+        args: Vec<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());
+        let completions = crate::dynamic::complete(cmd, args, index, current_dir)?;
+
+        for (i, candidate) in completions.iter().enumerate() {
+            if i != 0 {
+                write!(buf, "{}", ifs.as_deref().unwrap_or("\n"))?;
+            }
+            write!(buf, "{}", candidate.get_content().to_string_lossy())?;
+        }
+        Ok(())
+    }
+}
+
+/// Fish completion adapter
+#[derive(Copy, Clone, PartialEq, Eq, Debug)]
+pub struct Fish;
+
+impl EnvCompleter for Fish {
+    fn name(&self) -> &'static str {
+        "fish"
+    }
+    fn is(&self, name: &str) -> bool {
+        name == "fish"
+    }
+    fn write_registration(
+        &self,
+        var: &str,
+        _name: &str,
+        bin: &str,
+        completer: &str,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error> {
+        let bin = shlex::try_quote(bin).unwrap_or(std::borrow::Cow::Borrowed(bin));
+        let completer =
+            shlex::try_quote(completer).unwrap_or(std::borrow::Cow::Borrowed(completer));
+
+        writeln!(
+            buf,
+            r#"complete -x -c {bin} -a "({var}=fish "'{completer}'" -- (commandline --current-process --tokenize --cut-at-cursor) (commandline --current-token))""#
+        )
+    }
+    fn write_complete(
+        &self,
+        cmd: &mut clap::Command,
+        args: Vec<OsString>,
+        current_dir: Option<&std::path::Path>,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error> {
+        let index = args.len() - 1;
+        let completions = crate::dynamic::complete(cmd, args, index, current_dir)?;
+
+        for candidate in completions {
+            write!(buf, "{}", candidate.get_content().to_string_lossy())?;
+            if let Some(help) = candidate.get_help() {
+                write!(
+                    buf,
+                    "\t{}",
+                    help.to_string().lines().next().unwrap_or_default()
+                )?;
+            }
+            writeln!(buf)?;
+        }
+        Ok(())
+    }
+}
+
+/// Powershell completion adapter
+#[derive(Copy, Clone, PartialEq, Eq, Debug)]
+pub struct Powershell;
+
+impl EnvCompleter for Powershell {
+    fn name(&self) -> &'static str {
+        "powershell"
+    }
+    fn is(&self, name: &str) -> bool {
+        name == "powershell" || name == "powershell_ise"
+    }
+    fn write_registration(
+        &self,
+        var: &str,
+        _name: &str,
+        bin: &str,
+        completer: &str,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error> {
+        let bin = shlex::try_quote(bin).unwrap_or(std::borrow::Cow::Borrowed(bin));
+        let completer =
+            shlex::try_quote(completer).unwrap_or(std::borrow::Cow::Borrowed(completer));
+
+        writeln!(
+            buf,
+            r#"
+Register-ArgumentCompleter -Native -CommandName {bin} -ScriptBlock {{
+    param($wordToComplete, $commandAst, $cursorPosition)
+
+    $results = Invoke-Expression "{var}=powershell &{completer} -- $($commandAst.ToString())";
+    $results | ForEach-Object {{
+        $split = $_.Split("`t");
+        $cmd = $split[0];
+
+        if ($split.Length -eq 2) {{
+            $help = $split[1];
+        }}
+        else {{
+            $help = $split[0];
+        }}
+
+        [System.Management.Automation.CompletionResult]::new($cmd, $cmd, 'ParameterValue', $help)
+    }}
+}};
+        "#
+        )
+    }
+
+    fn write_complete(
+        &self,
+        cmd: &mut clap::Command,
+        args: Vec<OsString>,
+        current_dir: Option<&std::path::Path>,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error> {
+        let index = args.len() - 1;
+        let completions = crate::dynamic::complete(cmd, args, index, current_dir)?;
+
+        for candidate in completions {
+            write!(buf, "{}", candidate.get_content().to_string_lossy())?;
+            if let Some(help) = candidate.get_help() {
+                write!(
+                    buf,
+                    "\t{}",
+                    help.to_string().lines().next().unwrap_or_default()
+                )?;
+            }
+            writeln!(buf)?;
+        }
+        Ok(())
+    }
+}
+
+/// Zsh completion adapter
+#[derive(Copy, Clone, PartialEq, Eq, Debug)]
+pub struct Zsh;
+
+impl EnvCompleter for Zsh {
+    fn name(&self) -> &'static str {
+        "zsh"
+    }
+    fn is(&self, name: &str) -> bool {
+        name == "zsh"
+    }
+    fn write_registration(
+        &self,
+        var: &str,
+        _name: &str,
+        bin: &str,
+        completer: &str,
+        buf: &mut dyn std::io::Write,
+    ) -> Result<(), std::io::Error> {
+        let bin = shlex::try_quote(bin).unwrap_or(std::borrow::Cow::Borrowed(bin));
+        let completer =
+            shlex::try_quote(completer).unwrap_or(std::borrow::Cow::Borrowed(completer));
+
+        let script = r#"#compdef BIN
+function _clap_dynamic_completer() {
+    export _CLAP_COMPLETE_INDEX=$(expr $CURRENT - 1)
+    export _CLAP_IFS=$'\n'
+    export VAR="zsh"
+
+    local completions=("${(@f)$(COMPLETER -- ${words} 2>/dev/null)}")
+
+    if [[ -n $completions ]]; then
+        compadd -a completions
+    fi
+}
+
+compdef _clap_dynamic_completer BIN"#
+            .replace("COMPLETER", &completer)
+            .replace("BIN", &bin)
+            .replace("VAR", var);
+
+        writeln!(buf, "{script}")?;
+        Ok(())
+    }
+    fn write_complete(
+        &self,
+        cmd: &mut clap::Command,
+        args: Vec<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, candidate) in completions.iter().enumerate() {
+            if i != 0 {
+                write!(buf, "{}", ifs.as_deref().unwrap_or("\n"))?;
+            }
+            write!(buf, "{}", candidate.get_content().to_string_lossy())?;
+        }
+        Ok(())
+    }
+}
diff --git a/clap_complete/src/dynamic/mod.rs b/clap_complete/src/dynamic/mod.rs
index 6adc79318fb..118b7a266ac 100644
--- a/clap_complete/src/dynamic/mod.rs
+++ b/clap_complete/src/dynamic/mod.rs
@@ -11,6 +11,7 @@ mod custom;
 
 #[cfg(feature = "unstable-command")]
 pub mod command;
+pub mod env;
 
 pub use candidate::CompletionCandidate;
 pub use complete::complete;
@@ -21,3 +22,4 @@ pub use custom::CustomCompleter;
 pub use command::CompleteArgs;
 #[cfg(feature = "unstable-command")]
 pub use command::CompleteCommand;
+pub use env::CompleteEnv;


Test patch:
diff --git a/clap_complete/tests/snapshots/home/dynamic/exhaustive/bash/.bashrc b/clap_complete/tests/snapshots/home/dynamic-command/exhaustive/bash/.bashrc
similarity index 100%
rename from clap_complete/tests/snapshots/home/dynamic/exhaustive/bash/.bashrc
rename to clap_complete/tests/snapshots/home/dynamic-command/exhaustive/bash/.bashrc
diff --git a/clap_complete/tests/snapshots/home/dynamic/exhaustive/elvish/elvish/rc.elv b/clap_complete/tests/snapshots/home/dynamic-command/exhaustive/elvish/elvish/rc.elv
similarity index 100%
rename from clap_complete/tests/snapshots/home/dynamic/exhaustive/elvish/elvish/rc.elv
rename to clap_complete/tests/snapshots/home/dynamic-command/exhaustive/elvish/elvish/rc.elv
diff --git a/clap_complete/tests/snapshots/home/dynamic/exhaustive/fish/fish/completions/exhaustive.fish b/clap_complete/tests/snapshots/home/dynamic-command/exhaustive/fish/fish/completions/exhaustive.fish
similarity index 100%
rename from clap_complete/tests/snapshots/home/dynamic/exhaustive/fish/fish/completions/exhaustive.fish
rename to clap_complete/tests/snapshots/home/dynamic-command/exhaustive/fish/fish/completions/exhaustive.fish
diff --git a/clap_complete/tests/snapshots/home/dynamic/exhaustive/fish/fish/config.fish b/clap_complete/tests/snapshots/home/dynamic-command/exhaustive/fish/fish/config.fish
similarity index 100%
rename from clap_complete/tests/snapshots/home/dynamic/exhaustive/fish/fish/config.fish
rename to clap_complete/tests/snapshots/home/dynamic-command/exhaustive/fish/fish/config.fish
diff --git a/clap_complete/tests/snapshots/home/dynamic/exhaustive/zsh/.zshenv b/clap_complete/tests/snapshots/home/dynamic-command/exhaustive/zsh/.zshenv
similarity index 100%
rename from clap_complete/tests/snapshots/home/dynamic/exhaustive/zsh/.zshenv
rename to clap_complete/tests/snapshots/home/dynamic-command/exhaustive/zsh/.zshenv
diff --git a/clap_complete/tests/snapshots/home/dynamic/exhaustive/zsh/zsh/_exhaustive b/clap_complete/tests/snapshots/home/dynamic-command/exhaustive/zsh/zsh/_exhaustive
similarity index 100%
rename from clap_complete/tests/snapshots/home/dynamic/exhaustive/zsh/zsh/_exhaustive
rename to clap_complete/tests/snapshots/home/dynamic-command/exhaustive/zsh/zsh/_exhaustive
diff --git a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/bash/.bashrc b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/bash/.bashrc
new file mode 100644
index 00000000000..4384e8d1758
--- /dev/null
+++ b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/bash/.bashrc
@@ -0,0 +1,27 @@
+PS1='% '
+. /etc/bash_completion
+
+_clap_complete_exhaustive() {
+    export IFS=$'\013'
+    export _CLAP_COMPLETE_INDEX=${COMP_CWORD}
+    export _CLAP_COMPLETE_COMP_TYPE=${COMP_TYPE}
+    if compopt +o nospace 2> /dev/null; then
+        export _CLAP_COMPLETE_SPACE=false
+    else
+        export _CLAP_COMPLETE_SPACE=true
+    fi
+    export COMPLETE="bash"
+    COMPREPLY=( $("exhaustive" -- "${COMP_WORDS[@]}") )
+    if [[ $? != 0 ]]; then
+        unset COMPREPLY
+    elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then
+        compopt -o nospace
+    fi
+}
+if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then
+    complete -o nospace -o bashdefault -o nosort -F _clap_complete_exhaustive exhaustive
+else
+    complete -o nospace -o bashdefault -F _clap_complete_exhaustive exhaustive
+fi
+
+
diff --git a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/elvish/elvish/rc.elv b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/elvish/elvish/rc.elv
new file mode 100644
index 00000000000..5694024cb22
--- /dev/null
+++ b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/elvish/elvish/rc.elv
@@ -0,0 +1,15 @@
+set edit:rprompt = (constantly "")
+set edit:prompt = (constantly "% ")
+
+set edit:completion:arg-completer[exhaustive] = { |@words|
+    set E:_CLAP_IFS = "\n"
+
+    var index = (count $words)
+    set index = (- $index 1)
+    set E:_CLAP_COMPLETE_INDEX = (to-string $index)
+    set E:COMPLETE = "elvish"
+
+    put (exhaustive -- $@words) | to-lines
+}
+
+
diff --git a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/fish/fish/completions/exhaustive.fish b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/fish/fish/completions/exhaustive.fish
new file mode 100644
index 00000000000..0aae8215946
--- /dev/null
+++ b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/fish/fish/completions/exhaustive.fish
@@ -0,0 +1,1 @@
+complete -x -c exhaustive -a "(COMPLETE=fish "'exhaustive'" -- (commandline --current-process --tokenize --cut-at-cursor) (commandline --current-token))"
diff --git a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/fish/fish/config.fish b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/fish/fish/config.fish
new file mode 100644
index 00000000000..74bd2f00021
--- /dev/null
+++ b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/fish/fish/config.fish
@@ -0,0 +1,7 @@
+set -U fish_greeting ""
+set -U fish_autosuggestion_enabled 0
+function fish_title
+end
+function fish_prompt
+    printf '%% '
+end;
diff --git a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/zsh/.zshenv b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/zsh/.zshenv
new file mode 100644
index 00000000000..6d309f24b41
--- /dev/null
+++ b/clap_complete/tests/snapshots/home/dynamic-env/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-env/exhaustive/zsh/zsh/_exhaustive b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/zsh/zsh/_exhaustive
new file mode 100644
index 00000000000..6c5bd28ab50
--- /dev/null
+++ b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/zsh/zsh/_exhaustive
@@ -0,0 +1,14 @@
+#compdef exhaustive
+function _clap_dynamic_completer() {
+    export _CLAP_COMPLETE_INDEX=$(expr $CURRENT - 1)
+    export _CLAP_IFS=$'\n'
+    export COMPLETE="zsh"
+
+    local completions=("${(@f)$(exhaustive -- ${words} 2>/dev/null)}")
+
+    if [[ -n $completions ]]; then
+        compadd -a completions
+    fi
+}
+
+compdef _clap_dynamic_completer exhaustive
diff --git a/clap_complete/tests/testsuite/bash.rs b/clap_complete/tests/testsuite/bash.rs
index d39debbc4cb..91001a75734 100644
--- a/clap_complete/tests/testsuite/bash.rs
+++ b/clap_complete/tests/testsuite/bash.rs
@@ -248,22 +248,60 @@ fn complete() {
     assert_data_eq!(actual, expected);
 }
 
+#[test]
+#[cfg(all(unix, feature = "unstable-dynamic"))]
+fn register_dynamic_env() {
+    common::register_example::<completest_pty::BashRuntimeBuilder>("dynamic-env", "exhaustive");
+}
+
+#[test]
+#[cfg(all(unix, feature = "unstable-dynamic"))]
+fn complete_dynamic_env() {
+    if !common::has_command("bash") {
+        return;
+    }
+
+    let term = completest::Term::new();
+    let mut runtime =
+        common::load_runtime::<completest_pty::BashRuntimeBuilder>("dynamic-env", "exhaustive");
+
+    let input = "exhaustive \t\t";
+    let expected = snapbox::str![[r#"
+% 
+--global    --help      -h          action      help        last        quote       
+--generate  --version   -V          alias       hint        pacman      value       
+"#]];
+    let actual = runtime.complete(input, &term).unwrap();
+    assert_data_eq!(actual, expected);
+
+    let input = "exhaustive quote \t\t";
+    let expected = snapbox::str![[r#"
+% 
+--single-quotes    --brackets         --help             cmd-backslash      cmd-expansions     
+--double-quotes    --expansions       --version          cmd-backticks      cmd-single-quotes  
+--backticks        --choice           -h                 cmd-brackets       escape-help        
+--backslash        --global           -V                 cmd-double-quotes  help               
+"#]];
+    let actual = runtime.complete(input, &term).unwrap();
+    assert_data_eq!(actual, expected);
+}
+
 #[test]
 #[cfg(all(unix, feature = "unstable-command"))]
-fn register_dynamic_completion() {
-    common::register_example::<completest_pty::BashRuntimeBuilder>("dynamic", "exhaustive");
+fn register_dynamic_command() {
+    common::register_example::<completest_pty::BashRuntimeBuilder>("dynamic-command", "exhaustive");
 }
 
 #[test]
 #[cfg(all(unix, feature = "unstable-command"))]
-fn complete_dynamic() {
+fn complete_dynamic_command() {
     if !common::has_command("bash") {
         return;
     }
 
     let term = completest::Term::new();
     let mut runtime =
-        common::load_runtime::<completest_pty::BashRuntimeBuilder>("dynamic", "exhaustive");
+        common::load_runtime::<completest_pty::BashRuntimeBuilder>("dynamic-command", "exhaustive");
 
     let input = "exhaustive \t\t";
     let expected = snapbox::str![[r#"
diff --git a/clap_complete/tests/testsuite/common.rs b/clap_complete/tests/testsuite/common.rs
index 7ebfea6837a..1c6290f8378 100644
--- a/clap_complete/tests/testsuite/common.rs
+++ b/clap_complete/tests/testsuite/common.rs
@@ -331,7 +331,8 @@ pub(crate) fn register_example<R: completest::RuntimeBuilder>(context: &str, nam
     let mut registration = std::process::Command::new(&bin_path);
     match context {
         "static" => registration.args([format!("--generate={shell_name}")]),
-        "dynamic" => registration.args(["complete", shell_name]),
+        "dynamic-command" => registration.args(["complete", shell_name]),
+        "dynamic-env" => registration.env("COMPLETE", shell_name),
         _ => unreachable!("unsupported context {}", context),
     };
     let registration = registration.output().unwrap();
diff --git a/clap_complete/tests/testsuite/elvish.rs b/clap_complete/tests/testsuite/elvish.rs
index b953fd8bcfa..137c2662bf3 100644
--- a/clap_complete/tests/testsuite/elvish.rs
+++ b/clap_complete/tests/testsuite/elvish.rs
@@ -175,22 +175,67 @@ value          value
     assert_data_eq!(actual, expected);
 }
 
+#[test]
+#[cfg(all(unix, feature = "unstable-dynamic"))]
+fn register_dynamic_env() {
+    common::register_example::<completest_pty::ElvishRuntimeBuilder>("dynamic-env", "exhaustive");
+}
+
+#[test]
+#[cfg(all(unix, feature = "unstable-dynamic"))]
+fn complete_dynamic_env() {
+    if !common::has_command("elvish") {
+        return;
+    }
+
+    let term = completest::Term::new();
+    let mut runtime =
+        common::load_runtime::<completest_pty::ElvishRuntimeBuilder>("dynamic-env", "exhaustive");
+
+    let input = "exhaustive \t";
+    let expected = snapbox::str![[r#"
+% exhaustive --generate
+ COMPLETING argument  
+--generate  --help     -V  action  help  last    quote
+--global    --version  -h  alias   hint  pacman  value
+"#]];
+    let actual = runtime.complete(input, &term).unwrap();
+    assert_data_eq!(actual, expected);
+
+    let input = "exhaustive quote \t";
+    let expected = snapbox::str![[r#"
+% exhaustive quote --backslash
+ COMPLETING argument  
+--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);
+}
+
 #[test]
 #[cfg(all(unix, feature = "unstable-command"))]
-fn register_dynamic() {
-    common::register_example::<completest_pty::ElvishRuntimeBuilder>("dynamic", "exhaustive");
+fn register_dynamic_command() {
+    common::register_example::<completest_pty::ElvishRuntimeBuilder>(
+        "dynamic-command",
+        "exhaustive",
+    );
 }
 
 #[test]
 #[cfg(all(unix, feature = "unstable-command"))]
-fn complete_dynamic() {
+fn complete_dynamic_command() {
     if !common::has_command("elvish") {
         return;
     }
 
     let term = completest::Term::new();
-    let mut runtime =
-        common::load_runtime::<completest_pty::ElvishRuntimeBuilder>("dynamic", "exhaustive");
+    let mut runtime = common::load_runtime::<completest_pty::ElvishRuntimeBuilder>(
+        "dynamic-command",
+        "exhaustive",
+    );
 
     let input = "exhaustive \t";
     let expected = snapbox::str![[r#"
diff --git a/clap_complete/tests/testsuite/fish.rs b/clap_complete/tests/testsuite/fish.rs
index a4eaddab126..d50d587895c 100644
--- a/clap_complete/tests/testsuite/fish.rs
+++ b/clap_complete/tests/testsuite/fish.rs
@@ -165,22 +165,79 @@ bash  (bash (shell))  fish  (fish shell)  zsh  (zsh shell)"#;
     assert_data_eq!(actual, expected);
 }
 
+#[test]
+#[cfg(all(unix, feature = "unstable-dynamic"))]
+fn register_dynamic_env() {
+    common::register_example::<completest_pty::FishRuntimeBuilder>("dynamic-env", "exhaustive");
+}
+
+#[test]
+#[cfg(all(unix, feature = "unstable-dynamic"))]
+fn complete_dynamic_env() {
+    if !common::has_command("fish") {
+        return;
+    }
+
+    let term = completest::Term::new();
+    let mut runtime =
+        common::load_runtime::<completest_pty::FishRuntimeBuilder>("dynamic-env", "exhaustive");
+
+    let input = "exhaustive \t\t";
+    let expected = snapbox::str![[r#"
+% exhaustive action 
+action                                                             pacman               --generate      (generate)
+alias                                                              quote                --global      (everywhere)
+help  (Print this message or the help of the given subcommand(s))  value                --help        (Print help)
+hint                                                               -h     (Print help)  --version  (Print version)
+last                                                               -V  (Print version)  
+"#]];
+    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 
+cmd-backslash                                        (Avoid '/n')
+cmd-backticks              (For more information see `echo test`)
+cmd-brackets                             (List packages [filter])
+cmd-double-quotes           (Can be "always", "auto", or "never")
+cmd-expansions            (Execute the shell command with $SHELL)
+cmd-single-quotes           (Can be 'always', 'auto', or 'never')
+escape-help                                             (/tab "')
+help  (Print this message or the help of the given subcommand(s))
+-h                          (Print help (see more with '--help'))
+-V                                                (Print version)
+--backslash                                          (Avoid '/n')
+--backticks                (For more information see `echo test`)
+--brackets                               (List packages [filter])
+--choice                                                         
+--double-quotes             (Can be "always", "auto", or "never")
+--expansions              (Execute the shell command with $SHELL)
+--global                                             (everywhere)
+--help                      (Print help (see more with '--help'))
+--single-quotes             (Can be 'always', 'auto', or 'never')
+--version                                         (Print version)
+"#]];
+    let actual = runtime.complete(input, &term).unwrap();
+    assert_data_eq!(actual, expected);
+}
+
 #[test]
 #[cfg(all(unix, feature = "unstable-command"))]
-fn register_dynamic() {
-    common::register_example::<completest_pty::FishRuntimeBuilder>("dynamic", "exhaustive");
+fn register_dynamic_command() {
+    common::register_example::<completest_pty::FishRuntimeBuilder>("dynamic-command", "exhaustive");
 }
 
 #[test]
 #[cfg(all(unix, feature = "unstable-command"))]
-fn complete_dynamic() {
+fn complete_dynamic_command() {
     if !common::has_command("fish") {
         return;
     }
 
     let term = completest::Term::new();
     let mut runtime =
-        common::load_runtime::<completest_pty::FishRuntimeBuilder>("dynamic", "exhaustive");
+        common::load_runtime::<completest_pty::FishRuntimeBuilder>("dynamic-command", "exhaustive");
 
     let input = "exhaustive \t\t";
     let expected = snapbox::str![[r#"
diff --git a/clap_complete/tests/testsuite/zsh.rs b/clap_complete/tests/testsuite/zsh.rs
index d0b2f503ef0..02e662b2bd8 100644
--- a/clap_complete/tests/testsuite/zsh.rs
+++ b/clap_complete/tests/testsuite/zsh.rs
@@ -163,22 +163,60 @@ pacman    action  alias  value  quote  hint  last  --
     assert_data_eq!(actual, expected);
 }
 
+#[test]
+#[cfg(all(unix, feature = "unstable-dynamic"))]
+fn register_dynamic_env() {
+    common::register_example::<completest_pty::ZshRuntimeBuilder>("dynamic-env", "exhaustive");
+}
+
+#[test]
+#[cfg(all(unix, feature = "unstable-dynamic"))]
+fn complete_dynamic_env() {
+    if !common::has_command("zsh") {
+        return;
+    }
+
+    let term = completest::Term::new();
+    let mut runtime =
+        common::load_runtime::<completest_pty::ZshRuntimeBuilder>("dynamic-env", "exhaustive");
+
+    let input = "exhaustive \t\t";
+    let expected = snapbox::str![[r#"
+% exhaustive
+--generate  --help      -V          action      help        last        quote       
+--global    --version   -h          alias       hint        pacman      value       
+"#]];
+    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);
+}
+
 #[test]
 #[cfg(all(unix, feature = "unstable-command"))]
-fn register_dynamic() {
-    common::register_example::<completest_pty::ZshRuntimeBuilder>("dynamic", "exhaustive");
+fn register_dynamic_command() {
+    common::register_example::<completest_pty::ZshRuntimeBuilder>("dynamic-command", "exhaustive");
 }
 
 #[test]
 #[cfg(all(unix, feature = "unstable-command"))]
-fn complete_dynamic() {
+fn complete_dynamic_command() {
     if !common::has_command("zsh") {
         return;
     }
 
     let term = completest::Term::new();
     let mut runtime =
-        common::load_runtime::<completest_pty::ZshRuntimeBuilder>("dynamic", "exhaustive");
+        common::load_runtime::<completest_pty::ZshRuntimeBuilder>("dynamic-command", "exhaustive");
 
     let input = "exhaustive \t\t";
     let expected = snapbox::str![[r#"