uv_shell/
lib.rs

1pub mod runnable;
2mod shlex;
3pub mod windows;
4
5pub use shlex::{escape_posix_for_single_quotes, shlex_posix, shlex_windows};
6
7use std::env::home_dir;
8use std::path::{Path, PathBuf};
9
10use uv_fs::Simplified;
11use uv_static::EnvVars;
12
13#[cfg(unix)]
14use tracing::debug;
15
16/// Shells for which virtualenv activation scripts are available.
17#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
18#[allow(clippy::doc_markdown)]
19pub enum Shell {
20    /// Bourne Again SHell (bash)
21    Bash,
22    /// Friendly Interactive SHell (fish)
23    Fish,
24    /// PowerShell
25    Powershell,
26    /// Cmd (Command Prompt)
27    Cmd,
28    /// Z SHell (zsh)
29    Zsh,
30    /// Nushell
31    Nushell,
32    /// C SHell (csh)
33    Csh,
34    /// Korn SHell (ksh)
35    Ksh,
36}
37
38impl Shell {
39    /// Determine the user's current shell from the environment.
40    ///
41    /// This will read the `SHELL` environment variable and try to determine which shell is in use
42    /// from that.
43    ///
44    /// If `SHELL` is not set, then on windows, it will default to powershell, and on
45    /// other `OSes` it will return `None`.
46    ///
47    /// If `SHELL` is set, but contains a value that doesn't correspond to one of the supported
48    /// shell types, then return `None`.
49    pub fn from_env() -> Option<Self> {
50        if std::env::var_os(EnvVars::NU_VERSION).is_some() {
51            Some(Self::Nushell)
52        } else if std::env::var_os(EnvVars::FISH_VERSION).is_some() {
53            Some(Self::Fish)
54        } else if std::env::var_os(EnvVars::BASH_VERSION).is_some() {
55            Some(Self::Bash)
56        } else if std::env::var_os(EnvVars::ZSH_VERSION).is_some() {
57            Some(Self::Zsh)
58        } else if std::env::var_os(EnvVars::KSH_VERSION).is_some() {
59            Some(Self::Ksh)
60        } else if let Some(env_shell) = std::env::var_os(EnvVars::SHELL) {
61            Self::from_shell_path(env_shell)
62        } else if cfg!(windows) {
63            // Command Prompt relies on PROMPT for its appearance whereas PowerShell does not.
64            // See: https://stackoverflow.com/a/66415037.
65            if std::env::var_os(EnvVars::PROMPT).is_some() {
66                Some(Self::Cmd)
67            } else {
68                // Fallback to PowerShell if the PROMPT environment variable is not set.
69                Some(Self::Powershell)
70            }
71        } else {
72            // Fallback to detecting the shell from the parent process
73            Self::from_parent_process()
74        }
75    }
76
77    /// Attempt to determine the shell from the parent process.
78    ///
79    /// This is a fallback method for when environment variables don't provide
80    /// enough information about the current shell. It looks at the parent process
81    /// to try to identify which shell is running.
82    ///
83    /// This method currently only works on Unix-like systems. On other platforms,
84    /// it returns `None`.
85    fn from_parent_process() -> Option<Self> {
86        #[cfg(unix)]
87        {
88            // Get the parent process ID
89            let ppid = nix::unistd::getppid();
90            debug!("Detected parent process ID: {ppid}");
91
92            // Try to read the parent process executable path
93            let proc_exe_path = format!("/proc/{ppid}/exe");
94            if let Ok(exe_path) = fs_err::read_link(&proc_exe_path) {
95                debug!("Parent process executable: {}", exe_path.display());
96                if let Some(shell) = Self::from_shell_path(&exe_path) {
97                    return Some(shell);
98                }
99            }
100
101            // If reading exe fails, try reading the comm file
102            let proc_comm_path = format!("/proc/{ppid}/comm");
103            if let Ok(comm) = fs_err::read_to_string(&proc_comm_path) {
104                let comm = comm.trim();
105                debug!("Parent process comm: {comm}");
106                if let Some(shell) = parse_shell_from_path(Path::new(comm)) {
107                    return Some(shell);
108                }
109            }
110
111            debug!("Could not determine shell from parent process");
112            None
113        }
114
115        #[cfg(not(unix))]
116        {
117            None
118        }
119    }
120
121    /// Parse a shell from a path to the executable for the shell.
122    ///
123    /// # Examples
124    ///
125    /// ```ignore
126    /// use crate::shells::Shell;
127    ///
128    /// assert_eq!(Shell::from_shell_path("/bin/bash"), Some(Shell::Bash));
129    /// assert_eq!(Shell::from_shell_path("/usr/bin/zsh"), Some(Shell::Zsh));
130    /// assert_eq!(Shell::from_shell_path("/opt/my_custom_shell"), None);
131    /// ```
132    pub fn from_shell_path(path: impl AsRef<Path>) -> Option<Self> {
133        parse_shell_from_path(path.as_ref())
134    }
135
136    /// Returns `true` if the shell supports a `PATH` update command.
137    pub fn supports_update(self) -> bool {
138        match self {
139            Self::Powershell | Self::Cmd => true,
140            shell => !shell.configuration_files().is_empty(),
141        }
142    }
143
144    /// Return the configuration files that should be modified to append to a shell's `PATH`.
145    ///
146    /// Some of the logic here is based on rustup's rc file detection.
147    ///
148    /// See: <https://github.com/rust-lang/rustup/blob/fede22fea7b160868cece632bd213e6d72f8912f/src/cli/self_update/shell.rs#L197>
149    pub fn configuration_files(self) -> Vec<PathBuf> {
150        let Some(home_dir) = home_dir() else {
151            return vec![];
152        };
153        match self {
154            Self::Bash => {
155                // On Bash, we need to update both `.bashrc` and `.bash_profile`. The former is
156                // sourced for non-login shells, and the latter is sourced for login shells.
157                //
158                // In lieu of `.bash_profile`, shells will also respect `.bash_login` and
159                // `.profile`, if they exist. So we respect those too.
160                vec![
161                    [".bash_profile", ".bash_login", ".profile"]
162                        .iter()
163                        .map(|rc| home_dir.join(rc))
164                        .find(|rc| rc.is_file())
165                        .unwrap_or_else(|| home_dir.join(".bash_profile")),
166                    home_dir.join(".bashrc"),
167                ]
168            }
169            Self::Ksh => {
170                // On Ksh it's standard POSIX `.profile` for login shells, and `.kshrc` for non-login.
171                vec![home_dir.join(".profile"), home_dir.join(".kshrc")]
172            }
173            Self::Zsh => {
174                // On Zsh, we only need to update `.zshenv`. This file is sourced for both login and
175                // non-login shells. However, we match rustup's logic for determining _which_
176                // `.zshenv` to use.
177                //
178                // See: https://github.com/rust-lang/rustup/blob/fede22fea7b160868cece632bd213e6d72f8912f/src/cli/self_update/shell.rs#L197
179                let zsh_dot_dir = std::env::var(EnvVars::ZDOTDIR)
180                    .ok()
181                    .filter(|dir| !dir.is_empty())
182                    .map(PathBuf::from);
183
184                // Attempt to update an existing `.zshenv` file.
185                if let Some(zsh_dot_dir) = zsh_dot_dir.as_ref() {
186                    // If `ZDOTDIR` is set, and `ZDOTDIR/.zshenv` exists, then we update that file.
187                    let zshenv = zsh_dot_dir.join(".zshenv");
188                    if zshenv.is_file() {
189                        return vec![zshenv];
190                    }
191                }
192                // Whether `ZDOTDIR` is set or not, if `~/.zshenv` exists then we update that file.
193                let zshenv = home_dir.join(".zshenv");
194                if zshenv.is_file() {
195                    return vec![zshenv];
196                }
197
198                if let Some(zsh_dot_dir) = zsh_dot_dir.as_ref() {
199                    // If `ZDOTDIR` is set, then we create `ZDOTDIR/.zshenv`.
200                    vec![zsh_dot_dir.join(".zshenv")]
201                } else {
202                    // If `ZDOTDIR` is _not_ set, then we create `~/.zshenv`.
203                    vec![home_dir.join(".zshenv")]
204                }
205            }
206            Self::Fish => {
207                // On Fish, we only need to update `config.fish`. This file is sourced for both
208                // login and non-login shells. However, we must respect Fish's logic, which reads
209                // from `$XDG_CONFIG_HOME/fish/config.fish` if set, and `~/.config/fish/config.fish`
210                // otherwise.
211                if let Some(xdg_home_dir) = std::env::var(EnvVars::XDG_CONFIG_HOME)
212                    .ok()
213                    .filter(|dir| !dir.is_empty())
214                    .map(PathBuf::from)
215                {
216                    vec![xdg_home_dir.join("fish/config.fish")]
217                } else {
218                    vec![home_dir.join(".config/fish/config.fish")]
219                }
220            }
221            Self::Csh => {
222                // On Csh, we need to update both `.cshrc` and `.login`, like Bash.
223                vec![home_dir.join(".cshrc"), home_dir.join(".login")]
224            }
225            // TODO(charlie): Add support for Nushell.
226            Self::Nushell => vec![],
227            // See: [`crate::windows::prepend_path`].
228            Self::Powershell => vec![],
229            // See: [`crate::windows::prepend_path`].
230            Self::Cmd => vec![],
231        }
232    }
233
234    /// Returns `true` if the given path is on the `PATH` in this shell.
235    pub fn contains_path(path: &Path) -> bool {
236        let home_dir = home_dir();
237        std::env::var_os(EnvVars::PATH)
238            .as_ref()
239            .iter()
240            .flat_map(std::env::split_paths)
241            .map(|path| {
242                // If the first component is `~`, expand to the home directory.
243                if let Some(home_dir) = home_dir.as_ref() {
244                    if path
245                        .components()
246                        .next()
247                        .map(std::path::Component::as_os_str)
248                        == Some("~".as_ref())
249                    {
250                        return home_dir.join(path.components().skip(1).collect::<PathBuf>());
251                    }
252                }
253                path
254            })
255            .any(|p| same_file::is_same_file(path, p).unwrap_or(false))
256    }
257
258    /// Returns the command necessary to prepend a directory to the `PATH` in this shell.
259    pub fn prepend_path(self, path: &Path) -> Option<String> {
260        match self {
261            Self::Nushell => None,
262            Self::Bash | Self::Zsh | Self::Ksh => Some(format!(
263                "export PATH=\"{}:$PATH\"",
264                backslash_escape(&path.simplified_display().to_string()),
265            )),
266            Self::Fish => Some(format!(
267                "fish_add_path \"{}\"",
268                backslash_escape(&path.simplified_display().to_string()),
269            )),
270            Self::Csh => Some(format!(
271                "setenv PATH \"{}:$PATH\"",
272                backslash_escape(&path.simplified_display().to_string()),
273            )),
274            Self::Powershell => Some(format!(
275                "$env:PATH = \"{};$env:PATH\"",
276                backtick_escape(&path.simplified_display().to_string()),
277            )),
278            Self::Cmd => Some(format!(
279                "set PATH=\"{};%PATH%\"",
280                backslash_escape(&path.simplified_display().to_string()),
281            )),
282        }
283    }
284}
285
286impl std::fmt::Display for Shell {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        match self {
289            Self::Bash => write!(f, "Bash"),
290            Self::Fish => write!(f, "Fish"),
291            Self::Powershell => write!(f, "PowerShell"),
292            Self::Cmd => write!(f, "Command Prompt"),
293            Self::Zsh => write!(f, "Zsh"),
294            Self::Nushell => write!(f, "Nushell"),
295            Self::Csh => write!(f, "Csh"),
296            Self::Ksh => write!(f, "Ksh"),
297        }
298    }
299}
300
301/// Parse the shell from the name of the shell executable.
302fn parse_shell_from_path(path: &Path) -> Option<Shell> {
303    let name = path.file_stem()?.to_str()?;
304    match name {
305        "bash" => Some(Shell::Bash),
306        "zsh" => Some(Shell::Zsh),
307        "fish" => Some(Shell::Fish),
308        "csh" => Some(Shell::Csh),
309        "ksh" => Some(Shell::Ksh),
310        "powershell" | "powershell_ise" => Some(Shell::Powershell),
311        _ => None,
312    }
313}
314
315/// Escape a string for use in a shell command by inserting backslashes.
316fn backslash_escape(s: &str) -> String {
317    let mut escaped = String::with_capacity(s.len());
318    for c in s.chars() {
319        match c {
320            '\\' | '"' => escaped.push('\\'),
321            _ => {}
322        }
323        escaped.push(c);
324    }
325    escaped
326}
327
328/// Escape a string for use in a `PowerShell` command by inserting backticks.
329fn backtick_escape(s: &str) -> String {
330    let mut escaped = String::with_capacity(s.len());
331    for c in s.chars() {
332        match c {
333            // Need to also escape unicode double quotes that PowerShell treats
334            // as the ASCII double quote.
335            '"' | '`' | '\u{201C}' | '\u{201D}' | '\u{201E}' | '$' => escaped.push('`'),
336            _ => {}
337        }
338        escaped.push(c);
339    }
340    escaped
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use fs_err::File;
347    use temp_env::with_vars;
348    use tempfile::tempdir;
349
350    // First option used by std::env::home_dir.
351    const HOME_DIR_ENV_VAR: &str = if cfg!(windows) {
352        EnvVars::USERPROFILE
353    } else {
354        EnvVars::HOME
355    };
356
357    #[test]
358    fn configuration_files_zsh_no_existing_zshenv() {
359        let tmp_home_dir = tempdir().unwrap();
360        let tmp_zdotdir = tempdir().unwrap();
361
362        with_vars(
363            [
364                (EnvVars::ZDOTDIR, None),
365                (HOME_DIR_ENV_VAR, tmp_home_dir.path().to_str()),
366            ],
367            || {
368                assert_eq!(
369                    Shell::Zsh.configuration_files(),
370                    vec![tmp_home_dir.path().join(".zshenv")]
371                );
372            },
373        );
374
375        with_vars(
376            [
377                (EnvVars::ZDOTDIR, tmp_zdotdir.path().to_str()),
378                (HOME_DIR_ENV_VAR, tmp_home_dir.path().to_str()),
379            ],
380            || {
381                assert_eq!(
382                    Shell::Zsh.configuration_files(),
383                    vec![tmp_zdotdir.path().join(".zshenv")]
384                );
385            },
386        );
387    }
388
389    #[test]
390    fn configuration_files_zsh_existing_home_zshenv() {
391        let tmp_home_dir = tempdir().unwrap();
392        File::create(tmp_home_dir.path().join(".zshenv")).unwrap();
393
394        let tmp_zdotdir = tempdir().unwrap();
395
396        with_vars(
397            [
398                (EnvVars::ZDOTDIR, None),
399                (HOME_DIR_ENV_VAR, tmp_home_dir.path().to_str()),
400            ],
401            || {
402                assert_eq!(
403                    Shell::Zsh.configuration_files(),
404                    vec![tmp_home_dir.path().join(".zshenv")]
405                );
406            },
407        );
408
409        with_vars(
410            [
411                (EnvVars::ZDOTDIR, tmp_zdotdir.path().to_str()),
412                (HOME_DIR_ENV_VAR, tmp_home_dir.path().to_str()),
413            ],
414            || {
415                assert_eq!(
416                    Shell::Zsh.configuration_files(),
417                    vec![tmp_home_dir.path().join(".zshenv")]
418                );
419            },
420        );
421    }
422
423    #[test]
424    fn configuration_files_zsh_existing_zdotdir_zshenv() {
425        let tmp_home_dir = tempdir().unwrap();
426
427        let tmp_zdotdir = tempdir().unwrap();
428        File::create(tmp_zdotdir.path().join(".zshenv")).unwrap();
429
430        with_vars(
431            [
432                (EnvVars::ZDOTDIR, tmp_zdotdir.path().to_str()),
433                (HOME_DIR_ENV_VAR, tmp_home_dir.path().to_str()),
434            ],
435            || {
436                assert_eq!(
437                    Shell::Zsh.configuration_files(),
438                    vec![tmp_zdotdir.path().join(".zshenv")]
439                );
440            },
441        );
442    }
443}