Skip to main content

fc_gpui_util/
shell.rs

1#[cfg(any(windows, test))]
2use std::ffi::OsStr;
3#[cfg(any(windows, test))]
4use std::path::PathBuf;
5#[cfg(windows)]
6use std::sync::LazyLock;
7use std::{borrow::Cow, fmt, path::Path};
8
9#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum ShellKind {
11    #[default]
12    Posix,
13    Csh,
14    Tcsh,
15    Rc,
16    Fish,
17    PowerShell,
18    Nushell,
19    Cmd,
20    Xonsh,
21}
22
23pub fn get_system_shell() -> String {
24    if cfg!(windows) {
25        get_windows_system_shell()
26    } else {
27        std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
28    }
29}
30
31pub fn get_default_system_shell() -> String {
32    if cfg!(windows) {
33        get_windows_system_shell()
34    } else {
35        "/bin/sh".to_string()
36    }
37}
38
39/// Get the default system shell, preferring git-bash on Windows.
40pub fn get_default_system_shell_preferring_bash() -> String {
41    #[cfg(windows)]
42    {
43        get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell())
44    }
45
46    #[cfg(not(windows))]
47    {
48        "/bin/sh".to_string()
49    }
50}
51
52/// True Git for Windows install root: `git-bash.exe` plus `bin\bash.exe`.
53#[cfg(any(windows, test))]
54fn find_bash_in_installation(install_root: &Path) -> Option<PathBuf> {
55    if !install_root.join("git-bash.exe").is_file() {
56        return None;
57    }
58    let bash = install_root.join("bin").join("bash.exe");
59    bash.is_file().then_some(bash)
60}
61
62/// Resolve Git Bash from a `git` executable path.
63///
64/// Git for Windows ships `git` under `cmd\` or, when already inside Git Bash,
65/// `mingw64\bin\` (prepended to `PATH`). Walk one extra parent so the latter
66/// still finds the install root.
67#[cfg(any(windows, test))]
68fn find_bash_from_git_binary(git: &Path) -> Option<PathBuf> {
69    let binary_directory = git.parent()?;
70    let parent = binary_directory.parent()?;
71    find_bash_in_installation(parent).or_else(|| find_bash_in_installation(parent.parent()?))
72}
73
74#[cfg(any(windows, test))]
75fn find_bash_using_git_install(
76    git_install_root: Option<PathBuf>,
77    git_binary: Option<&Path>,
78) -> Option<PathBuf> {
79    if let Some(bash) = git_install_root.and_then(|path| find_bash_in_installation(&path)) {
80        return Some(bash);
81    }
82    find_bash_from_git_binary(git_binary?)
83}
84
85#[cfg(windows)]
86pub fn get_windows_git_bash() -> Option<String> {
87    fn find_bash_in_git() -> Option<PathBuf> {
88        let git = which::which("git").ok();
89        find_bash_using_git_install(
90            std::env::var_os("GIT_INSTALL_ROOT").map(PathBuf::from),
91            git.as_deref(),
92        )
93    }
94
95    static GIT_BASH: LazyLock<Option<String>> = LazyLock::new(|| {
96        let bash = find_bash_in_git().map(|p| p.to_string_lossy().into_owned());
97        if let Some(ref path) = bash {
98            log::info!("Found bash at {}", path);
99        }
100        bash
101    });
102
103    (*GIT_BASH).clone()
104}
105
106/// Scoop root from a non-empty `SCOOP`, otherwise `%USERPROFILE%\scoop`.
107///
108/// An empty `SCOOP` is treated as unset. `PathBuf::from("")` would otherwise
109/// join `shims\pwsh.exe` relative to the current directory.
110#[cfg(any(windows, test))]
111fn scoop_install_root(scoop: Option<&OsStr>, userprofile: Option<&OsStr>) -> Option<PathBuf> {
112    scoop
113        .filter(|value| !value.is_empty())
114        .map(PathBuf::from)
115        .or_else(|| userprofile.map(|home| PathBuf::from(home).join("scoop")))
116}
117
118#[cfg(windows)]
119pub fn get_powershell() -> Option<String> {
120    fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
121        #[cfg(target_pointer_width = "64")]
122        let env_var = if find_alternate {
123            "ProgramFiles(x86)"
124        } else {
125            "ProgramFiles"
126        };
127
128        #[cfg(target_pointer_width = "32")]
129        let env_var = if find_alternate {
130            "ProgramW6432"
131        } else {
132            "ProgramFiles"
133        };
134
135        let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
136        install_base_dir
137            .read_dir()
138            .ok()?
139            .filter_map(Result::ok)
140            .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
141            .filter_map(|entry| {
142                let dir_name = entry.file_name();
143                let dir_name = dir_name.to_string_lossy();
144
145                let version = if find_preview {
146                    let dash_index = dir_name.find('-')?;
147                    if &dir_name[dash_index + 1..] != "preview" {
148                        return None;
149                    };
150                    dir_name[..dash_index].parse::<u32>().ok()?
151                } else {
152                    dir_name.parse::<u32>().ok()?
153                };
154
155                let exe_path = entry.path().join("pwsh.exe");
156                if exe_path.is_file() {
157                    Some((version, exe_path))
158                } else {
159                    None
160                }
161            })
162            .max_by_key(|(version, _)| *version)
163            .map(|(_, path)| path)
164    }
165
166    fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
167        let msix_app_dir =
168            PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
169        let package_family_name = if find_preview {
170            "Microsoft.PowerShellPreview_8wekyb3d8bbwe"
171        } else {
172            "Microsoft.PowerShell_8wekyb3d8bbwe"
173        };
174        let pwsh_exe = msix_app_dir.join(package_family_name).join("pwsh.exe");
175        pwsh_exe.exists().then_some(pwsh_exe)
176    }
177
178    fn find_pwsh_in_scoop() -> Option<PathBuf> {
179        // Scoop can be installed to a custom location; $SCOOP points at the
180        // scoop root in that case and defaults to %USERPROFILE%\scoop otherwise.
181        let scoop = std::env::var_os("SCOOP");
182        let userprofile = std::env::var_os("USERPROFILE");
183        let scoop_dir = scoop_install_root(scoop.as_deref(), userprofile.as_deref())?;
184        let pwsh_exe = scoop_dir.join("shims").join("pwsh.exe");
185        pwsh_exe.is_file().then_some(pwsh_exe)
186    }
187
188    fn find_pwsh_in_dotnet_tools() -> Option<PathBuf> {
189        let pwsh_exe =
190            PathBuf::from(std::env::var_os("USERPROFILE")?).join(".dotnet\\tools\\pwsh.exe");
191        pwsh_exe.is_file().then_some(pwsh_exe)
192    }
193
194    fn find_windows_powershell() -> Option<PathBuf> {
195        let system_root = PathBuf::from(std::env::var_os("SystemRoot")?);
196        let powershell = system_root.join("System32\\WindowsPowerShell\\v1.0\\powershell.exe");
197        powershell.is_file().then_some(powershell)
198    }
199
200    static POWERSHELL: LazyLock<Option<String>> = LazyLock::new(|| {
201        let locations = [
202            || find_pwsh_in_programfiles(false, false),
203            || find_pwsh_in_programfiles(true, false),
204            || find_pwsh_in_msix(false),
205            || find_pwsh_in_programfiles(false, true),
206            || find_pwsh_in_msix(true),
207            || find_pwsh_in_programfiles(true, true),
208            || find_pwsh_in_scoop(),
209            || find_pwsh_in_dotnet_tools(),
210            || which::which_global("pwsh.exe").ok(),
211            || which::which_global("powershell.exe").ok(),
212            || find_windows_powershell(),
213        ];
214
215        locations
216            .into_iter()
217            .find_map(|f| f())
218            .map(|p| p.to_string_lossy().trim().to_owned())
219            .inspect(|shell| log::info!("Found powershell in: {}", shell))
220    });
221
222    (*POWERSHELL).clone()
223}
224
225#[cfg(windows)]
226pub fn get_windows_system_shell() -> String {
227    static CMD: LazyLock<String> = LazyLock::new(|| {
228        log::warn!("Powershell not found, falling back to `cmd`");
229        let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
230        PathBuf::from(system_root)
231            .join("System32\\cmd.exe")
232            .to_string_lossy()
233            .into_owned()
234    });
235    get_powershell().unwrap_or_else(|| (*CMD).clone())
236}
237
238#[cfg(not(windows))]
239pub fn get_windows_system_shell() -> String {
240    "cmd.exe".to_string()
241}
242
243impl fmt::Display for ShellKind {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        match self {
246            ShellKind::Posix => write!(f, "sh"),
247            ShellKind::Csh => write!(f, "csh"),
248            ShellKind::Tcsh => write!(f, "tcsh"),
249            ShellKind::Fish => write!(f, "fish"),
250            ShellKind::PowerShell => write!(f, "powershell"),
251            ShellKind::Nushell => write!(f, "nu"),
252            ShellKind::Cmd => write!(f, "cmd"),
253            ShellKind::Rc => write!(f, "rc"),
254            ShellKind::Xonsh => write!(f, "xonsh"),
255        }
256    }
257}
258
259impl ShellKind {
260    pub fn system() -> Self {
261        Self::new(&get_system_shell(), cfg!(windows))
262    }
263
264    pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
265        let program = program.as_ref();
266        let program = program
267            .file_stem()
268            .unwrap_or_else(|| program.as_os_str())
269            .to_string_lossy();
270
271        if program == "powershell" || program == "pwsh" {
272            ShellKind::PowerShell
273        } else if program == "cmd" {
274            ShellKind::Cmd
275        } else if program == "nu" {
276            ShellKind::Nushell
277        } else if program == "fish" {
278            ShellKind::Fish
279        } else if program == "csh" {
280            ShellKind::Csh
281        } else if program == "tcsh" {
282            ShellKind::Tcsh
283        } else if program == "rc" {
284            ShellKind::Rc
285        } else if program == "xonsh" {
286            ShellKind::Xonsh
287        } else if program == "sh" || program == "bash" {
288            ShellKind::Posix
289        } else {
290            if is_windows {
291                ShellKind::PowerShell
292            } else {
293                // Some other shell detected, the user might install and use a
294                // unix-like shell.
295                ShellKind::Posix
296            }
297        }
298    }
299
300    pub fn to_shell_variable(self, input: &str) -> String {
301        match self {
302            Self::PowerShell => Self::to_powershell_variable(input),
303            Self::Cmd => Self::to_cmd_variable(input),
304            Self::Posix => input.to_owned(),
305            Self::Fish => input.to_owned(),
306            Self::Csh => input.to_owned(),
307            Self::Tcsh => input.to_owned(),
308            Self::Rc => input.to_owned(),
309            Self::Nushell => Self::to_nushell_variable(input),
310            Self::Xonsh => input.to_owned(),
311        }
312    }
313
314    fn to_cmd_variable(input: &str) -> String {
315        if let Some(var_str) = input.strip_prefix("${") {
316            match var_str.strip_suffix('}') {
317                Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
318                    format!("%{var_name}%")
319                }
320                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
321                // which will result in the task failing to run in such cases.
322                _ => input.into(),
323            }
324        } else if let Some(var_str) = input.strip_prefix('$') {
325            // If the input starts with "$", directly append to "$env:"
326            format!("%{}%", var_str)
327        } else {
328            // If no prefix is found, return the input as is
329            input.into()
330        }
331    }
332
333    fn to_powershell_variable(input: &str) -> String {
334        if let Some(var_str) = input.strip_prefix("${") {
335            match var_str.strip_suffix('}') {
336                Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
337                    format!("$env:{var_name}")
338                }
339                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
340                // which will result in the task failing to run in such cases.
341                _ => input.into(),
342            }
343        } else if let Some(var_str) = input.strip_prefix('$') {
344            // If the input starts with "$", directly append to "$env:"
345            format!("$env:{}", var_str)
346        } else {
347            // If no prefix is found, return the input as is
348            input.into()
349        }
350    }
351
352    fn to_nushell_variable(input: &str) -> String {
353        let mut result = String::new();
354        let mut source = input;
355        let mut is_start = true;
356
357        loop {
358            match source.chars().next() {
359                None => return result,
360                Some('$') => {
361                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
362                    is_start = false;
363                }
364                Some(_) => {
365                    is_start = false;
366                    let chunk_end = source.find('$').unwrap_or(source.len());
367                    let (chunk, rest) = source.split_at(chunk_end);
368                    result.push_str(chunk);
369                    source = rest;
370                }
371            }
372        }
373    }
374
375    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
376        if source.starts_with("env.") {
377            text.push('$');
378            return source;
379        }
380
381        match source.chars().next() {
382            Some('{') => {
383                let source = &source[1..];
384                if let Some(end) = source.find('}') {
385                    let var_name = &source[..end];
386                    if !var_name.is_empty() {
387                        if !is_start {
388                            text.push_str("(");
389                        }
390                        text.push_str("$env.");
391                        text.push_str(var_name);
392                        if !is_start {
393                            text.push_str(")");
394                        }
395                        &source[end + 1..]
396                    } else {
397                        text.push_str("${}");
398                        &source[end + 1..]
399                    }
400                } else {
401                    text.push_str("${");
402                    source
403                }
404            }
405            Some(c) if c.is_alphabetic() || c == '_' => {
406                let end = source
407                    .find(|c: char| !c.is_alphanumeric() && c != '_')
408                    .unwrap_or(source.len());
409                let var_name = &source[..end];
410                if !is_start {
411                    text.push_str("(");
412                }
413                text.push_str("$env.");
414                text.push_str(var_name);
415                if !is_start {
416                    text.push_str(")");
417                }
418                &source[end..]
419            }
420            _ => {
421                text.push('$');
422                source
423            }
424        }
425    }
426
427    pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
428        match self {
429            ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
430            ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
431            ShellKind::Posix
432            | ShellKind::Nushell
433            | ShellKind::Fish
434            | ShellKind::Csh
435            | ShellKind::Tcsh
436            | ShellKind::Rc
437            | ShellKind::Xonsh => interactive
438                .then(|| "-i".to_owned())
439                .into_iter()
440                .chain(["-c".to_owned(), combined_command])
441                .collect(),
442        }
443    }
444
445    pub const fn command_prefix(&self) -> Option<char> {
446        match self {
447            ShellKind::PowerShell => Some('&'),
448            ShellKind::Nushell => Some('^'),
449            _ => None,
450        }
451    }
452
453    pub const fn sequential_commands_separator(&self) -> char {
454        match self {
455            ShellKind::Cmd => '&',
456            _ => ';',
457        }
458    }
459
460    pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
461        shlex::try_quote(arg).ok().map(|arg| match self {
462            // If we are running in PowerShell, we want to take extra care when escaping strings.
463            // In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
464            // TODO double escaping backslashes is not necessary in PowerShell and probably CMD
465            ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"")),
466            _ => arg,
467        })
468    }
469
470    pub const fn activate_keyword(&self) -> &'static str {
471        match self {
472            ShellKind::Cmd => "",
473            ShellKind::Nushell => "overlay use",
474            ShellKind::PowerShell => ".",
475            ShellKind::Fish => "source",
476            ShellKind::Csh => "source",
477            ShellKind::Tcsh => "source",
478            ShellKind::Posix | ShellKind::Rc => "source",
479            ShellKind::Xonsh => "source",
480        }
481    }
482
483    pub const fn clear_screen_command(&self) -> &'static str {
484        match self {
485            ShellKind::Cmd => "cls",
486            _ => "clear",
487        }
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    fn git_for_windows_layout() -> tempfile::TempDir {
496        let dir = tempfile::tempdir().unwrap();
497        let bin = dir.path().join("bin");
498        std::fs::create_dir_all(&bin).unwrap();
499        std::fs::write(dir.path().join("git-bash.exe"), []).unwrap();
500        std::fs::write(bin.join("bash.exe"), []).unwrap();
501        dir
502    }
503
504    #[test]
505    fn test_to_shell_variable() {
506        assert_eq!(
507            ShellKind::PowerShell.to_shell_variable("${FOO}"),
508            "$env:FOO"
509        );
510        assert_eq!(ShellKind::Cmd.to_shell_variable("${FOO}"), "%FOO%");
511        assert_eq!(ShellKind::Nushell.to_shell_variable("${FOO}"), "$env.FOO");
512        assert_eq!(ShellKind::Posix.to_shell_variable("${FOO}"), "${FOO}");
513
514        assert_eq!(ShellKind::PowerShell.to_shell_variable("$FOO"), "$env:FOO");
515        assert_eq!(
516            ShellKind::PowerShell.to_shell_variable("${日本}"),
517            "$env:日本"
518        );
519        assert_eq!(
520            ShellKind::PowerShell.to_shell_variable("${FOO:-bar}"),
521            "${FOO:-bar}"
522        );
523    }
524
525    #[test]
526    fn test_to_shell_variable_malformed_is_passed_through() {
527        for input in ["${", "${FOO", "${café", "${}", "${日本"] {
528            for shell_kind in [ShellKind::PowerShell, ShellKind::Cmd, ShellKind::Nushell] {
529                assert_eq!(shell_kind.to_shell_variable(input), input);
530            }
531        }
532    }
533
534    #[test]
535    fn git_bash_requires_git_bash_exe_and_bin_bash() {
536        let dir = tempfile::tempdir().unwrap();
537        let bin = dir.path().join("bin");
538        std::fs::create_dir_all(&bin).unwrap();
539        std::fs::write(bin.join("bash.exe"), []).unwrap();
540        assert!(find_bash_in_installation(dir.path()).is_none());
541
542        std::fs::write(dir.path().join("git-bash.exe"), []).unwrap();
543        assert_eq!(
544            find_bash_in_installation(dir.path()).as_deref(),
545            Some(bin.join("bash.exe").as_path())
546        );
547    }
548
549    #[test]
550    fn git_bash_from_cmd_git_uses_install_root() {
551        let install = git_for_windows_layout();
552        let cmd = install.path().join("cmd");
553        std::fs::create_dir_all(&cmd).unwrap();
554        let git = cmd.join("git.exe");
555        std::fs::write(&git, []).unwrap();
556        assert_eq!(
557            find_bash_from_git_binary(&git).unwrap(),
558            install.path().join("bin").join("bash.exe")
559        );
560    }
561
562    #[test]
563    fn git_bash_from_mingw64_bin_walks_up_to_install_root() {
564        let install = git_for_windows_layout();
565        let mingw_bin = install.path().join("mingw64").join("bin");
566        std::fs::create_dir_all(&mingw_bin).unwrap();
567        let git = mingw_bin.join("git.exe");
568        std::fs::write(&git, []).unwrap();
569        assert_eq!(
570            find_bash_from_git_binary(&git).unwrap(),
571            install.path().join("bin").join("bash.exe")
572        );
573    }
574
575    #[test]
576    fn git_install_root_takes_precedence_over_git_binary() {
577        let preferred = git_for_windows_layout();
578        let other = git_for_windows_layout();
579        let git = other.path().join("cmd").join("git.exe");
580        std::fs::create_dir_all(git.parent().unwrap()).unwrap();
581        std::fs::write(&git, []).unwrap();
582        assert_eq!(
583            find_bash_using_git_install(Some(preferred.path().to_path_buf()), Some(&git)).unwrap(),
584            preferred.path().join("bin").join("bash.exe")
585        );
586    }
587
588    #[test]
589    fn empty_scoop_falls_back_to_userprofile() {
590        assert_eq!(
591            scoop_install_root(Some(OsStr::new("")), Some(OsStr::new(r"C:\Users\someone")))
592                .unwrap(),
593            PathBuf::from(r"C:\Users\someone").join("scoop")
594        );
595    }
596
597    #[test]
598    fn scoop_env_overrides_userprofile_when_non_empty() {
599        assert_eq!(
600            scoop_install_root(
601                Some(OsStr::new(r"D:\Apps\scoop")),
602                Some(OsStr::new(r"C:\Users\someone"))
603            )
604            .unwrap(),
605            PathBuf::from(r"D:\Apps\scoop")
606        );
607    }
608
609    #[test]
610    fn scoop_root_is_none_when_scoop_and_userprofile_are_missing() {
611        assert!(scoop_install_root(None, None).is_none());
612        assert!(scoop_install_root(Some(OsStr::new("")), None).is_none());
613    }
614
615    #[cfg(not(windows))]
616    #[test]
617    fn unix_stubs_for_windows_shell_discovery() {
618        assert_eq!(get_windows_system_shell(), "cmd.exe");
619        assert_eq!(get_default_system_shell_preferring_bash(), "/bin/sh");
620        assert_eq!(get_default_system_shell(), "/bin/sh");
621    }
622}