Skip to main content

forge_terminal/
shell.rs

1//! The shell integration scripts, and how a shell is started with them.
2//!
3//! **The person's own rc is never replaced.** zsh gets a `ZDOTDIR` shim that
4//! sources the real `.zshenv`, `.zprofile` and `.zshrc` and then the
5//! integration; bash gets an `--rcfile` that sources `~/.bashrc` first; fish
6//! gets its init command. A shell forge does not know starts with no
7//! integration, and the classifier says `heuristic`.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// The zsh integration: `precmd` closes the previous command and opens the
13/// prompt, `preexec` opens the command.
14pub const ZSH: &str = r#"
15_forge_cmd_running=0
16_forge_precmd() {
17  local ret=$?
18  if (( _forge_cmd_running )); then
19    printf '\033]133;D;%s\007' "$ret"
20    _forge_cmd_running=0
21  fi
22  printf '\033]133;A\007\033]133;B\007'
23}
24_forge_preexec() {
25  _forge_cmd_running=1
26  printf '\033]133;C\007'
27}
28precmd_functions+=(_forge_precmd)
29preexec_functions+=(_forge_preexec)
30"#;
31
32/// The bash integration: `PROMPT_COMMAND` closes the previous command and
33/// opens the prompt, a `DEBUG` trap opens the command.
34///
35/// Bash 3.2 (macOS) has no `PS0`, so the trap is the only pre-exec hook, and
36/// it fires for the prompt's own commands too. Two guards keep the marks
37/// honest: the trap hands `$?` back so `_forge_prompt` reads the command's
38/// real status, and `_forge_in_prompt` plus the `$BASH_COMMAND` match keep the
39/// prompt's commands from being announced as the person's.
40pub const BASH: &str = r#"
41_forge_cmd_running=
42_forge_in_prompt=
43_forge_status=0
44_forge_prompt() {
45  local ret=$_forge_status
46  if [ -n "$_forge_cmd_running" ]; then
47    printf '\033]133;D;%s\007' "$ret"
48    _forge_cmd_running=
49  fi
50  printf '\033]133;A\007\033]133;B\007'
51  _forge_in_prompt=1
52}
53_forge_preexec() {
54  _forge_status=$?
55  if [ -n "$_forge_in_prompt" ]; then
56    return 0
57  fi
58  case "$BASH_COMMAND" in
59    _forge_prompt) return 0 ;;
60  esac
61  if [ -z "$_forge_cmd_running" ]; then
62    _forge_cmd_running=1
63    printf '\033]133;C\007'
64  fi
65  return 0
66}
67PROMPT_COMMAND="_forge_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND};_forge_in_prompt="
68trap '_forge_preexec' DEBUG
69"#;
70
71/// The fish integration: the `fish_prompt` and `fish_preexec` events.
72pub const FISH: &str = r"
73function _forge_prompt --on-event fish_prompt
74  set -l ret $status
75  if set -q _forge_cmd_running
76    printf '\033]133;D;%s\007' $ret
77    set -e _forge_cmd_running
78  end
79  printf '\033]133;A\007\033]133;B\007'
80end
81function _forge_preexec --on-event fish_preexec
82  set -g _forge_cmd_running 1
83  printf '\033]133;C\007'
84end
85";
86
87/// How one shell is started, and the shim directory it needs.
88#[derive(Debug)]
89pub struct ShellPlan {
90    pub program: String,
91    pub args: Vec<String>,
92    pub env: HashMap<String, String>,
93    /// The shim directory, removed when the terminal drops.
94    pub shim: Option<PathBuf>,
95}
96
97impl ShellPlan {
98    /// Remove the shim directory, if one was made.
99    pub fn cleanup(&self) {
100        if let Some(shim) = &self.shim {
101            let _ = std::fs::remove_dir_all(shim);
102        }
103    }
104}
105
106/// Build the command for `shell`, with the integration its name supports.
107///
108/// `env` is the seat's environment; the plan adds only what the integration
109/// needs. A shell whose name is not zsh, bash or fish starts with no
110/// integration.
111pub fn plan<S: std::hash::BuildHasher>(shell: &str, env: &HashMap<String, String, S>) -> ShellPlan {
112    let name = Path::new(shell).file_name().and_then(|name| name.to_str()).unwrap_or(shell);
113    match name {
114        "zsh" => zsh_plan(shell, env),
115        "bash" => bash_plan(shell, env),
116        "fish" => fish_plan(shell, env),
117        _ => ShellPlan {
118            program: shell.to_owned(),
119            args: vec!["-l".to_owned()],
120            env: clone_env(env),
121            shim: None,
122        },
123    }
124}
125
126fn zsh_plan<S: std::hash::BuildHasher>(shell: &str, env: &HashMap<String, String, S>) -> ShellPlan {
127    let real = env
128        .get("ZDOTDIR")
129        .cloned()
130        .or_else(|| env.get("HOME").cloned())
131        .unwrap_or_else(|| "/".to_owned());
132    let Some(shim) = make_shim("zsh") else {
133        return ShellPlan {
134            program: shell.to_owned(),
135            args: vec!["-l".to_owned()],
136            env: clone_env(env),
137            shim: None,
138        };
139    };
140    // **zsh reads .zshenv from ZDOTDIR for every invocation**, before the
141    // profile and rc, so the shim has to forward the person's own or their
142    // environment is silently dropped.
143    let zshenv = format!("if [ -f \"{real}/.zshenv\" ]; then source \"{real}/.zshenv\"; fi\n");
144    let zshrc = format!("if [ -f \"{real}/.zshrc\" ]; then source \"{real}/.zshrc\"; fi\n{ZSH}");
145    let zprofile =
146        format!("if [ -f \"{real}/.zprofile\" ]; then source \"{real}/.zprofile\"; fi\n");
147    let zshenv_path = shim.join(".zshenv");
148    let zshrc_path = shim.join(".zshrc");
149    let zprofile_path = shim.join(".zprofile");
150    for (path, body) in
151        [(&zshenv_path, &zshenv), (&zshrc_path, &zshrc), (&zprofile_path, &zprofile)]
152    {
153        if let Err(error) = std::fs::write(path, body) {
154            warn_unavailable("zsh", path, &error);
155            let _ = std::fs::remove_dir_all(&shim);
156            return ShellPlan {
157                program: shell.to_owned(),
158                args: vec!["-l".to_owned()],
159                env: clone_env(env),
160                shim: None,
161            };
162        }
163    }
164    let mut env = clone_env(env);
165    env.insert("ZDOTDIR".to_owned(), shim.display().to_string());
166    env.insert("FORGE_REAL_ZDOTDIR".to_owned(), real);
167    ShellPlan { program: shell.to_owned(), args: vec!["-l".to_owned()], env, shim: Some(shim) }
168}
169
170fn bash_plan<S: std::hash::BuildHasher>(
171    shell: &str,
172    env: &HashMap<String, String, S>,
173) -> ShellPlan {
174    let home = env.get("HOME").cloned().unwrap_or_else(|| "/".to_owned());
175    let Some(shim) = make_shim("bash") else {
176        return ShellPlan {
177            program: shell.to_owned(),
178            args: vec!["-i".to_owned()],
179            env: clone_env(env),
180            shim: None,
181        };
182    };
183    let rc = format!("if [ -f \"{home}/.bashrc\" ]; then source \"{home}/.bashrc\"; fi\n{BASH}");
184    let rcfile = shim.join("bashrc");
185    if let Err(error) = std::fs::write(&rcfile, rc) {
186        warn_unavailable("bash", &rcfile, &error);
187        let _ = std::fs::remove_dir_all(&shim);
188        return ShellPlan {
189            program: shell.to_owned(),
190            args: vec!["-i".to_owned()],
191            env: clone_env(env),
192            shim: None,
193        };
194    }
195    // **Not `-l`.** bash 3.2 ignores `--rcfile` in a login shell, which would
196    // silently cost the marks the terminal__wait exactness rests on; the
197    // rcfile sources the person's `~/.bashrc`, so their aliases still load.
198    ShellPlan {
199        program: shell.to_owned(),
200        args: vec!["--rcfile".to_owned(), rcfile.display().to_string(), "-i".to_owned()],
201        env: clone_env(env),
202        shim: Some(shim),
203    }
204}
205
206fn fish_plan<S: std::hash::BuildHasher>(
207    shell: &str,
208    env: &HashMap<String, String, S>,
209) -> ShellPlan {
210    let Some(shim) = make_shim("fish") else {
211        return ShellPlan {
212            program: shell.to_owned(),
213            args: vec!["-l".to_owned()],
214            env: clone_env(env),
215            shim: None,
216        };
217    };
218    let script = shim.join("integration.fish");
219    if let Err(error) = std::fs::write(&script, FISH) {
220        warn_unavailable("fish", &script, &error);
221        let _ = std::fs::remove_dir_all(&shim);
222        return ShellPlan {
223            program: shell.to_owned(),
224            args: vec!["-l".to_owned()],
225            env: clone_env(env),
226            shim: None,
227        };
228    }
229    ShellPlan {
230        program: shell.to_owned(),
231        args: vec!["-l".to_owned(), "-C".to_owned(), format!("source {}", script.display())],
232        env: clone_env(env),
233        shim: Some(shim),
234    }
235}
236
237/// The environment the shell starts with: the seat's own, plus what the PTY's
238/// terminal type answers to. The seat's env carries forge's launch terminal's
239/// `TERM`, which is not what this shell is attached to.
240fn clone_env<S: std::hash::BuildHasher>(
241    env: &HashMap<String, String, S>,
242) -> HashMap<String, String> {
243    let mut env: HashMap<String, String> =
244        env.iter().map(|(key, value)| (key.clone(), value.clone())).collect();
245    env.insert("TERM".to_owned(), "xterm-256color".to_owned());
246    env.insert("COLORTERM".to_owned(), "truecolor".to_owned());
247    env
248}
249
250/// A fresh shim directory under the system temp dir.
251fn make_shim(shell: &str) -> Option<PathBuf> {
252    let nanos = std::time::SystemTime::now()
253        .duration_since(std::time::UNIX_EPOCH)
254        .map_or(0, |d| d.as_nanos());
255    let dir =
256        std::env::temp_dir().join(format!("forge-terminal-{shell}-{}-{nanos}", std::process::id()));
257    if let Err(error) = std::fs::create_dir_all(&dir) {
258        warn_unavailable(shell, &dir, &error);
259        return None;
260    }
261    Some(dir)
262}
263
264/// **A missing integration is said, not discovered later as a password
265/// prompt.** Without the marks the shell falls back to the heuristic, which
266/// is forge's own problem to know about.
267fn warn_unavailable(shell: &str, path: &Path, error: &std::io::Error) {
268    tracing::warn!(
269        event_name = "terminal_integration_unavailable",
270        shell,
271        path = %path.display(),
272        %error,
273        "the shell integration could not be written; the terminal starts without OSC 133 marks"
274    );
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn env() -> HashMap<String, String> {
282        let mut env = HashMap::new();
283        env.insert("HOME".to_owned(), "/home/person".to_owned());
284        env
285    }
286
287    /// zsh starts as a login shell with a `ZDOTDIR` shim, and the shim
288    /// sources the person's own `.zshrc` before the integration.
289    #[test]
290    fn zsh_gets_a_zdotdir_shim_that_keeps_the_persons_rc() {
291        let plan = plan("/bin/zsh", &env());
292        assert_eq!(plan.program, "/bin/zsh");
293        assert_eq!(plan.args, ["-l"]);
294        let shim = plan.shim.as_ref().expect("a shim");
295        assert_eq!(plan.env.get("ZDOTDIR"), Some(&shim.display().to_string()));
296        assert_eq!(plan.env.get("FORGE_REAL_ZDOTDIR"), Some(&"/home/person".to_owned()));
297        assert_eq!(plan.env.get("TERM").map(String::as_str), Some("xterm-256color"));
298        assert_eq!(plan.env.get("COLORTERM").map(String::as_str), Some("truecolor"));
299        let zshenv = std::fs::read_to_string(shim.join(".zshenv")).expect("a .zshenv");
300        assert!(
301            zshenv.contains("source \"/home/person/.zshenv\""),
302            "the person's env file is sourced, as zsh always reads it"
303        );
304        let zshrc = std::fs::read_to_string(shim.join(".zshrc")).expect("a .zshrc");
305        assert!(zshrc.contains("source \"/home/person/.zshrc\""), "the person's rc is sourced");
306        assert!(zshrc.contains("133;D"), "and the integration follows it");
307        plan.cleanup();
308        assert!(!shim.exists(), "the shim is removed");
309    }
310
311    /// bash starts with `--rcfile`, and the rcfile sources `~/.bashrc` first.
312    #[test]
313    fn bash_gets_an_rcfile_that_keeps_the_persons_rc() {
314        let plan = plan("/bin/bash", &env());
315        assert_eq!(plan.program, "/bin/bash");
316        assert_eq!(plan.args[0], "--rcfile");
317        assert_eq!(plan.args[2], "-i");
318        let rcfile = PathBuf::from(&plan.args[1]);
319        let rc = std::fs::read_to_string(&rcfile).expect("an rcfile");
320        assert!(rc.contains("source \"/home/person/.bashrc\""), "the person's rc is sourced");
321        assert!(rc.contains("133;D"), "and the integration follows it");
322        plan.cleanup();
323    }
324
325    /// fish gets its init command, which sources the integration.
326    #[test]
327    fn fish_gets_an_init_command() {
328        let plan = plan("/opt/homebrew/bin/fish", &env());
329        assert_eq!(plan.program, "/opt/homebrew/bin/fish");
330        assert_eq!(plan.args[0], "-l", "a login shell, so the person's config ran");
331        assert_eq!(plan.args[1], "-C");
332        assert!(plan.args[2].starts_with("source "), "{}", plan.args[2]);
333        plan.cleanup();
334    }
335
336    /// A shell forge does not know starts with no integration, so the
337    /// classifier falls back rather than pretending.
338    #[test]
339    fn an_unknown_shell_gets_no_integration() {
340        let plan = plan("/bin/sh", &env());
341        assert!(plan.shim.is_none());
342        assert_eq!(plan.args, ["-l"]);
343    }
344}