Skip to main content

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