ctl_core/shell_init.rs
1//! Shell integration: one `init <SHELL>` verb that hooks a CLI into the
2//! user's shell.
3//!
4//! The verb prints a script that runs one command of the CLI in the
5//! foreground, before the shell draws its first prompt. It never runs again in
6//! that shell. The CLI declares the command once as a [`Hook`], and ctl-core
7//! writes every line of shell syntax from it.
8//!
9//! The hook runs only when its variable is set to a non-empty value and the
10//! binary is on `PATH`. A startup file that loads the script therefore stays
11//! safe on a machine without the CLI, or outside the context the variable
12//! marks.
13//! Loading the script twice installs the hook once.
14//!
15//! A CLI flattens [`InitCommand`] into its subcommands, routes the verb to
16//! [`shell::view`], and returns the script. The example needs `clap` with its
17//! `derive` feature, as the [crate quick start](crate#quick-start) shows:
18//!
19//! ```no_run
20//! use ctl_core::prelude::*;
21//! use ctl_core::shell;
22//! use ctl_core::shell_init::{Hook, InitCommand};
23//!
24//! #[derive(Parser)]
25//! struct Cli {
26//! #[command(flatten)]
27//! output: OutputArgs,
28//! #[command(subcommand)]
29//! command: Command,
30//! }
31//!
32//! #[derive(Subcommand)]
33//! enum Command {
34//! #[command(flatten)]
35//! Init(InitCommand),
36//! }
37//!
38//! fn main() -> ExitCode {
39//! App::<Cli>::new("toy")
40//! .view(|cli| match cli.command {
41//! Command::Init(_) => shell::view(cli.output.format),
42//! })
43//! .run(|cli| match cli.command {
44//! Command::Init(command) => {
45//! let hook = Hook::new("toy", "pick", "TOY_BLOCK")?;
46//! Ok(command.script(&hook))
47//! }
48//! })
49//! }
50//! ```
51//!
52//! A user loads the script from a startup file. Each script names its
53//! line in its first comment:
54//!
55//! | Shell | Startup file | Line |
56//! |:--|:--|:--|
57//! | bash | `~/.bashrc` | `eval "$(toy init bash)"` |
58//! | zsh | `~/.zshrc` | `eval "$(toy init zsh)"` |
59//! | fish | `~/.config/fish/config.fish` | `toy init fish \| source` |
60//!
61//! The script calls the binary by name and carries no path or setting read
62//! at `init` time, so a saved copy keeps working across upgrades.
63//!
64//! [`shell::view`]: crate::shell::view
65
66use std::error::Error;
67use std::fmt;
68
69use crate::formatdoc;
70use crate::shell::{Script, Shell};
71
72/// A command of the CLI that the shell runs once, in the foreground, before
73/// its first prompt.
74///
75/// It runs as `<bin> <subcommand>`, and only when the environment variable
76/// `variable` is set to a non-empty value and `bin` is on `PATH`. Each shell
77/// removes the hook before running the command, so a later prompt never runs
78/// it again.
79///
80/// In bash, another tool, such as bash-preexec, can rewrite `PROMPT_COMMAND`
81/// so that the removal misses. So bash's hook also sets a variable it checks
82/// first.
83///
84/// Every part is one word the shell reads without quoting, which
85/// [`Hook::new`] checks once.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Hook {
88 bin: String,
89 subcommand: String,
90 variable: String,
91}
92
93impl Hook {
94 /// Declare the hook that runs `bin subcommand` when `variable` is set.
95 ///
96 /// # Errors
97 ///
98 /// Returns a [`HookError`] naming the first part that is not a plain word.
99 ///
100 /// - `bin` and `subcommand` take letters, digits, `-`, and `_`, and start
101 /// with a letter or digit.
102 /// - `variable` takes letters, digits, and `_`, and does not start with a
103 /// digit.
104 pub fn new(bin: &str, subcommand: &str, variable: &str) -> Result<Self, HookError> {
105 if !is_command_word(bin) {
106 return Err(HookError::Bin(bin.to_owned()));
107 }
108 if !is_command_word(subcommand) {
109 return Err(HookError::Subcommand(subcommand.to_owned()));
110 }
111 if !is_variable_name(variable) {
112 return Err(HookError::Variable(variable.to_owned()));
113 }
114 Ok(Self {
115 bin: bin.to_owned(),
116 subcommand: subcommand.to_owned(),
117 variable: variable.to_owned(),
118 })
119 }
120
121 /// The script that installs this hook in `shell`.
122 ///
123 /// bash runs it from `PROMPT_COMMAND`, as a string or, from bash 5.1, an
124 /// array. zsh runs it from `precmd_functions`, and fish on the
125 /// `fish_prompt` event. The bash hook returns the exit status it found, so
126 /// a prompt command after it still reads the status of the user's last
127 /// command.
128 #[must_use]
129 pub fn script(&self, shell: Shell) -> Script {
130 let text = match shell {
131 Shell::Bash => self.bash(),
132 Shell::Zsh => self.zsh(),
133 Shell::Fish => self.fish(),
134 };
135 Script { shell, text }
136 }
137
138 /// The shell function that runs the hook: the binary's name with `-`
139 /// written `_`, so it is also a valid name for a bash variable.
140 fn function(&self) -> String {
141 format!("__{}_first_prompt", self.bin.replace('-', "_"))
142 }
143
144 fn bash(&self) -> String {
145 let Self {
146 bin,
147 subcommand,
148 variable,
149 } = self;
150 let function = self.function();
151 formatdoc! {r#"
152 # Load in ~/.bashrc: eval "$({bin} init bash)"
153 {function}() {{
154 local status=$? declaration
155 if [[ -n ${{{function}_ran-}} ]]; then
156 return "${{status}}"
157 fi
158 {function}_ran=1
159 declaration=$(declare -p PROMPT_COMMAND 2>/dev/null)
160 if [[ ${{declaration}} == "declare -a"* ]]; then
161 local hooks=() hook
162 for hook in "${{PROMPT_COMMAND[@]}}"; do
163 [[ ${{hook}} == {function} ]] || hooks+=("${{hook}}")
164 done
165 PROMPT_COMMAND=("${{hooks[@]}}")
166 else
167 printf -v PROMPT_COMMAND %s "${{PROMPT_COMMAND//{function};/}}"
168 fi
169 if [[ -n ${{{variable}-}} ]] && type -P {bin} >/dev/null; then
170 command {bin} {subcommand}
171 fi
172 return "${{status}}"
173 }}
174 {function}_declaration=$(declare -p PROMPT_COMMAND 2>/dev/null)
175 if [[ ${{{function}_declaration}} == "declare -a"* ]]; then
176 if [[ " ${{PROMPT_COMMAND[*]}} " != *" {function} "* ]]; then
177 PROMPT_COMMAND=({function} "${{PROMPT_COMMAND[@]}}")
178 fi
179 elif [[ ${{PROMPT_COMMAND-}} != *{function}\;* ]]; then
180 printf -v PROMPT_COMMAND %s "{function};${{PROMPT_COMMAND-}}"
181 fi
182 unset {function}_declaration
183 "#}
184 }
185
186 fn zsh(&self) -> String {
187 let Self {
188 bin,
189 subcommand,
190 variable,
191 } = self;
192 let function = self.function();
193 formatdoc! {r#"
194 # Load in ~/.zshrc: eval "$({bin} init zsh)"
195 {function}() {{
196 precmd_functions=(${{precmd_functions:#{function}}})
197 if [[ -n ${{{variable}-}} ]] && whence -p {bin} >/dev/null; then
198 command {bin} {subcommand}
199 fi
200 }}
201 if (( ! ${{precmd_functions[(I){function}]:-0}} )); then
202 precmd_functions+=({function})
203 fi
204 "#}
205 }
206
207 fn fish(&self) -> String {
208 let Self {
209 bin,
210 subcommand,
211 variable,
212 } = self;
213 let function = self.function();
214 formatdoc! {r#"
215 # Load in ~/.config/fish/config.fish: {bin} init fish | source
216 function {function} --on-event fish_prompt
217 functions --erase {function}
218 if test -n "${variable}"; and command -q {bin}
219 command {bin} {subcommand}
220 end
221 end
222 "#}
223 }
224}
225
226fn is_command_word(word: &str) -> bool {
227 word.chars()
228 .next()
229 .is_some_and(|first| first.is_ascii_alphanumeric())
230 && word
231 .chars()
232 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
233}
234
235fn is_variable_name(name: &str) -> bool {
236 name.chars()
237 .next()
238 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
239 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
240}
241
242/// A [`Hook`] part that is not a word the shell reads without quoting.
243#[derive(Debug, Clone, PartialEq, Eq)]
244#[non_exhaustive]
245pub enum HookError {
246 /// The binary name is not a plain word. Holds the rejected value.
247 Bin(String),
248 /// The subcommand is not a plain word. Holds the rejected value.
249 Subcommand(String),
250 /// The environment variable name is not a plain word. Holds the rejected
251 /// value.
252 Variable(String),
253}
254
255impl fmt::Display for HookError {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 match self {
258 Self::Bin(bin) => write!(
259 f,
260 "binary name `{bin}` must be letters, digits, `-`, and `_`, starting with a letter or digit"
261 ),
262 Self::Subcommand(subcommand) => write!(
263 f,
264 "subcommand `{subcommand}` must be letters, digits, `-`, and `_`, starting with a letter or digit"
265 ),
266 Self::Variable(variable) => write!(
267 f,
268 "variable `{variable}` must be letters, digits, and `_`, not starting with a digit"
269 ),
270 }
271 }
272}
273
274impl Error for HookError {}
275
276/// The `init` verb, flattened into a CLI's subcommands with
277/// `#[command(flatten)]`, so its name, help, and argument read the same in
278/// every CLI built on ctl-core.
279#[derive(Debug, Clone, PartialEq, Eq, clap::Subcommand)]
280pub enum InitCommand {
281 /// Print the script that hooks this command into your shell.
282 Init(InitArgs),
283}
284
285impl InitCommand {
286 /// The script for the shell the user asked for.
287 #[must_use]
288 pub fn script(&self, hook: &Hook) -> Script {
289 match self {
290 Self::Init(args) => hook.script(args.shell),
291 }
292 }
293}
294
295/// The arguments of the `init` verb.
296///
297/// An argument can join it in a minor release, so a caller reads its fields
298/// and never builds one with a struct literal.
299#[derive(Debug, Clone, PartialEq, Eq, clap::Args)]
300#[non_exhaustive]
301pub struct InitArgs {
302 /// Shell to hook into: bash, zsh, or fish.
303 #[arg(value_enum)]
304 pub shell: Shell,
305}