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}