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}