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                } else {
192                    // If `ZDOTDIR` is _not_ set, and `~/.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
199                if let Some(zsh_dot_dir) = zsh_dot_dir.as_ref() {
200                    // If `ZDOTDIR` is set, then we create `ZDOTDIR/.zshenv`.
201                    vec![zsh_dot_dir.join(".zshenv")]
202                } else {
203                    // If `ZDOTDIR` is _not_ set, then we create `~/.zshenv`.
204                    vec![home_dir.join(".zshenv")]
205                }
206            }
207            Self::Fish => {
208                // On Fish, we only need to update `config.fish`. This file is sourced for both
209                // login and non-login shells. However, we must respect Fish's logic, which reads
210                // from `$XDG_CONFIG_HOME/fish/config.fish` if set, and `~/.config/fish/config.fish`
211                // otherwise.
212                if let Some(xdg_home_dir) = std::env::var(EnvVars::XDG_CONFIG_HOME)
213                    .ok()
214                    .filter(|dir| !dir.is_empty())
215                    .map(PathBuf::from)
216                {
217                    vec![xdg_home_dir.join("fish/config.fish")]
218                } else {
219                    vec![home_dir.join(".config/fish/config.fish")]
220                }
221            }
222            Self::Csh => {
223                // On Csh, we need to update both `.cshrc` and `.login`, like Bash.
224                vec![home_dir.join(".cshrc"), home_dir.join(".login")]
225            }
226            // TODO(charlie): Add support for Nushell.
227            Self::Nushell => vec![],
228            // See: [`crate::windows::prepend_path`].
229            Self::Powershell => vec![],
230            // See: [`crate::windows::prepend_path`].
231            Self::Cmd => vec![],
232        }
233    }
234
235    /// Returns `true` if the given path is on the `PATH` in this shell.
236    pub fn contains_path(path: &Path) -> bool {
237        let home_dir = home_dir();
238        std::env::var_os(EnvVars::PATH)
239            .as_ref()
240            .iter()
241            .flat_map(std::env::split_paths)
242            .map(|path| {
243                // If the first component is `~`, expand to the home directory.
244                if let Some(home_dir) = home_dir.as_ref() {
245                    if path
246                        .components()
247                        .next()
248                        .map(std::path::Component::as_os_str)
249                        == Some("~".as_ref())
250                    {
251                        return home_dir.join(path.components().skip(1).collect::<PathBuf>());
252                    }
253                }
254                path
255            })
256            .any(|p| same_file::is_same_file(path, p).unwrap_or(false))
257    }
258
259    /// Returns the command necessary to prepend a directory to the `PATH` in this shell.
260    pub fn prepend_path(self, path: &Path) -> Option<String> {
261        match self {
262            Self::Nushell => None,
263            Self::Bash | Self::Zsh | Self::Ksh => Some(format!(
264                "export PATH=\"{}:$PATH\"",
265                backslash_escape(&path.simplified_display().to_string()),
266            )),
267            Self::Fish => Some(format!(
268                "fish_add_path \"{}\"",
269                backslash_escape(&path.simplified_display().to_string()),
270            )),
271            Self::Csh => Some(format!(
272                "setenv PATH \"{}:$PATH\"",
273                backslash_escape(&path.simplified_display().to_string()),
274            )),
275            Self::Powershell => Some(format!(
276                "$env:PATH = \"{};$env:PATH\"",
277                backtick_escape(&path.simplified_display().to_string()),
278            )),
279            Self::Cmd => Some(format!(
280                "set PATH=\"{};%PATH%\"",
281                backslash_escape(&path.simplified_display().to_string()),
282            )),
283        }
284    }
285}
286
287impl std::fmt::Display for Shell {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        match self {
290            Self::Bash => write!(f, "Bash"),
291            Self::Fish => write!(f, "Fish"),
292            Self::Powershell => write!(f, "PowerShell"),
293            Self::Cmd => write!(f, "Command Prompt"),
294            Self::Zsh => write!(f, "Zsh"),
295            Self::Nushell => write!(f, "Nushell"),
296            Self::Csh => write!(f, "Csh"),
297            Self::Ksh => write!(f, "Ksh"),
298        }
299    }
300}
301
302/// Parse the shell from the name of the shell executable.
303fn parse_shell_from_path(path: &Path) -> Option<Shell> {
304    let name = path.file_stem()?.to_str()?;
305    match name {
306        "bash" => Some(Shell::Bash),
307        "zsh" => Some(Shell::Zsh),
308        "fish" => Some(Shell::Fish),
309        "csh" => Some(Shell::Csh),
310        "ksh" => Some(Shell::Ksh),
311        "powershell" | "powershell_ise" => Some(Shell::Powershell),
312        _ => None,
313    }
314}
315
316/// Escape a string for use in a shell command by inserting backslashes.
317fn backslash_escape(s: &str) -> String {
318    let mut escaped = String::with_capacity(s.len());
319    for c in s.chars() {
320        match c {
321            '\\' | '"' => escaped.push('\\'),
322            _ => {}
323        }
324        escaped.push(c);
325    }
326    escaped
327}
328
329/// Escape a string for use in a `PowerShell` command by inserting backticks.
330fn backtick_escape(s: &str) -> String {
331    let mut escaped = String::with_capacity(s.len());
332    for c in s.chars() {
333        match c {
334            // Need to also escape unicode double quotes that PowerShell treats
335            // as the ASCII double quote.
336            '"' | '`' | '\u{201C}' | '\u{201D}' | '\u{201E}' | '$' => escaped.push('`'),
337            _ => {}
338        }
339        escaped.push(c);
340    }
341    escaped
342}