Skip to main content

uv_shell/
lib.rs

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