From c402ec61ee6ebd1fdee8da419247c20598151f34 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Mon, 12 Aug 2024 10:53:24 -0500 Subject: [PATCH] feat(complete): Env hook for dynamic completions Fixes #3930 --- clap_complete/Cargo.toml | 4 +- clap_complete/examples/dynamic.rs | 2 + clap_complete/examples/exhaustive.rs | 3 + clap_complete/src/dynamic/env/mod.rs | 295 +++++++++++++ clap_complete/src/dynamic/env/shells.rs | 397 ++++++++++++++++++ clap_complete/src/dynamic/mod.rs | 2 + .../home/dynamic-env/exhaustive/bash/.bashrc | 27 ++ .../exhaustive/elvish/elvish/rc.elv | 15 + .../fish/fish/completions/exhaustive.fish | 1 + .../exhaustive/fish/fish/config.fish | 7 + .../home/dynamic-env/exhaustive/zsh/.zshenv | 5 + .../exhaustive/zsh/zsh/_exhaustive | 14 + clap_complete/tests/testsuite/bash.rs | 38 ++ clap_complete/tests/testsuite/common.rs | 1 + clap_complete/tests/testsuite/elvish.rs | 40 ++ clap_complete/tests/testsuite/fish.rs | 57 +++ clap_complete/tests/testsuite/zsh.rs | 38 ++ 17 files changed, 944 insertions(+), 2 deletions(-) create mode 100644 clap_complete/src/dynamic/env/mod.rs create mode 100644 clap_complete/src/dynamic/env/shells.rs create mode 100644 clap_complete/tests/snapshots/home/dynamic-env/exhaustive/bash/.bashrc create mode 100644 clap_complete/tests/snapshots/home/dynamic-env/exhaustive/elvish/elvish/rc.elv create mode 100644 clap_complete/tests/snapshots/home/dynamic-env/exhaustive/fish/fish/completions/exhaustive.fish create mode 100644 clap_complete/tests/snapshots/home/dynamic-env/exhaustive/fish/fish/config.fish create mode 100644 clap_complete/tests/snapshots/home/dynamic-env/exhaustive/zsh/.zshenv create mode 100644 clap_complete/tests/snapshots/home/dynamic-env/exhaustive/zsh/zsh/_exhaustive 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::("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 `][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, + /// } + /// + /// 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>, + current_dir: Option<&std::path::Path>, + ) -> clap::error::Result { + self.try_complete_(args.into_iter().map(|a| a.into()).collect(), current_dir) + } + + fn try_complete_( + self, + mut args: Vec, + current_dir: Option<&std::path::Path>, + ) -> clap::error::Result { + 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::(); + 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 + '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 `= --`, 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, + 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, + 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 = std::env::var("_CLAP_COMPLETE_SPACE") + .ok() + .and_then(|i| i.parse().ok()); + let ifs: Option = 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 { + 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, + 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 = 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, + 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, + 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, + 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 = 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; 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 @@ +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 16594dfa348..91001a75734 100644 --- a/clap_complete/tests/testsuite/bash.rs +++ b/clap_complete/tests/testsuite/bash.rs @@ -248,6 +248,44 @@ fn complete() { assert_data_eq!(actual, expected); } +#[test] +#[cfg(all(unix, feature = "unstable-dynamic"))] +fn register_dynamic_env() { + common::register_example::("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::("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_command() { diff --git a/clap_complete/tests/testsuite/common.rs b/clap_complete/tests/testsuite/common.rs index b495d919a01..1c6290f8378 100644 --- a/clap_complete/tests/testsuite/common.rs +++ b/clap_complete/tests/testsuite/common.rs @@ -332,6 +332,7 @@ pub(crate) fn register_example(context: &str, nam match context { "static" => registration.args([format!("--generate={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 4b1e877acdb..137c2662bf3 100644 --- a/clap_complete/tests/testsuite/elvish.rs +++ b/clap_complete/tests/testsuite/elvish.rs @@ -175,6 +175,46 @@ value value assert_data_eq!(actual, expected); } +#[test] +#[cfg(all(unix, feature = "unstable-dynamic"))] +fn register_dynamic_env() { + common::register_example::("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::("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_command() { diff --git a/clap_complete/tests/testsuite/fish.rs b/clap_complete/tests/testsuite/fish.rs index 705b2d4922a..d50d587895c 100644 --- a/clap_complete/tests/testsuite/fish.rs +++ b/clap_complete/tests/testsuite/fish.rs @@ -165,6 +165,63 @@ 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::("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::("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_command() { diff --git a/clap_complete/tests/testsuite/zsh.rs b/clap_complete/tests/testsuite/zsh.rs index 3a3fa2cf080..02e662b2bd8 100644 --- a/clap_complete/tests/testsuite/zsh.rs +++ b/clap_complete/tests/testsuite/zsh.rs @@ -163,6 +163,44 @@ 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::("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::("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_command() {