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