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