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 const fn sequential_commands_separator(&self) -> char {
412        match self {
413            ShellKind::Cmd => '&',
414            ShellKind::Posix
415            | ShellKind::Csh
416            | ShellKind::Tcsh
417            | ShellKind::Rc
418            | ShellKind::Fish
419            | ShellKind::PowerShell
420            | ShellKind::Nushell
421            | ShellKind::Xonsh => ';',
422        }
423    }
424
425    pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
426        shlex::try_quote(arg).ok().map(|arg| match self {
427            // If we are running in PowerShell, we want to take extra care when escaping strings.
428            // In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
429            ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")),
430            ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
431            ShellKind::Posix
432            | ShellKind::Csh
433            | ShellKind::Tcsh
434            | ShellKind::Rc
435            | ShellKind::Fish
436            | ShellKind::Nushell
437            | ShellKind::Xonsh => arg,
438        })
439    }
440
441    pub fn split(&self, input: &str) -> Option<Vec<String>> {
442        shlex::split(input)
443    }
444
445    pub const fn activate_keyword(&self) -> &'static str {
446        match self {
447            ShellKind::Cmd => "",
448            ShellKind::Nushell => "overlay use",
449            ShellKind::PowerShell => ".",
450            ShellKind::Fish
451            | ShellKind::Csh
452            | ShellKind::Tcsh
453            | ShellKind::Posix
454            | ShellKind::Rc
455            | ShellKind::Xonsh => "source",
456        }
457    }
458
459    pub const fn clear_screen_command(&self) -> &'static str {
460        match self {
461            ShellKind::Cmd => "cls",
462            ShellKind::Posix
463            | ShellKind::Csh
464            | ShellKind::Tcsh
465            | ShellKind::Rc
466            | ShellKind::Fish
467            | ShellKind::PowerShell
468            | ShellKind::Nushell
469            | ShellKind::Xonsh => "clear",
470        }
471    }
472
473    #[cfg(windows)]
474    /// We do not want to escape arguments if we are using CMD as our shell.
475    /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
476    pub const fn tty_escape_args(&self) -> bool {
477        match self {
478            ShellKind::Cmd => false,
479            ShellKind::Posix
480            | ShellKind::Csh
481            | ShellKind::Tcsh
482            | ShellKind::Rc
483            | ShellKind::Fish
484            | ShellKind::PowerShell
485            | ShellKind::Nushell
486            | ShellKind::Xonsh => true,
487        }
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    // Examples
496    // WSL
497    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
498    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
499    // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
500    // PowerShell from Nushell
501    // 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\""
502    // PowerShell from CMD
503    // 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\\\"\"\"
504
505    #[test]
506    fn test_try_quote_powershell() {
507        let shell_kind = ShellKind::PowerShell;
508        assert_eq!(
509            shell_kind
510                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
511                .unwrap()
512                .into_owned(),
513            "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest `\"test_foo.py::test_foo`\"\"".to_string()
514        );
515    }
516
517    #[test]
518    fn test_try_quote_cmd() {
519        let shell_kind = ShellKind::Cmd;
520        assert_eq!(
521            shell_kind
522                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
523                .unwrap()
524                .into_owned(),
525            "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"".to_string()
526        );
527    }
528}