Skip to main content

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}