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