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, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)]
  7#[serde(rename_all = "snake_case")]
  8pub enum Shell {
  9    /// Use the system's default terminal configuration in /etc/passwd
 10    #[default]
 11    System,
 12    /// Use a specific program with no arguments.
 13    Program(String),
 14    /// Use a specific program with arguments.
 15    WithArguments {
 16        /// The program to run.
 17        program: String,
 18        /// The arguments to pass to the program.
 19        args: Vec<String>,
 20        /// An optional string to override the title of the terminal tab
 21        title_override: Option<String>,
 22    },
 23}
 24
 25impl Shell {
 26    pub fn program(&self) -> String {
 27        match self {
 28            Shell::Program(program) => program.clone(),
 29            Shell::WithArguments { program, .. } => program.clone(),
 30            Shell::System => get_system_shell(),
 31        }
 32    }
 33
 34    pub fn program_and_args(&self) -> (String, &[String]) {
 35        match self {
 36            Shell::Program(program) => (program.clone(), &[]),
 37            Shell::WithArguments { program, args, .. } => (program.clone(), args),
 38            Shell::System => (get_system_shell(), &[]),
 39        }
 40    }
 41
 42    pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
 43        match self {
 44            Shell::Program(program) => ShellKind::new(program, is_windows),
 45            Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
 46            Shell::System => ShellKind::system(),
 47        }
 48    }
 49}
 50
 51#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
 52pub enum ShellKind {
 53    #[default]
 54    Posix,
 55    Csh,
 56    Tcsh,
 57    Rc,
 58    Fish,
 59    PowerShell,
 60    Nushell,
 61    Cmd,
 62    Xonsh,
 63}
 64
 65pub fn get_system_shell() -> String {
 66    if cfg!(windows) {
 67        get_windows_system_shell()
 68    } else {
 69        std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
 70    }
 71}
 72
 73pub fn get_default_system_shell() -> String {
 74    if cfg!(windows) {
 75        get_windows_system_shell()
 76    } else {
 77        "/bin/sh".to_string()
 78    }
 79}
 80
 81/// Get the default system shell, preferring git-bash on Windows.
 82pub fn get_default_system_shell_preferring_bash() -> String {
 83    if cfg!(windows) {
 84        get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell())
 85    } else {
 86        "/bin/sh".to_string()
 87    }
 88}
 89
 90pub fn get_windows_git_bash() -> Option<String> {
 91    static GIT_BASH: LazyLock<Option<String>> = LazyLock::new(|| {
 92        // /path/to/git/cmd/git.exe/../../bin/bash.exe
 93        let git = which::which("git").ok()?;
 94        let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
 95        if git_bash.is_file() {
 96            log::info!("Found git-bash at {}", git_bash.display());
 97            Some(git_bash.to_string_lossy().to_string())
 98        } else {
 99            None
100        }
101    });
102
103    (*GIT_BASH).clone()
104}
105
106pub fn get_windows_system_shell() -> String {
107    use std::path::PathBuf;
108
109    fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
110        #[cfg(target_pointer_width = "64")]
111        let env_var = if find_alternate {
112            "ProgramFiles(x86)"
113        } else {
114            "ProgramFiles"
115        };
116
117        #[cfg(target_pointer_width = "32")]
118        let env_var = if find_alternate {
119            "ProgramW6432"
120        } else {
121            "ProgramFiles"
122        };
123
124        let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
125        install_base_dir
126            .read_dir()
127            .ok()?
128            .filter_map(Result::ok)
129            .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
130            .filter_map(|entry| {
131                let dir_name = entry.file_name();
132                let dir_name = dir_name.to_string_lossy();
133
134                let version = if find_preview {
135                    let dash_index = dir_name.find('-')?;
136                    if &dir_name[dash_index + 1..] != "preview" {
137                        return None;
138                    };
139                    dir_name[..dash_index].parse::<u32>().ok()?
140                } else {
141                    dir_name.parse::<u32>().ok()?
142                };
143
144                let exe_path = entry.path().join("pwsh.exe");
145                if exe_path.exists() {
146                    Some((version, exe_path))
147                } else {
148                    None
149                }
150            })
151            .max_by_key(|(version, _)| *version)
152            .map(|(_, path)| path)
153    }
154
155    fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
156        let msix_app_dir =
157            PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
158        if !msix_app_dir.exists() {
159            return None;
160        }
161
162        let prefix = if find_preview {
163            "Microsoft.PowerShellPreview_"
164        } else {
165            "Microsoft.PowerShell_"
166        };
167        msix_app_dir
168            .read_dir()
169            .ok()?
170            .filter_map(|entry| {
171                let entry = entry.ok()?;
172                if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
173                    return None;
174                }
175
176                if !entry.file_name().to_string_lossy().starts_with(prefix) {
177                    return None;
178                }
179
180                let exe_path = entry.path().join("pwsh.exe");
181                exe_path.exists().then_some(exe_path)
182            })
183            .next()
184    }
185
186    fn find_pwsh_in_scoop() -> Option<PathBuf> {
187        let pwsh_exe =
188            PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
189        pwsh_exe.exists().then_some(pwsh_exe)
190    }
191
192    static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
193        find_pwsh_in_programfiles(false, false)
194            .or_else(|| find_pwsh_in_programfiles(true, false))
195            .or_else(|| find_pwsh_in_msix(false))
196            .or_else(|| find_pwsh_in_programfiles(false, true))
197            .or_else(|| find_pwsh_in_msix(true))
198            .or_else(|| find_pwsh_in_programfiles(true, true))
199            .or_else(find_pwsh_in_scoop)
200            .map(|p| p.to_string_lossy().into_owned())
201            .unwrap_or("powershell.exe".to_string())
202    });
203
204    (*SYSTEM_SHELL).clone()
205}
206
207impl fmt::Display for ShellKind {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        match self {
210            ShellKind::Posix => write!(f, "sh"),
211            ShellKind::Csh => write!(f, "csh"),
212            ShellKind::Tcsh => write!(f, "tcsh"),
213            ShellKind::Fish => write!(f, "fish"),
214            ShellKind::PowerShell => write!(f, "powershell"),
215            ShellKind::Nushell => write!(f, "nu"),
216            ShellKind::Cmd => write!(f, "cmd"),
217            ShellKind::Rc => write!(f, "rc"),
218            ShellKind::Xonsh => write!(f, "xonsh"),
219        }
220    }
221}
222
223impl ShellKind {
224    pub fn system() -> Self {
225        Self::new(&get_system_shell(), cfg!(windows))
226    }
227
228    pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
229        let program = program.as_ref();
230        let program = program
231            .file_stem()
232            .unwrap_or_else(|| program.as_os_str())
233            .to_string_lossy();
234
235        match &*program {
236            "powershell" | "pwsh" => ShellKind::PowerShell,
237            "cmd" => ShellKind::Cmd,
238            "nu" => ShellKind::Nushell,
239            "fish" => ShellKind::Fish,
240            "csh" => ShellKind::Csh,
241            "tcsh" => ShellKind::Tcsh,
242            "rc" => ShellKind::Rc,
243            "xonsh" => ShellKind::Xonsh,
244            "sh" | "bash" | "zsh" => ShellKind::Posix,
245            _ if is_windows => ShellKind::PowerShell,
246            // Some other shell detected, the user might install and use a
247            // unix-like shell.
248            _ => ShellKind::Posix,
249        }
250    }
251
252    pub fn to_shell_variable(self, input: &str) -> String {
253        match self {
254            Self::PowerShell => Self::to_powershell_variable(input),
255            Self::Cmd => Self::to_cmd_variable(input),
256            Self::Posix => input.to_owned(),
257            Self::Fish => input.to_owned(),
258            Self::Csh => input.to_owned(),
259            Self::Tcsh => input.to_owned(),
260            Self::Rc => input.to_owned(),
261            Self::Nushell => Self::to_nushell_variable(input),
262            Self::Xonsh => input.to_owned(),
263        }
264    }
265
266    fn to_cmd_variable(input: &str) -> String {
267        if let Some(var_str) = input.strip_prefix("${") {
268            if var_str.find(':').is_none() {
269                // If the input starts with "${", remove the trailing "}"
270                format!("%{}%", &var_str[..var_str.len() - 1])
271            } else {
272                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
273                // which will result in the task failing to run in such cases.
274                input.into()
275            }
276        } else if let Some(var_str) = input.strip_prefix('$') {
277            // If the input starts with "$", directly append to "$env:"
278            format!("%{}%", var_str)
279        } else {
280            // If no prefix is found, return the input as is
281            input.into()
282        }
283    }
284
285    fn to_powershell_variable(input: &str) -> String {
286        if let Some(var_str) = input.strip_prefix("${") {
287            if var_str.find(':').is_none() {
288                // If the input starts with "${", remove the trailing "}"
289                format!("$env:{}", &var_str[..var_str.len() - 1])
290            } else {
291                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
292                // which will result in the task failing to run in such cases.
293                input.into()
294            }
295        } else if let Some(var_str) = input.strip_prefix('$') {
296            // If the input starts with "$", directly append to "$env:"
297            format!("$env:{}", var_str)
298        } else {
299            // If no prefix is found, return the input as is
300            input.into()
301        }
302    }
303
304    fn to_nushell_variable(input: &str) -> String {
305        let mut result = String::new();
306        let mut source = input;
307        let mut is_start = true;
308
309        loop {
310            match source.chars().next() {
311                None => return result,
312                Some('$') => {
313                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
314                    is_start = false;
315                }
316                Some(_) => {
317                    is_start = false;
318                    let chunk_end = source.find('$').unwrap_or(source.len());
319                    let (chunk, rest) = source.split_at(chunk_end);
320                    result.push_str(chunk);
321                    source = rest;
322                }
323            }
324        }
325    }
326
327    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
328        if source.starts_with("env.") {
329            text.push('$');
330            return source;
331        }
332
333        match source.chars().next() {
334            Some('{') => {
335                let source = &source[1..];
336                if let Some(end) = source.find('}') {
337                    let var_name = &source[..end];
338                    if !var_name.is_empty() {
339                        if !is_start {
340                            text.push_str("(");
341                        }
342                        text.push_str("$env.");
343                        text.push_str(var_name);
344                        if !is_start {
345                            text.push_str(")");
346                        }
347                        &source[end + 1..]
348                    } else {
349                        text.push_str("${}");
350                        &source[end + 1..]
351                    }
352                } else {
353                    text.push_str("${");
354                    source
355                }
356            }
357            Some(c) if c.is_alphabetic() || c == '_' => {
358                let end = source
359                    .find(|c: char| !c.is_alphanumeric() && c != '_')
360                    .unwrap_or(source.len());
361                let var_name = &source[..end];
362                if !is_start {
363                    text.push_str("(");
364                }
365                text.push_str("$env.");
366                text.push_str(var_name);
367                if !is_start {
368                    text.push_str(")");
369                }
370                &source[end..]
371            }
372            _ => {
373                text.push('$');
374                source
375            }
376        }
377    }
378
379    pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
380        match self {
381            ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
382            ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
383            ShellKind::Posix
384            | ShellKind::Nushell
385            | ShellKind::Fish
386            | ShellKind::Csh
387            | ShellKind::Tcsh
388            | ShellKind::Rc
389            | ShellKind::Xonsh => interactive
390                .then(|| "-i".to_owned())
391                .into_iter()
392                .chain(["-c".to_owned(), combined_command])
393                .collect(),
394        }
395    }
396
397    pub const fn command_prefix(&self) -> Option<char> {
398        match self {
399            ShellKind::PowerShell => Some('&'),
400            ShellKind::Nushell => Some('^'),
401            ShellKind::Posix
402            | ShellKind::Csh
403            | ShellKind::Tcsh
404            | ShellKind::Rc
405            | ShellKind::Fish
406            | ShellKind::Cmd
407            | ShellKind::Xonsh => None,
408        }
409    }
410
411    pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
412        match self.command_prefix() {
413            Some(prefix) if !command.starts_with(prefix) => {
414                Cow::Owned(format!("{prefix}{command}"))
415            }
416            _ => Cow::Borrowed(command),
417        }
418    }
419
420    pub const fn sequential_commands_separator(&self) -> char {
421        match self {
422            ShellKind::Cmd => '&',
423            ShellKind::Posix
424            | ShellKind::Csh
425            | ShellKind::Tcsh
426            | ShellKind::Rc
427            | ShellKind::Fish
428            | ShellKind::PowerShell
429            | ShellKind::Nushell
430            | ShellKind::Xonsh => ';',
431        }
432    }
433
434    pub const fn sequential_and_commands_separator(&self) -> &'static str {
435        match self {
436            ShellKind::Cmd
437            | ShellKind::Posix
438            | ShellKind::Csh
439            | ShellKind::Tcsh
440            | ShellKind::Rc
441            | ShellKind::Fish
442            | ShellKind::PowerShell
443            | ShellKind::Xonsh => "&&",
444            ShellKind::Nushell => ";",
445        }
446    }
447
448    pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
449        shlex::try_quote(arg).ok().map(|arg| match self {
450            // If we are running in PowerShell, we want to take extra care when escaping strings.
451            // In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
452            ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")),
453            ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
454            ShellKind::Posix
455            | ShellKind::Csh
456            | ShellKind::Tcsh
457            | ShellKind::Rc
458            | ShellKind::Fish
459            | ShellKind::Nushell
460            | ShellKind::Xonsh => arg,
461        })
462    }
463
464    /// Quotes the given argument if necessary, taking into account the command prefix.
465    ///
466    /// In other words, this will consider quoting arg without its command prefix to not break the command.
467    /// You should use this over `try_quote` when you want to quote a shell command.
468    pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
469        if let Some(char) = self.command_prefix() {
470            if let Some(arg) = arg.strip_prefix(char) {
471                // we have a command that is prefixed
472                for quote in ['\'', '"'] {
473                    if let Some(arg) = arg
474                        .strip_prefix(quote)
475                        .and_then(|arg| arg.strip_suffix(quote))
476                    {
477                        // and the command itself is wrapped as a literal, that
478                        // means the prefix exists to interpret a literal as a
479                        // command. So strip the quotes, quote the command, and
480                        // re-add the quotes if they are missing after requoting
481                        let quoted = self.try_quote(arg)?;
482                        return Some(if quoted.starts_with(['\'', '"']) {
483                            Cow::Owned(self.prepend_command_prefix(&quoted).into_owned())
484                        } else {
485                            Cow::Owned(
486                                self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
487                                    .into_owned(),
488                            )
489                        });
490                    }
491                }
492                return self
493                    .try_quote(arg)
494                    .map(|quoted| Cow::Owned(self.prepend_command_prefix(&quoted).into_owned()));
495            }
496        }
497        self.try_quote(arg)
498    }
499
500    pub fn split(&self, input: &str) -> Option<Vec<String>> {
501        shlex::split(input)
502    }
503
504    pub const fn activate_keyword(&self) -> &'static str {
505        match self {
506            ShellKind::Cmd => "",
507            ShellKind::Nushell => "overlay use",
508            ShellKind::PowerShell => ".",
509            ShellKind::Fish
510            | ShellKind::Csh
511            | ShellKind::Tcsh
512            | ShellKind::Posix
513            | ShellKind::Rc
514            | ShellKind::Xonsh => "source",
515        }
516    }
517
518    pub const fn clear_screen_command(&self) -> &'static str {
519        match self {
520            ShellKind::Cmd => "cls",
521            ShellKind::Posix
522            | ShellKind::Csh
523            | ShellKind::Tcsh
524            | ShellKind::Rc
525            | ShellKind::Fish
526            | ShellKind::PowerShell
527            | ShellKind::Nushell
528            | ShellKind::Xonsh => "clear",
529        }
530    }
531
532    #[cfg(windows)]
533    /// We do not want to escape arguments if we are using CMD as our shell.
534    /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
535    pub const fn tty_escape_args(&self) -> bool {
536        match self {
537            ShellKind::Cmd => false,
538            ShellKind::Posix
539            | ShellKind::Csh
540            | ShellKind::Tcsh
541            | ShellKind::Rc
542            | ShellKind::Fish
543            | ShellKind::PowerShell
544            | ShellKind::Nushell
545            | ShellKind::Xonsh => true,
546        }
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    // Examples
555    // WSL
556    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
557    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
558    // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
559    // PowerShell from Nushell
560    // 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\""
561    // PowerShell from CMD
562    // 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\\\"\"\"
563
564    #[test]
565    fn test_try_quote_powershell() {
566        let shell_kind = ShellKind::PowerShell;
567        assert_eq!(
568            shell_kind
569                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
570                .unwrap()
571                .into_owned(),
572            "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest `\"test_foo.py::test_foo`\"\"".to_string()
573        );
574    }
575
576    #[test]
577    fn test_try_quote_cmd() {
578        let shell_kind = ShellKind::Cmd;
579        assert_eq!(
580            shell_kind
581                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
582                .unwrap()
583                .into_owned(),
584            "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"".to_string()
585        );
586    }
587
588    #[test]
589    fn test_try_quote_nu_command() {
590        let shell_kind = ShellKind::Nushell;
591        assert_eq!(
592            shell_kind.try_quote("'uname'").unwrap().into_owned(),
593            "\"'uname'\"".to_string()
594        );
595        assert_eq!(
596            shell_kind
597                .try_quote_prefix_aware("'uname'")
598                .unwrap()
599                .into_owned(),
600            "\"'uname'\"".to_string()
601        );
602        assert_eq!(
603            shell_kind.try_quote("^uname").unwrap().into_owned(),
604            "'^uname'".to_string()
605        );
606        assert_eq!(
607            shell_kind
608                .try_quote_prefix_aware("^uname")
609                .unwrap()
610                .into_owned(),
611            "^uname".to_string()
612        );
613        assert_eq!(
614            shell_kind.try_quote("^'uname'").unwrap().into_owned(),
615            "'^'\"'uname\'\"".to_string()
616        );
617        assert_eq!(
618            shell_kind
619                .try_quote_prefix_aware("^'uname'")
620                .unwrap()
621                .into_owned(),
622            "^'uname'".to_string()
623        );
624        assert_eq!(
625            shell_kind.try_quote("'uname a'").unwrap().into_owned(),
626            "\"'uname a'\"".to_string()
627        );
628        assert_eq!(
629            shell_kind
630                .try_quote_prefix_aware("'uname a'")
631                .unwrap()
632                .into_owned(),
633            "\"'uname a'\"".to_string()
634        );
635        assert_eq!(
636            shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
637            "'^'\"'uname a'\"".to_string()
638        );
639        assert_eq!(
640            shell_kind
641                .try_quote_prefix_aware("^'uname a'")
642                .unwrap()
643                .into_owned(),
644            "^'uname a'".to_string()
645        );
646        assert_eq!(
647            shell_kind.try_quote("uname").unwrap().into_owned(),
648            "uname".to_string()
649        );
650        assert_eq!(
651            shell_kind
652                .try_quote_prefix_aware("uname")
653                .unwrap()
654                .into_owned(),
655            "uname".to_string()
656        );
657    }
658}