shell.rs

  1use std::{borrow::Cow, fmt, path::Path, sync::LazyLock};
  2
  3#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
  4pub enum ShellKind {
  5    #[default]
  6    Posix,
  7    Csh,
  8    Tcsh,
  9    Rc,
 10    Fish,
 11    PowerShell,
 12    Nushell,
 13    Cmd,
 14    Xonsh,
 15}
 16
 17pub fn get_system_shell() -> String {
 18    if cfg!(windows) {
 19        get_windows_system_shell()
 20    } else {
 21        std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
 22    }
 23}
 24
 25pub fn get_default_system_shell() -> String {
 26    if cfg!(windows) {
 27        get_windows_system_shell()
 28    } else {
 29        "/bin/sh".to_string()
 30    }
 31}
 32
 33/// Get the default system shell, preferring git-bash on Windows.
 34pub fn get_default_system_shell_preferring_bash() -> String {
 35    if cfg!(windows) {
 36        get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell())
 37    } else {
 38        "/bin/sh".to_string()
 39    }
 40}
 41
 42pub fn get_windows_git_bash() -> Option<String> {
 43    static GIT_BASH: LazyLock<Option<String>> = LazyLock::new(|| {
 44        // /path/to/git/cmd/git.exe/../../bin/bash.exe
 45        let git = which::which("git").ok()?;
 46        let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
 47        if git_bash.is_file() {
 48            Some(git_bash.to_string_lossy().to_string())
 49        } else {
 50            None
 51        }
 52    });
 53
 54    (*GIT_BASH).clone()
 55}
 56
 57pub fn get_windows_system_shell() -> String {
 58    use std::path::PathBuf;
 59
 60    fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
 61        #[cfg(target_pointer_width = "64")]
 62        let env_var = if find_alternate {
 63            "ProgramFiles(x86)"
 64        } else {
 65            "ProgramFiles"
 66        };
 67
 68        #[cfg(target_pointer_width = "32")]
 69        let env_var = if find_alternate {
 70            "ProgramW6432"
 71        } else {
 72            "ProgramFiles"
 73        };
 74
 75        let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
 76        install_base_dir
 77            .read_dir()
 78            .ok()?
 79            .filter_map(Result::ok)
 80            .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
 81            .filter_map(|entry| {
 82                let dir_name = entry.file_name();
 83                let dir_name = dir_name.to_string_lossy();
 84
 85                let version = if find_preview {
 86                    let dash_index = dir_name.find('-')?;
 87                    if &dir_name[dash_index + 1..] != "preview" {
 88                        return None;
 89                    };
 90                    dir_name[..dash_index].parse::<u32>().ok()?
 91                } else {
 92                    dir_name.parse::<u32>().ok()?
 93                };
 94
 95                let exe_path = entry.path().join("pwsh.exe");
 96                if exe_path.exists() {
 97                    Some((version, exe_path))
 98                } else {
 99                    None
100                }
101            })
102            .max_by_key(|(version, _)| *version)
103            .map(|(_, path)| path)
104    }
105
106    fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
107        let msix_app_dir =
108            PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
109        if !msix_app_dir.exists() {
110            return None;
111        }
112
113        let prefix = if find_preview {
114            "Microsoft.PowerShellPreview_"
115        } else {
116            "Microsoft.PowerShell_"
117        };
118        msix_app_dir
119            .read_dir()
120            .ok()?
121            .filter_map(|entry| {
122                let entry = entry.ok()?;
123                if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
124                    return None;
125                }
126
127                if !entry.file_name().to_string_lossy().starts_with(prefix) {
128                    return None;
129                }
130
131                let exe_path = entry.path().join("pwsh.exe");
132                exe_path.exists().then_some(exe_path)
133            })
134            .next()
135    }
136
137    fn find_pwsh_in_scoop() -> Option<PathBuf> {
138        let pwsh_exe =
139            PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
140        pwsh_exe.exists().then_some(pwsh_exe)
141    }
142
143    static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
144        find_pwsh_in_programfiles(false, false)
145            .or_else(|| find_pwsh_in_programfiles(true, false))
146            .or_else(|| find_pwsh_in_msix(false))
147            .or_else(|| find_pwsh_in_programfiles(false, true))
148            .or_else(|| find_pwsh_in_msix(true))
149            .or_else(|| find_pwsh_in_programfiles(true, true))
150            .or_else(find_pwsh_in_scoop)
151            .map(|p| p.to_string_lossy().into_owned())
152            .unwrap_or("powershell.exe".to_string())
153    });
154
155    (*SYSTEM_SHELL).clone()
156}
157
158impl fmt::Display for ShellKind {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        match self {
161            ShellKind::Posix => write!(f, "sh"),
162            ShellKind::Csh => write!(f, "csh"),
163            ShellKind::Tcsh => write!(f, "tcsh"),
164            ShellKind::Fish => write!(f, "fish"),
165            ShellKind::PowerShell => write!(f, "powershell"),
166            ShellKind::Nushell => write!(f, "nu"),
167            ShellKind::Cmd => write!(f, "cmd"),
168            ShellKind::Rc => write!(f, "rc"),
169            ShellKind::Xonsh => write!(f, "xonsh"),
170        }
171    }
172}
173
174impl ShellKind {
175    pub fn system() -> Self {
176        Self::new(&get_system_shell(), cfg!(windows))
177    }
178
179    pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
180        let program = program.as_ref();
181        let program = program
182            .file_stem()
183            .unwrap_or_else(|| program.as_os_str())
184            .to_string_lossy();
185
186        if program == "powershell" || program == "pwsh" {
187            ShellKind::PowerShell
188        } else if program == "cmd" {
189            ShellKind::Cmd
190        } else if program == "nu" {
191            ShellKind::Nushell
192        } else if program == "fish" {
193            ShellKind::Fish
194        } else if program == "csh" {
195            ShellKind::Csh
196        } else if program == "tcsh" {
197            ShellKind::Tcsh
198        } else if program == "rc" {
199            ShellKind::Rc
200        } else if program == "xonsh" {
201            ShellKind::Xonsh
202        } else if program == "sh" || program == "bash" {
203            ShellKind::Posix
204        } else {
205            if is_windows {
206                ShellKind::PowerShell
207            } else {
208                // Some other shell detected, the user might install and use a
209                // unix-like shell.
210                ShellKind::Posix
211            }
212        }
213    }
214
215    pub fn to_shell_variable(self, input: &str) -> String {
216        match self {
217            Self::PowerShell => Self::to_powershell_variable(input),
218            Self::Cmd => Self::to_cmd_variable(input),
219            Self::Posix => input.to_owned(),
220            Self::Fish => input.to_owned(),
221            Self::Csh => input.to_owned(),
222            Self::Tcsh => input.to_owned(),
223            Self::Rc => input.to_owned(),
224            Self::Nushell => Self::to_nushell_variable(input),
225            Self::Xonsh => input.to_owned(),
226        }
227    }
228
229    fn to_cmd_variable(input: &str) -> String {
230        if let Some(var_str) = input.strip_prefix("${") {
231            if var_str.find(':').is_none() {
232                // If the input starts with "${", remove the trailing "}"
233                format!("%{}%", &var_str[..var_str.len() - 1])
234            } else {
235                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
236                // which will result in the task failing to run in such cases.
237                input.into()
238            }
239        } else if let Some(var_str) = input.strip_prefix('$') {
240            // If the input starts with "$", directly append to "$env:"
241            format!("%{}%", var_str)
242        } else {
243            // If no prefix is found, return the input as is
244            input.into()
245        }
246    }
247
248    fn to_powershell_variable(input: &str) -> String {
249        if let Some(var_str) = input.strip_prefix("${") {
250            if var_str.find(':').is_none() {
251                // If the input starts with "${", remove the trailing "}"
252                format!("$env:{}", &var_str[..var_str.len() - 1])
253            } else {
254                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
255                // which will result in the task failing to run in such cases.
256                input.into()
257            }
258        } else if let Some(var_str) = input.strip_prefix('$') {
259            // If the input starts with "$", directly append to "$env:"
260            format!("$env:{}", var_str)
261        } else {
262            // If no prefix is found, return the input as is
263            input.into()
264        }
265    }
266
267    fn to_nushell_variable(input: &str) -> String {
268        let mut result = String::new();
269        let mut source = input;
270        let mut is_start = true;
271
272        loop {
273            match source.chars().next() {
274                None => return result,
275                Some('$') => {
276                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
277                    is_start = false;
278                }
279                Some(_) => {
280                    is_start = false;
281                    let chunk_end = source.find('$').unwrap_or(source.len());
282                    let (chunk, rest) = source.split_at(chunk_end);
283                    result.push_str(chunk);
284                    source = rest;
285                }
286            }
287        }
288    }
289
290    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
291        if source.starts_with("env.") {
292            text.push('$');
293            return source;
294        }
295
296        match source.chars().next() {
297            Some('{') => {
298                let source = &source[1..];
299                if let Some(end) = source.find('}') {
300                    let var_name = &source[..end];
301                    if !var_name.is_empty() {
302                        if !is_start {
303                            text.push_str("(");
304                        }
305                        text.push_str("$env.");
306                        text.push_str(var_name);
307                        if !is_start {
308                            text.push_str(")");
309                        }
310                        &source[end + 1..]
311                    } else {
312                        text.push_str("${}");
313                        &source[end + 1..]
314                    }
315                } else {
316                    text.push_str("${");
317                    source
318                }
319            }
320            Some(c) if c.is_alphabetic() || c == '_' => {
321                let end = source
322                    .find(|c: char| !c.is_alphanumeric() && c != '_')
323                    .unwrap_or(source.len());
324                let var_name = &source[..end];
325                if !is_start {
326                    text.push_str("(");
327                }
328                text.push_str("$env.");
329                text.push_str(var_name);
330                if !is_start {
331                    text.push_str(")");
332                }
333                &source[end..]
334            }
335            _ => {
336                text.push('$');
337                source
338            }
339        }
340    }
341
342    pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
343        match self {
344            ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
345            ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
346            ShellKind::Posix
347            | ShellKind::Nushell
348            | ShellKind::Fish
349            | ShellKind::Csh
350            | ShellKind::Tcsh
351            | ShellKind::Rc
352            | ShellKind::Xonsh => interactive
353                .then(|| "-i".to_owned())
354                .into_iter()
355                .chain(["-c".to_owned(), combined_command])
356                .collect(),
357        }
358    }
359
360    pub const fn command_prefix(&self) -> Option<char> {
361        match self {
362            ShellKind::PowerShell => Some('&'),
363            ShellKind::Nushell => Some('^'),
364            _ => None,
365        }
366    }
367
368    pub const fn sequential_commands_separator(&self) -> char {
369        match self {
370            ShellKind::Cmd => '&',
371            _ => ';',
372        }
373    }
374
375    pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
376        shlex::try_quote(arg).ok().map(|arg| match self {
377            // If we are running in PowerShell, we want to take extra care when escaping strings.
378            // In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
379            // TODO double escaping backslashes is not necessary in PowerShell and probably CMD
380            ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"")),
381            _ => arg,
382        })
383    }
384
385    pub const fn activate_keyword(&self) -> &'static str {
386        match self {
387            ShellKind::Cmd => "",
388            ShellKind::Nushell => "overlay use",
389            ShellKind::PowerShell => ".",
390            ShellKind::Fish => "source",
391            ShellKind::Csh => "source",
392            ShellKind::Tcsh => "source",
393            ShellKind::Posix | ShellKind::Rc => "source",
394            ShellKind::Xonsh => "source",
395        }
396    }
397
398    pub const fn clear_screen_command(&self) -> &'static str {
399        match self {
400            ShellKind::Cmd => "cls",
401            _ => "clear",
402        }
403    }
404}