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