shell.rs

  1use schemars::JsonSchema;
  2use serde::{Deserialize, Serialize};
  3use std::{borrow::Cow, fmt, path::Path, sync::LazyLock};
  4
  5/// Shell configuration to open the terminal with.
  6#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
  7pub enum Shell {
  8    /// Use the system's default terminal configuration in /etc/passwd
  9    #[default]
 10    System,
 11    /// Use a specific program with no arguments.
 12    Program(String),
 13    /// Use a specific program with arguments.
 14    WithArguments {
 15        /// The program to run.
 16        program: String,
 17        /// The arguments to pass to the program.
 18        args: Vec<String>,
 19        /// An optional string to override the title of the terminal tab
 20        title_override: Option<String>,
 21    },
 22}
 23
 24impl Shell {
 25    pub fn program(&self) -> String {
 26        match self {
 27            Shell::Program(program) => program.clone(),
 28            Shell::WithArguments { program, .. } => program.clone(),
 29            Shell::System => get_system_shell(),
 30        }
 31    }
 32
 33    pub fn program_and_args(&self) -> (String, &[String]) {
 34        match self {
 35            Shell::Program(program) => (program.clone(), &[]),
 36            Shell::WithArguments { program, args, .. } => (program.clone(), args),
 37            Shell::System => (get_system_shell(), &[]),
 38        }
 39    }
 40
 41    pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
 42        match self {
 43            Shell::Program(program) => ShellKind::new(program, is_windows),
 44            Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
 45            Shell::System => ShellKind::system(),
 46        }
 47    }
 48}
 49
 50#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
 51pub enum ShellKind {
 52    #[default]
 53    Posix,
 54    Csh,
 55    Tcsh,
 56    Rc,
 57    Fish,
 58    PowerShell,
 59    Nushell,
 60    Cmd,
 61    Xonsh,
 62}
 63
 64pub fn get_system_shell() -> String {
 65    if cfg!(windows) {
 66        get_windows_system_shell()
 67    } else {
 68        std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
 69    }
 70}
 71
 72pub fn get_default_system_shell() -> String {
 73    if cfg!(windows) {
 74        get_windows_system_shell()
 75    } else {
 76        "/bin/sh".to_string()
 77    }
 78}
 79
 80/// Get the default system shell, preferring git-bash on Windows.
 81pub fn get_default_system_shell_preferring_bash() -> String {
 82    if cfg!(windows) {
 83        get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell())
 84    } else {
 85        "/bin/sh".to_string()
 86    }
 87}
 88
 89pub fn get_windows_git_bash() -> Option<String> {
 90    static GIT_BASH: LazyLock<Option<String>> = LazyLock::new(|| {
 91        // /path/to/git/cmd/git.exe/../../bin/bash.exe
 92        let git = which::which("git").ok()?;
 93        let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
 94        if git_bash.is_file() {
 95            log::info!("Found git-bash at {}", git_bash.display());
 96            Some(git_bash.to_string_lossy().to_string())
 97        } else {
 98            None
 99        }
100    });
101
102    (*GIT_BASH).clone()
103}
104
105pub fn get_windows_system_shell() -> String {
106    use std::path::PathBuf;
107
108    fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
109        #[cfg(target_pointer_width = "64")]
110        let env_var = if find_alternate {
111            "ProgramFiles(x86)"
112        } else {
113            "ProgramFiles"
114        };
115
116        #[cfg(target_pointer_width = "32")]
117        let env_var = if find_alternate {
118            "ProgramW6432"
119        } else {
120            "ProgramFiles"
121        };
122
123        let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
124        install_base_dir
125            .read_dir()
126            .ok()?
127            .filter_map(Result::ok)
128            .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
129            .filter_map(|entry| {
130                let dir_name = entry.file_name();
131                let dir_name = dir_name.to_string_lossy();
132
133                let version = if find_preview {
134                    let dash_index = dir_name.find('-')?;
135                    if &dir_name[dash_index + 1..] != "preview" {
136                        return None;
137                    };
138                    dir_name[..dash_index].parse::<u32>().ok()?
139                } else {
140                    dir_name.parse::<u32>().ok()?
141                };
142
143                let exe_path = entry.path().join("pwsh.exe");
144                if exe_path.exists() {
145                    Some((version, exe_path))
146                } else {
147                    None
148                }
149            })
150            .max_by_key(|(version, _)| *version)
151            .map(|(_, path)| path)
152    }
153
154    fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
155        let msix_app_dir =
156            PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
157        if !msix_app_dir.exists() {
158            return None;
159        }
160
161        let prefix = if find_preview {
162            "Microsoft.PowerShellPreview_"
163        } else {
164            "Microsoft.PowerShell_"
165        };
166        msix_app_dir
167            .read_dir()
168            .ok()?
169            .filter_map(|entry| {
170                let entry = entry.ok()?;
171                if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
172                    return None;
173                }
174
175                if !entry.file_name().to_string_lossy().starts_with(prefix) {
176                    return None;
177                }
178
179                let exe_path = entry.path().join("pwsh.exe");
180                exe_path.exists().then_some(exe_path)
181            })
182            .next()
183    }
184
185    fn find_pwsh_in_scoop() -> Option<PathBuf> {
186        let pwsh_exe =
187            PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
188        pwsh_exe.exists().then_some(pwsh_exe)
189    }
190
191    static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
192        find_pwsh_in_programfiles(false, false)
193            .or_else(|| find_pwsh_in_programfiles(true, false))
194            .or_else(|| find_pwsh_in_msix(false))
195            .or_else(|| find_pwsh_in_programfiles(false, true))
196            .or_else(|| find_pwsh_in_msix(true))
197            .or_else(|| find_pwsh_in_programfiles(true, true))
198            .or_else(find_pwsh_in_scoop)
199            .map(|p| p.to_string_lossy().into_owned())
200            .unwrap_or("powershell.exe".to_string())
201    });
202
203    (*SYSTEM_SHELL).clone()
204}
205
206impl fmt::Display for ShellKind {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        match self {
209            ShellKind::Posix => write!(f, "sh"),
210            ShellKind::Csh => write!(f, "csh"),
211            ShellKind::Tcsh => write!(f, "tcsh"),
212            ShellKind::Fish => write!(f, "fish"),
213            ShellKind::PowerShell => write!(f, "powershell"),
214            ShellKind::Nushell => write!(f, "nu"),
215            ShellKind::Cmd => write!(f, "cmd"),
216            ShellKind::Rc => write!(f, "rc"),
217            ShellKind::Xonsh => write!(f, "xonsh"),
218        }
219    }
220}
221
222impl ShellKind {
223    pub fn system() -> Self {
224        Self::new(&get_system_shell(), cfg!(windows))
225    }
226
227    pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
228        let program = program.as_ref();
229        let program = program
230            .file_stem()
231            .unwrap_or_else(|| program.as_os_str())
232            .to_string_lossy();
233
234        match &*program {
235            "powershell" | "pwsh" => ShellKind::PowerShell,
236            "cmd" => ShellKind::Cmd,
237            "nu" => ShellKind::Nushell,
238            "fish" => ShellKind::Fish,
239            "csh" => ShellKind::Csh,
240            "tcsh" => ShellKind::Tcsh,
241            "rc" => ShellKind::Rc,
242            "xonsh" => ShellKind::Xonsh,
243            "sh" | "bash" => ShellKind::Posix,
244            _ if is_windows => ShellKind::PowerShell,
245            // Some other shell detected, the user might install and use a
246            // unix-like shell.
247            _ => ShellKind::Posix,
248        }
249    }
250
251    pub fn to_shell_variable(self, input: &str) -> String {
252        match self {
253            Self::PowerShell => Self::to_powershell_variable(input),
254            Self::Cmd => Self::to_cmd_variable(input),
255            Self::Posix => input.to_owned(),
256            Self::Fish => input.to_owned(),
257            Self::Csh => input.to_owned(),
258            Self::Tcsh => input.to_owned(),
259            Self::Rc => input.to_owned(),
260            Self::Nushell => Self::to_nushell_variable(input),
261            Self::Xonsh => input.to_owned(),
262        }
263    }
264
265    fn to_cmd_variable(input: &str) -> String {
266        if let Some(var_str) = input.strip_prefix("${") {
267            if var_str.find(':').is_none() {
268                // If the input starts with "${", remove the trailing "}"
269                format!("%{}%", &var_str[..var_str.len() - 1])
270            } else {
271                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
272                // which will result in the task failing to run in such cases.
273                input.into()
274            }
275        } else if let Some(var_str) = input.strip_prefix('$') {
276            // If the input starts with "$", directly append to "$env:"
277            format!("%{}%", var_str)
278        } else {
279            // If no prefix is found, return the input as is
280            input.into()
281        }
282    }
283
284    fn to_powershell_variable(input: &str) -> String {
285        if let Some(var_str) = input.strip_prefix("${") {
286            if var_str.find(':').is_none() {
287                // If the input starts with "${", remove the trailing "}"
288                format!("$env:{}", &var_str[..var_str.len() - 1])
289            } else {
290                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
291                // which will result in the task failing to run in such cases.
292                input.into()
293            }
294        } else if let Some(var_str) = input.strip_prefix('$') {
295            // If the input starts with "$", directly append to "$env:"
296            format!("$env:{}", var_str)
297        } else {
298            // If no prefix is found, return the input as is
299            input.into()
300        }
301    }
302
303    fn to_nushell_variable(input: &str) -> String {
304        let mut result = String::new();
305        let mut source = input;
306        let mut is_start = true;
307
308        loop {
309            match source.chars().next() {
310                None => return result,
311                Some('$') => {
312                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
313                    is_start = false;
314                }
315                Some(_) => {
316                    is_start = false;
317                    let chunk_end = source.find('$').unwrap_or(source.len());
318                    let (chunk, rest) = source.split_at(chunk_end);
319                    result.push_str(chunk);
320                    source = rest;
321                }
322            }
323        }
324    }
325
326    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
327        if source.starts_with("env.") {
328            text.push('$');
329            return source;
330        }
331
332        match source.chars().next() {
333            Some('{') => {
334                let source = &source[1..];
335                if let Some(end) = source.find('}') {
336                    let var_name = &source[..end];
337                    if !var_name.is_empty() {
338                        if !is_start {
339                            text.push_str("(");
340                        }
341                        text.push_str("$env.");
342                        text.push_str(var_name);
343                        if !is_start {
344                            text.push_str(")");
345                        }
346                        &source[end + 1..]
347                    } else {
348                        text.push_str("${}");
349                        &source[end + 1..]
350                    }
351                } else {
352                    text.push_str("${");
353                    source
354                }
355            }
356            Some(c) if c.is_alphabetic() || c == '_' => {
357                let end = source
358                    .find(|c: char| !c.is_alphanumeric() && c != '_')
359                    .unwrap_or(source.len());
360                let var_name = &source[..end];
361                if !is_start {
362                    text.push_str("(");
363                }
364                text.push_str("$env.");
365                text.push_str(var_name);
366                if !is_start {
367                    text.push_str(")");
368                }
369                &source[end..]
370            }
371            _ => {
372                text.push('$');
373                source
374            }
375        }
376    }
377
378    pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
379        match self {
380            ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
381            ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
382            ShellKind::Posix
383            | ShellKind::Nushell
384            | ShellKind::Fish
385            | ShellKind::Csh
386            | ShellKind::Tcsh
387            | ShellKind::Rc
388            | ShellKind::Xonsh => interactive
389                .then(|| "-i".to_owned())
390                .into_iter()
391                .chain(["-c".to_owned(), combined_command])
392                .collect(),
393        }
394    }
395
396    pub const fn command_prefix(&self) -> Option<char> {
397        match self {
398            ShellKind::PowerShell => Some('&'),
399            ShellKind::Nushell => Some('^'),
400            ShellKind::Posix
401            | ShellKind::Csh
402            | ShellKind::Tcsh
403            | ShellKind::Rc
404            | ShellKind::Fish
405            | ShellKind::Cmd
406            | ShellKind::Xonsh => None,
407        }
408    }
409
410    pub const fn sequential_commands_separator(&self) -> char {
411        match self {
412            ShellKind::Cmd => '&',
413            ShellKind::Posix
414            | ShellKind::Csh
415            | ShellKind::Tcsh
416            | ShellKind::Rc
417            | ShellKind::Fish
418            | ShellKind::PowerShell
419            | ShellKind::Nushell
420            | ShellKind::Xonsh => ';',
421        }
422    }
423
424    pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
425        shlex::try_quote(arg).ok().map(|arg| match self {
426            ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")),
427            ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
428            ShellKind::Posix
429            | ShellKind::Csh
430            | ShellKind::Tcsh
431            | ShellKind::Rc
432            | ShellKind::Fish
433            | ShellKind::Nushell
434            | ShellKind::Xonsh => arg,
435        })
436    }
437
438    pub const fn activate_keyword(&self) -> &'static str {
439        match self {
440            ShellKind::Cmd => "",
441            ShellKind::Nushell => "overlay use",
442            ShellKind::PowerShell => ".",
443            ShellKind::Fish
444            | ShellKind::Csh
445            | ShellKind::Tcsh
446            | ShellKind::Posix
447            | ShellKind::Rc
448            | ShellKind::Xonsh => "source",
449        }
450    }
451
452    pub const fn clear_screen_command(&self) -> &'static str {
453        match self {
454            ShellKind::Cmd => "cls",
455            ShellKind::Posix
456            | ShellKind::Csh
457            | ShellKind::Tcsh
458            | ShellKind::Rc
459            | ShellKind::Fish
460            | ShellKind::PowerShell
461            | ShellKind::Nushell
462            | ShellKind::Xonsh => "clear",
463        }
464    }
465
466    #[cfg(windows)]
467    /// We do not want to escape arguments if we are using CMD as our shell.
468    /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
469    pub const fn tty_escape_args(&self) -> bool {
470        match self {
471            ShellKind::Cmd => false,
472            ShellKind::Posix
473            | ShellKind::Csh
474            | ShellKind::Tcsh
475            | ShellKind::Rc
476            | ShellKind::Fish
477            | ShellKind::PowerShell
478            | ShellKind::Nushell
479            | ShellKind::Xonsh => true,
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    // Examples
489    // WSL
490    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
491    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
492    // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
493    // PowerShell from Nushell
494    // nu -c overlay use "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\activate.nu"; ^"C:\Program Files\PowerShell\7\pwsh.exe" -C "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\python.exe -m pytest \"test_foo.py::test_foo\""
495    // PowerShell from CMD
496    // cmd /C \" \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\activate.bat\"& \"C:\\\\Program Files\\\\PowerShell\\\\7\\\\pwsh.exe\" -C \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"\"
497
498    #[test]
499    fn test_try_quote_powershell() {
500        let shell_kind = ShellKind::PowerShell;
501        assert_eq!(
502            shell_kind
503                .try_quote("pwsh.exe -c \"echo \"hello there\"\"")
504                .unwrap()
505                .into_owned(),
506            "\"echo `\"hello there`\"\"".to_string()
507        );
508    }
509}