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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
23#[expect(clippy::doc_markdown)]
24pub enum Shell {
25 Bash,
27 Fish,
29 Powershell,
31 Cmd,
33 Zsh,
35 Nushell,
37 Csh,
39 Ksh,
41}
42
43impl Shell {
44 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 if std::env::var_os(EnvVars::PROMPT).is_some() {
74 Some(Self::Cmd)
75 } else {
76 Some(Self::Powershell)
78 }
79 } else {
80 Self::from_parent_process()
82 }
83 }
84
85 fn from_parent_process() -> Option<Self> {
94 #[cfg(unix)]
95 {
96 let ppid = nix::unistd::getppid();
98 debug!("Detected parent process ID: {ppid}");
99
100 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 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 fn from_shell_path(path: impl AsRef<Path>) -> Option<Self> {
141 parse_shell_from_path(path.as_ref())
142 }
143
144 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 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 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 vec![home_dir.join(".profile"), home_dir.join(".kshrc")]
180 }
181 Self::Zsh => {
182 let zsh_dot_dir = std::env::var(EnvVars::ZDOTDIR)
188 .ok()
189 .filter(|dir| !dir.is_empty())
190 .map(PathBuf::from);
191
192 if let Some(zsh_dot_dir) = zsh_dot_dir.as_ref() {
194 let zshenv = zsh_dot_dir.join(".zshenv");
196 if zshenv.is_file() {
197 return vec![zshenv];
198 }
199 }
200 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 vec![zsh_dot_dir.join(".zshenv")]
209 } else {
210 vec![home_dir.join(".zshenv")]
212 }
213 }
214 Self::Fish => {
215 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 vec![home_dir.join(".cshrc"), home_dir.join(".login")]
232 }
233 Self::Nushell => vec![],
235 Self::Powershell => vec![],
237 Self::Cmd => vec![],
239 }
240 }
241
242 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 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 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
309fn 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
323fn 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
340fn 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 '"' | '`' | '\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 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}