Skip to main content

ctl_core/
completion.rs

1//! Shell completion: one `completion <SHELL>` verb for a CLI.
2//!
3//! The verb prints a short registration script. The script does not hold the
4//! command grammar. At each TAB the shell runs the CLI again with [`VAR`] set
5//! to the shell's name, and [`App::completion`] prints the candidates before
6//! Clap parses anything. Candidates therefore come from the binary that is
7//! installed, including values a CLI computes at run time through
8//! `clap_complete`'s `ArgValueCompleter`.
9//!
10//! A CLI flattens [`CompletionCommand`] into its subcommands, turns the hook
11//! on, routes the verb to [`shell::view`], and returns the script. The example
12//! needs `clap` with its `derive` feature, as the
13//! [crate quick start](crate#quick-start) shows:
14//!
15//! ```no_run
16//! use ctl_core::completion::CompletionCommand;
17//! use ctl_core::prelude::*;
18//! use ctl_core::shell;
19//!
20//! #[derive(Parser)]
21//! struct Cli {
22//!     #[command(flatten)]
23//!     output: OutputArgs,
24//!     #[command(subcommand)]
25//!     command: Command,
26//! }
27//!
28//! #[derive(Subcommand)]
29//! enum Command {
30//!     #[command(flatten)]
31//!     Completion(CompletionCommand),
32//! }
33//!
34//! fn main() -> ExitCode {
35//!     App::<Cli>::new("toy")
36//!         .completion()
37//!         .view(|cli| match cli.command {
38//!             Command::Completion(_) => shell::view(cli.output.format),
39//!         })
40//!         .run(|cli| match cli.command {
41//!             Command::Completion(command) => Ok(command.script("toy")?),
42//!         })
43//! }
44//! ```
45//!
46//! A user loads the script at shell start, so it always matches the
47//! installed binary:
48//!
49//! | Shell | Startup file | Line |
50//! |:--|:--|:--|
51//! | bash | `~/.bashrc` | `source <(toy completion bash)` |
52//! | zsh | `~/.zshrc`, after `compinit` | `source <(toy completion zsh)` |
53//! | fish | `~/.config/fish/config.fish` | `toy completion fish \| source` |
54//!
55//! A Homebrew formula installs the same script at every install and upgrade
56//! with `generate_completions_from_executable(bin/"toy", "completion")`, or
57//! with `shell_parameter_format: :clap`, which sets [`VAR`] instead.
58//!
59//! [`App::completion`]: crate::app::App::completion
60//! [`shell::view`]: crate::shell::view
61
62use std::ffi::{OsStr, OsString};
63use std::io::{self, Write as _};
64use std::path::Path;
65use std::process::ExitCode;
66
67use clap::{CommandFactory, ValueEnum};
68use clap_complete::CompleteEnv;
69use clap_complete::env::{Bash, EnvCompleter, Fish, Shells, Zsh};
70
71use crate::formatdoc;
72use crate::shell::{Script, Shell};
73
74/// The environment variable that turns a run into a completion request.
75///
76/// It is `clap_complete`'s own default, which Homebrew's `:clap` format
77/// sets, so `COMPLETE=zsh toy` prints the same script as `toy completion
78/// zsh`. An empty value or `0` leaves the run alone. `clap_complete` removes
79/// the variable before it calls a value completer, so a child process never
80/// answers a request meant for its parent.
81pub const VAR: &str = "COMPLETE";
82
83/// The shells a TAB-time request may name: those [`Shell`] holds.
84const SHELLS: Shells<'static> = Shells(&[&Bash, &Zsh, &Fish]);
85
86impl Shell {
87    /// The registration script for `bin`, which the shell finds on `PATH`
88    /// at each TAB.
89    ///
90    /// The script calls `bin` by name, never by the path that produced it.
91    /// A script Homebrew writes from a versioned directory then keeps working
92    /// after the next upgrade.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error when the script `clap_complete` writes is not UTF-8,
97    /// which a `bin` that is a `&str` never causes.
98    pub fn script(self, bin: &str) -> io::Result<Script> {
99        let mut buffer = Vec::new();
100        self.completer()
101            .write_registration(VAR, bin, bin, bin, &mut buffer)?;
102        let mut text = String::from_utf8(buffer)
103            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
104        match self {
105            Self::Bash | Self::Fish => {}
106            Self::Zsh => text.push_str(&zsh_autoload(bin)),
107        }
108        Ok(Script { shell: self, text })
109    }
110
111    fn completer(self) -> &'static dyn EnvCompleter {
112        match self {
113            Self::Bash => &Bash,
114            Self::Zsh => &Zsh,
115            Self::Fish => &Fish,
116        }
117    }
118}
119
120/// Lines that make the zsh script complete on the first TAB when zsh
121/// autoloads it from `fpath`, where Homebrew installs it as `_<bin>`.
122///
123/// `clap_complete` 4.6.11 ends its zsh script with `compdef`, which binds the
124/// completer for the next TAB and lists nothing for this one. Autoloaded,
125/// the file runs as the function `_<bin>`, so these lines also complete now.
126/// Sourced, `funcstack[1]` is the file, and they do nothing. Drop them when
127/// a pinned `clap_complete` writes its own; the script test shows when.
128fn zsh_autoload(bin: &str) -> String {
129    formatdoc! {r#"
130        if [[ ${{funcstack[1]}} == _{bin} ]]; then
131            _clap_dynamic_completer_{name} "$@"
132        fi
133        "#,
134        name = bin.replace('-', "_"),
135    }
136}
137
138/// The `completion` verb, flattened into a CLI's subcommands with
139/// `#[command(flatten)]`, so its name, help, and argument read the same in
140/// every CLI built on ctl-core.
141#[derive(Debug, Clone, PartialEq, Eq, clap::Subcommand)]
142pub enum CompletionCommand {
143    /// Print the script that loads shell completion for this command.
144    Completion(CompletionArgs),
145}
146
147impl CompletionCommand {
148    /// The registration script the user asked for, for `bin`.
149    ///
150    /// # Errors
151    ///
152    /// Returns the error [`Shell::script`] returns.
153    pub fn script(&self, bin: &str) -> io::Result<Script> {
154        match self {
155            Self::Completion(args) => args.shell.script(bin),
156        }
157    }
158}
159
160/// The arguments of the `completion` verb.
161///
162/// An argument can join it in a minor release, so a caller reads its fields
163/// and never builds one with a struct literal.
164#[derive(Debug, Clone, PartialEq, Eq, clap::Args)]
165#[non_exhaustive]
166pub struct CompletionArgs {
167    /// Shell to load completion into: bash, zsh, or fish.
168    #[arg(value_enum)]
169    pub shell: Shell,
170}
171
172/// Answer a completion request for `C` when [`VAR`] is set, or return
173/// `None` for an ordinary run.
174///
175/// With no word after `--` the request is for the registration, which this
176/// prints from [`Shell::script`] so it matches the verb byte for byte. With
177/// words, `clap_complete` prints the candidates.
178pub(crate) fn complete<C: CommandFactory>(bin: &str, args: &[OsString]) -> Option<ExitCode> {
179    let requested = std::env::var_os(VAR).filter(|value| !value.is_empty() && value != "0")?;
180    let words = args
181        .iter()
182        .skip(1)
183        .skip_while(|arg| arg.as_os_str() != "--")
184        .nth(1)
185        .is_some();
186    Some(if words {
187        candidates::<C>(bin, args)
188    } else {
189        registration(bin, &requested)
190    })
191}
192
193fn registration(bin: &str, requested: &OsStr) -> ExitCode {
194    let name = Path::new(requested)
195        .file_stem()
196        .unwrap_or(requested)
197        .to_string_lossy();
198    let Ok(shell) = <Shell as ValueEnum>::from_str(&name, false) else {
199        let expected = Shell::value_variants()
200            .iter()
201            .filter_map(ValueEnum::to_possible_value)
202            .map(|value| value.get_name().to_owned())
203            .collect::<Vec<_>>()
204            .join(", ");
205        let _ = writeln!(
206            io::stderr().lock(),
207            "{bin}: error: unknown shell `{name}` in {VAR}, expected one of {expected}"
208        );
209        return ExitCode::from(2);
210    };
211    shell
212        .script(bin)
213        .and_then(|script| crate::view::write_stdout(script.text.as_bytes()))
214        .map_or(ExitCode::FAILURE, |()| ExitCode::SUCCESS)
215}
216
217fn candidates<C: CommandFactory>(bin: &str, args: &[OsString]) -> ExitCode {
218    let current_dir = std::env::current_dir().ok();
219    match CompleteEnv::with_factory(|| crate::parser::with_help(C::command()))
220        .var(VAR)
221        .bin(bin)
222        .completer(bin)
223        .shells(SHELLS)
224        .try_complete(args.iter().cloned(), current_dir.as_deref())
225    {
226        Ok(_) => ExitCode::SUCCESS,
227        Err(error) => {
228            let _ = error.print();
229            u8::try_from(error.exit_code()).map_or(ExitCode::FAILURE, ExitCode::from)
230        }
231    }
232}