Skip to main content

util/
shell.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::{borrow::Cow, fmt, path::Path};
4#[cfg(windows)]
5use std::{path::PathBuf, sync::LazyLock};
6
7/// Shell configuration to open the terminal with.
8#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)]
9#[serde(rename_all = "snake_case")]
10pub enum Shell {
11    /// Use the system's default terminal configuration in /etc/passwd
12    #[default]
13    System,
14    /// Use a specific program with no arguments.
15    Program(String),
16    /// Use a specific program with arguments.
17    WithArguments {
18        /// The program to run.
19        program: String,
20        /// The arguments to pass to the program.
21        args: Vec<String>,
22        /// An optional string to override the title of the terminal tab
23        title_override: Option<String>,
24    },
25}
26
27impl Shell {
28    pub fn program(&self) -> String {
29        match self {
30            Shell::Program(program) => program.clone(),
31            Shell::WithArguments { program, .. } => program.clone(),
32            Shell::System => get_system_shell(),
33        }
34    }
35
36    pub fn program_and_args(&self) -> (String, &[String]) {
37        match self {
38            Shell::Program(program) => (program.clone(), &[]),
39            Shell::WithArguments { program, args, .. } => (program.clone(), args),
40            Shell::System => (get_system_shell(), &[]),
41        }
42    }
43
44    pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
45        match self {
46            Shell::Program(program) => ShellKind::new(program, is_windows),
47            Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
48            Shell::System => ShellKind::system(),
49        }
50    }
51}
52
53#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum ShellKind {
55    #[default]
56    Posix,
57    Csh,
58    Tcsh,
59    Rc,
60    Fish,
61    /// Pre-installed "legacy" powershell for windows
62    PowerShell,
63    /// PowerShell 7.x
64    Pwsh,
65    Nushell,
66    Cmd,
67    Xonsh,
68    Elvish,
69}
70
71pub fn get_system_shell() -> String {
72    if cfg!(windows) {
73        get_windows_system_shell()
74    } else {
75        std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
76    }
77}
78
79pub fn get_default_system_shell() -> String {
80    if cfg!(windows) {
81        get_windows_system_shell()
82    } else {
83        "/bin/sh".to_string()
84    }
85}
86
87/// Get the default system shell, preferring bash on Windows.
88pub fn get_default_system_shell_preferring_bash() -> String {
89    #[cfg(windows)]
90    {
91        get_windows_bash().unwrap_or_else(|| get_windows_system_shell())
92    }
93
94    #[cfg(not(windows))]
95    {
96        "/bin/sh".to_string()
97    }
98}
99
100#[cfg(windows)]
101pub fn get_windows_bash() -> Option<String> {
102    fn find_bash_in_installation(install_root: &Path) -> Option<PathBuf> {
103        if !install_root.join("git-bash.exe").is_file() {
104            return None;
105        }
106        let bash = install_root.join("bin").join("bash.exe");
107        bash.is_file().then_some(bash)
108    }
109
110    fn find_bash_in_git() -> Option<PathBuf> {
111        if let Some(bash) = std::env::var_os("GIT_INSTALL_ROOT")
112            .map(PathBuf::from)
113            .and_then(|path| find_bash_in_installation(&path))
114        {
115            return Some(bash);
116        }
117        let git = which::which("git").ok()?;
118        let binary_directory = git.parent()?;
119        let parent = binary_directory.parent()?;
120        find_bash_in_installation(parent).or_else(|| find_bash_in_installation(parent.parent()?))
121    }
122
123    static BASH: LazyLock<Option<String>> = LazyLock::new(|| {
124        let bash = find_bash_in_git().map(|p| p.to_string_lossy().into_owned());
125        if let Some(ref path) = bash {
126            log::info!("Found bash at {}", path);
127        }
128        bash
129    });
130
131    (*BASH).clone()
132}
133
134pub fn get_windows_system_shell() -> String {
135    #[cfg(windows)]
136    return gpui_util::get_windows_system_shell();
137
138    #[cfg(not(windows))]
139    return "cmd.exe".to_string();
140}
141
142impl fmt::Display for ShellKind {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            ShellKind::Posix => write!(f, "sh"),
146            ShellKind::Csh => write!(f, "csh"),
147            ShellKind::Tcsh => write!(f, "tcsh"),
148            ShellKind::Fish => write!(f, "fish"),
149            ShellKind::PowerShell => write!(f, "powershell"),
150            ShellKind::Pwsh => write!(f, "pwsh"),
151            ShellKind::Nushell => write!(f, "nu"),
152            ShellKind::Cmd => write!(f, "cmd"),
153            ShellKind::Rc => write!(f, "rc"),
154            ShellKind::Xonsh => write!(f, "xonsh"),
155            ShellKind::Elvish => write!(f, "elvish"),
156        }
157    }
158}
159
160impl ShellKind {
161    pub fn system() -> Self {
162        Self::new(&get_system_shell(), cfg!(windows))
163    }
164
165    /// Returns whether this shell's command chaining syntax can be parsed by brush-parser.
166    ///
167    /// This is used to determine if we can safely parse shell commands to extract sub-commands
168    /// for security purposes (e.g., preventing shell injection in "always allow" patterns).
169    ///
170    /// The brush-parser handles `;` (sequential execution) and `|` (piping), which are
171    /// supported by all common shells. It also handles `&&` and `||` for conditional
172    /// execution, `$()` and backticks for command substitution, and process substitution.
173    ///
174    /// # Shell Notes
175    ///
176    /// - **Nushell**: Uses `;` for sequential execution. The `and`/`or` keywords are boolean
177    ///   operators on values (e.g., `$true and $false`), not command chaining operators.
178    /// - **Elvish**: Uses `;` to separate pipelines, which brush-parser handles. Elvish does
179    ///   not have `&&` or `||` operators. Its `and`/`or` are special commands that operate
180    ///   on values, not command chaining (e.g., `and $true $false`).
181    /// - **Rc (Plan 9)**: Uses `;` for sequential execution and `|` for piping. Does not
182    ///   have `&&`/`||` operators for conditional chaining.
183    /// All current shell variants are listed here because brush-parser can handle
184    /// their syntax. If a new `ShellKind` variant is added, evaluate whether
185    /// brush-parser can safely parse its command chaining syntax before including
186    /// it. Omitting a variant will cause `tool_permissions::from_input` to deny
187    /// terminal commands that have `always_allow` patterns configured.
188    pub fn supports_posix_chaining(&self) -> bool {
189        matches!(
190            self,
191            ShellKind::Posix
192                | ShellKind::Fish
193                | ShellKind::PowerShell
194                | ShellKind::Pwsh
195                | ShellKind::Cmd
196                | ShellKind::Xonsh
197                | ShellKind::Csh
198                | ShellKind::Tcsh
199                | ShellKind::Nushell
200                | ShellKind::Elvish
201                | ShellKind::Rc
202        )
203    }
204
205    pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
206        let program = program.as_ref();
207        let program = program
208            .file_stem()
209            .unwrap_or_else(|| program.as_os_str())
210            .to_string_lossy();
211
212        match &*program {
213            "powershell" => ShellKind::PowerShell,
214            "pwsh" => ShellKind::Pwsh,
215            "cmd" => ShellKind::Cmd,
216            "nu" => ShellKind::Nushell,
217            "fish" => ShellKind::Fish,
218            "csh" => ShellKind::Csh,
219            "tcsh" => ShellKind::Tcsh,
220            "rc" => ShellKind::Rc,
221            "xonsh" => ShellKind::Xonsh,
222            "elvish" => ShellKind::Elvish,
223            "sh" | "bash" | "zsh" => ShellKind::Posix,
224            _ if is_windows => ShellKind::PowerShell,
225            // Some other shell detected, the user might install and use a
226            // unix-like shell.
227            _ => ShellKind::Posix,
228        }
229    }
230
231    pub fn to_shell_variable(self, input: &str) -> String {
232        match self {
233            Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
234            Self::Cmd => Self::to_cmd_variable(input),
235            Self::Posix => input.to_owned(),
236            Self::Fish => input.to_owned(),
237            Self::Csh => input.to_owned(),
238            Self::Tcsh => input.to_owned(),
239            Self::Rc => input.to_owned(),
240            Self::Nushell => Self::to_nushell_variable(input),
241            Self::Xonsh => input.to_owned(),
242            Self::Elvish => input.to_owned(),
243        }
244    }
245
246    fn to_cmd_variable(input: &str) -> String {
247        if let Some(var_str) = input.strip_prefix("${") {
248            match var_str.strip_suffix('}') {
249                Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
250                    format!("%{var_name}%")
251                }
252                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
253                // which will result in the task failing to run in such cases.
254                _ => input.into(),
255            }
256        } else if let Some(var_str) = input.strip_prefix('$') {
257            // If the input starts with "$", directly append to "$env:"
258            format!("%{}%", var_str)
259        } else {
260            // If no prefix is found, return the input as is
261            input.into()
262        }
263    }
264
265    fn to_powershell_variable(input: &str) -> String {
266        if let Some(var_str) = input.strip_prefix("${") {
267            match var_str.strip_suffix('}') {
268                Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
269                    format!("$env:{var_name}")
270                }
271                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
272                // which will result in the task failing to run in such cases.
273                _ => input.into(),
274            }
275        } else if let Some(var_str) = input.strip_prefix('$') {
276            // If the input starts with "$", directly append to "$env:"
277            format!("$env:{}", var_str)
278        } else {
279            // If no prefix is found, return the input as is
280            input.into()
281        }
282    }
283
284    fn to_nushell_variable(input: &str) -> String {
285        let mut result = String::new();
286        let mut source = input;
287        let mut is_start = true;
288
289        loop {
290            match source.chars().next() {
291                None => return result,
292                Some('$') => {
293                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
294                    is_start = false;
295                }
296                Some(_) => {
297                    is_start = false;
298                    let chunk_end = source.find('$').unwrap_or(source.len());
299                    let (chunk, rest) = source.split_at(chunk_end);
300                    result.push_str(chunk);
301                    source = rest;
302                }
303            }
304        }
305    }
306
307    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
308        if source.starts_with("env.") {
309            text.push('$');
310            return source;
311        }
312
313        match source.chars().next() {
314            Some('{') => {
315                let source = &source[1..];
316                if let Some(end) = source.find('}') {
317                    let var_name = &source[..end];
318                    if !var_name.is_empty() {
319                        if !is_start {
320                            text.push_str("(");
321                        }
322                        text.push_str("$env.");
323                        text.push_str(var_name);
324                        if !is_start {
325                            text.push_str(")");
326                        }
327                        &source[end + 1..]
328                    } else {
329                        text.push_str("${}");
330                        &source[end + 1..]
331                    }
332                } else {
333                    text.push_str("${");
334                    source
335                }
336            }
337            Some(c) if c.is_alphabetic() || c == '_' => {
338                let end = source
339                    .find(|c: char| !c.is_alphanumeric() && c != '_')
340                    .unwrap_or(source.len());
341                let var_name = &source[..end];
342                if !is_start {
343                    text.push_str("(");
344                }
345                text.push_str("$env.");
346                text.push_str(var_name);
347                if !is_start {
348                    text.push_str(")");
349                }
350                &source[end..]
351            }
352            _ => {
353                text.push('$');
354                source
355            }
356        }
357    }
358
359    pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
360        match self {
361            ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
362            ShellKind::Cmd => vec![
363                "/S".to_owned(),
364                "/C".to_owned(),
365                format!("\"{combined_command}\""),
366            ],
367            ShellKind::Posix
368            | ShellKind::Nushell
369            | ShellKind::Fish
370            | ShellKind::Csh
371            | ShellKind::Tcsh
372            | ShellKind::Rc
373            | ShellKind::Xonsh
374            | ShellKind::Elvish => interactive
375                .then(|| "-i".to_owned())
376                .into_iter()
377                .chain(["-c".to_owned(), combined_command])
378                .collect(),
379        }
380    }
381
382    pub const fn command_prefix(&self) -> Option<char> {
383        match self {
384            ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
385            ShellKind::Nushell => Some('^'),
386            ShellKind::Posix
387            | ShellKind::Csh
388            | ShellKind::Tcsh
389            | ShellKind::Rc
390            | ShellKind::Fish
391            | ShellKind::Cmd
392            | ShellKind::Xonsh
393            | ShellKind::Elvish => None,
394        }
395    }
396
397    pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
398        match self.command_prefix() {
399            Some(prefix) if !command.starts_with(prefix) => {
400                Cow::Owned(format!("{prefix}{command}"))
401            }
402            _ => Cow::Borrowed(command),
403        }
404    }
405
406    pub const fn sequential_commands_separator(&self) -> char {
407        match self {
408            ShellKind::Cmd => '&',
409            ShellKind::Posix
410            | ShellKind::Csh
411            | ShellKind::Tcsh
412            | ShellKind::Rc
413            | ShellKind::Fish
414            | ShellKind::PowerShell
415            | ShellKind::Pwsh
416            | ShellKind::Nushell
417            | ShellKind::Xonsh
418            | ShellKind::Elvish => ';',
419        }
420    }
421
422    pub const fn sequential_and_commands_separator(&self) -> &'static str {
423        match self {
424            ShellKind::Cmd
425            | ShellKind::Posix
426            | ShellKind::Csh
427            | ShellKind::Tcsh
428            | ShellKind::Rc
429            | ShellKind::Fish
430            | ShellKind::Pwsh
431            | ShellKind::Xonsh => "&&",
432            ShellKind::PowerShell | ShellKind::Nushell | ShellKind::Elvish => ";",
433        }
434    }
435
436    pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
437        match self {
438            ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
439            ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
440            ShellKind::Cmd => Some(Self::quote_cmd(arg)),
441            ShellKind::Csh | ShellKind::Tcsh => Self::quote_csh(arg),
442            ShellKind::Nushell => Self::quote_nushell(arg),
443            ShellKind::Posix
444            | ShellKind::Rc
445            | ShellKind::Fish
446            | ShellKind::Xonsh
447            | ShellKind::Elvish => shlex::try_quote(arg).ok(),
448        }
449    }
450
451    fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
452        if arg.is_empty() {
453            return Cow::Borrowed("\"\"");
454        }
455
456        let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
457        if !needs_quoting {
458            return Cow::Borrowed(arg);
459        }
460
461        let mut result = String::with_capacity(arg.len() + 2);
462
463        if enclose {
464            result.push('"');
465        }
466
467        let chars: Vec<char> = arg.chars().collect();
468        let mut i = 0;
469
470        while i < chars.len() {
471            if chars[i] == '\\' {
472                let mut num_backslashes = 0;
473                while i < chars.len() && chars[i] == '\\' {
474                    num_backslashes += 1;
475                    i += 1;
476                }
477
478                if i < chars.len() && chars[i] == '"' {
479                    // Backslashes followed by quote: double the backslashes and escape the quote
480                    for _ in 0..(num_backslashes * 2 + 1) {
481                        result.push('\\');
482                    }
483                    result.push('"');
484                    i += 1;
485                } else if i >= chars.len() {
486                    // Trailing backslashes: double them (they precede the closing quote)
487                    for _ in 0..(num_backslashes * 2) {
488                        result.push('\\');
489                    }
490                } else {
491                    // Backslashes not followed by quote: output as-is
492                    for _ in 0..num_backslashes {
493                        result.push('\\');
494                    }
495                }
496            } else if chars[i] == '"' {
497                // Quote not preceded by backslash: escape it
498                result.push('\\');
499                result.push('"');
500                i += 1;
501            } else {
502                result.push(chars[i]);
503                i += 1;
504            }
505        }
506
507        if enclose {
508            result.push('"');
509        }
510        Cow::Owned(result)
511    }
512
513    fn needs_quoting_powershell(s: &str) -> bool {
514        s.is_empty()
515            || s.chars().any(|c| {
516                c.is_whitespace()
517                    || matches!(
518                        c,
519                        '"' | '`'
520                            | '$'
521                            | '&'
522                            | '|'
523                            | '<'
524                            | '>'
525                            | ';'
526                            | '('
527                            | ')'
528                            | '['
529                            | ']'
530                            | '{'
531                            | '}'
532                            | ','
533                            | '\''
534                            | '@'
535                    )
536            })
537    }
538
539    fn need_quotes_powershell(arg: &str) -> bool {
540        let mut quote_count = 0;
541        for c in arg.chars() {
542            if c == '"' {
543                quote_count += 1;
544            } else if c.is_whitespace() && (quote_count % 2 == 0) {
545                return true;
546            }
547        }
548        false
549    }
550
551    fn escape_powershell_quotes(s: &str) -> String {
552        let mut result = String::with_capacity(s.len() + 4);
553        result.push('\'');
554        for c in s.chars() {
555            if c == '\'' {
556                result.push('\'');
557            }
558            result.push(c);
559        }
560        result.push('\'');
561        result
562    }
563
564    pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
565        let ps_will_quote = Self::need_quotes_powershell(arg);
566        let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
567
568        if !Self::needs_quoting_powershell(arg) {
569            return crt_quoted;
570        }
571
572        Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
573    }
574
575    pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
576        if arg.is_empty() {
577            return Cow::Borrowed("''");
578        }
579
580        if !Self::needs_quoting_powershell(arg) {
581            return Cow::Borrowed(arg);
582        }
583
584        Cow::Owned(Self::escape_powershell_quotes(arg))
585    }
586
587    pub fn quote_csh(arg: &str) -> Option<Cow<'_, str>> {
588        if arg.contains('\0') {
589            return None;
590        }
591        if Self::is_unquoted_safe(arg) && !arg.starts_with('%') {
592            return Some(Cow::Borrowed(arg));
593        }
594        if arg.is_empty() {
595            return Some(Cow::Borrowed("''"));
596        }
597        let mut quoted = String::with_capacity(arg.len() + 2);
598        let mut in_quotes = false;
599        for character in arg.chars() {
600            match character {
601                '\'' | '!' | '\\' => {
602                    if in_quotes {
603                        quoted.push('\'');
604                        in_quotes = false;
605                    }
606                    quoted.push('\\');
607                    quoted.push(character);
608                }
609                character => {
610                    if !in_quotes {
611                        quoted.push('\'');
612                        in_quotes = true;
613                    }
614                    if character == '\n' {
615                        quoted.push('\\');
616                    }
617                    quoted.push(character);
618                }
619            }
620        }
621        if in_quotes {
622            quoted.push('\'');
623        }
624        Some(Cow::Owned(quoted))
625    }
626
627    pub fn quote_nushell(arg: &str) -> Option<Cow<'_, str>> {
628        if arg.contains('\0') {
629            return None;
630        }
631        if Self::is_unquoted_safe(arg) {
632            return Some(Cow::Borrowed(arg));
633        }
634        if !arg.contains('\'') {
635            return Some(Cow::Owned(format!("'{arg}'")));
636        }
637        let mut quoted = String::with_capacity(arg.len() + 2);
638        quoted.push('"');
639        for character in arg.chars() {
640            if character == '\\' || character == '"' {
641                quoted.push('\\');
642            }
643            quoted.push(character);
644        }
645        quoted.push('"');
646        Some(Cow::Owned(quoted))
647    }
648
649    fn is_unquoted_safe(arg: &str) -> bool {
650        !arg.is_empty()
651            && arg
652                .bytes()
653                .all(|byte| byte.is_ascii_alphanumeric() || b"-_./:,+@%".contains(&byte))
654    }
655
656    pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
657        let crt_quoted = Self::quote_windows(arg, true);
658
659        let needs_cmd_escaping = crt_quoted.contains(['"', '%', '^', '<', '>', '&', '|', '(', ')']);
660
661        if !needs_cmd_escaping {
662            return crt_quoted;
663        }
664
665        let mut result = String::with_capacity(crt_quoted.len() * 2);
666        for c in crt_quoted.chars() {
667            match c {
668                '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
669                    result.push('^');
670                    result.push(c);
671                }
672                '%' => {
673                    result.push_str("%%cd:~,%");
674                }
675                _ => result.push(c),
676            }
677        }
678        Cow::Owned(result)
679    }
680
681    /// Quotes the given argument if necessary, taking into account the command prefix.
682    ///
683    /// In other words, this will consider quoting arg without its command prefix to not break the command.
684    /// You should use this over `try_quote` when you want to quote a shell command.
685    pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
686        if let Some(char) = self.command_prefix() {
687            if let Some(arg) = arg.strip_prefix(char) {
688                // we have a command that is prefixed
689                for quote in ['\'', '"'] {
690                    if let Some(arg) = arg
691                        .strip_prefix(quote)
692                        .and_then(|arg| arg.strip_suffix(quote))
693                    {
694                        // and the command itself is wrapped as a literal, that
695                        // means the prefix exists to interpret a literal as a
696                        // command. So strip the quotes, quote the command, and
697                        // re-add the quotes if they are missing after requoting
698                        let quoted = self.try_quote(arg)?;
699                        return Some(if quoted.starts_with(['\'', '"']) {
700                            Cow::Owned(self.prepend_command_prefix(&quoted).into_owned())
701                        } else {
702                            Cow::Owned(
703                                self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
704                                    .into_owned(),
705                            )
706                        });
707                    }
708                }
709                return self
710                    .try_quote(arg)
711                    .map(|quoted| Cow::Owned(self.prepend_command_prefix(&quoted).into_owned()));
712            }
713        }
714        self.try_quote(arg).map(|quoted| match quoted {
715            unquoted @ Cow::Borrowed(_) => unquoted,
716            Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix(&quoted).into_owned()),
717        })
718    }
719
720    pub fn split(&self, input: &str) -> Option<Vec<String>> {
721        shlex::split(input)
722    }
723
724    pub const fn activate_keyword(&self) -> &'static str {
725        match self {
726            ShellKind::Cmd => "",
727            ShellKind::Nushell => "overlay use",
728            ShellKind::PowerShell | ShellKind::Pwsh => ".",
729            ShellKind::Fish
730            | ShellKind::Csh
731            | ShellKind::Tcsh
732            | ShellKind::Posix
733            | ShellKind::Rc
734            | ShellKind::Xonsh
735            | ShellKind::Elvish => "source",
736        }
737    }
738
739    pub const fn clear_screen_command(&self) -> &'static str {
740        match self {
741            ShellKind::Cmd => "cls",
742            ShellKind::Posix
743            | ShellKind::Csh
744            | ShellKind::Tcsh
745            | ShellKind::Rc
746            | ShellKind::Fish
747            | ShellKind::PowerShell
748            | ShellKind::Pwsh
749            | ShellKind::Nushell
750            | ShellKind::Xonsh
751            | ShellKind::Elvish => "clear",
752        }
753    }
754
755    #[cfg(windows)]
756    /// We do not want to escape arguments if we are using CMD as our shell.
757    /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
758    pub const fn tty_escape_args(&self) -> bool {
759        match self {
760            ShellKind::Cmd => false,
761            ShellKind::Posix
762            | ShellKind::Csh
763            | ShellKind::Tcsh
764            | ShellKind::Rc
765            | ShellKind::Fish
766            | ShellKind::PowerShell
767            | ShellKind::Pwsh
768            | ShellKind::Nushell
769            | ShellKind::Xonsh
770            | ShellKind::Elvish => true,
771        }
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    #[cfg(unix)]
779    use crate::shell_builder::ShellBuilder;
780
781    // Examples
782    // WSL
783    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
784    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
785    // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
786    // PowerShell from Nushell
787    // 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\""
788    // PowerShell from CMD
789    // 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\\\"\"\"
790
791    #[test]
792    fn test_try_quote_powershell() {
793        let shell_kind = ShellKind::PowerShell;
794        assert_eq!(
795            shell_kind
796                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
797                .unwrap()
798                .into_owned(),
799            "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
800        );
801    }
802
803    #[test]
804    fn test_try_quote_cmd() {
805        let shell_kind = ShellKind::Cmd;
806        assert_eq!(
807            shell_kind
808                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
809                .unwrap()
810                .into_owned(),
811            "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
812        );
813    }
814
815    #[test]
816    fn test_try_quote_powershell_edge_cases() {
817        let shell_kind = ShellKind::PowerShell;
818
819        // Empty string
820        assert_eq!(
821            shell_kind.try_quote("").unwrap().into_owned(),
822            "'\"\"'".to_string()
823        );
824
825        // String without special characters (no quoting needed)
826        assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
827
828        // String with spaces
829        assert_eq!(
830            shell_kind.try_quote("hello world").unwrap().into_owned(),
831            "'hello world'".to_string()
832        );
833
834        // String with dollar signs
835        assert_eq!(
836            shell_kind.try_quote("$variable").unwrap().into_owned(),
837            "'$variable'".to_string()
838        );
839
840        // String with backticks
841        assert_eq!(
842            shell_kind.try_quote("test`command").unwrap().into_owned(),
843            "'test`command'".to_string()
844        );
845
846        // String with multiple special characters
847        assert_eq!(
848            shell_kind
849                .try_quote("test `\"$var`\" end")
850                .unwrap()
851                .into_owned(),
852            "'test `\\\"$var`\\\" end'".to_string()
853        );
854
855        // String with backslashes and colon (path without spaces doesn't need quoting)
856        assert_eq!(
857            shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
858            "C:\\path\\to\\file"
859        );
860    }
861
862    #[test]
863    fn test_try_quote_cmd_edge_cases() {
864        let shell_kind = ShellKind::Cmd;
865
866        // Empty string
867        assert_eq!(
868            shell_kind.try_quote("").unwrap().into_owned(),
869            "^\"^\"".to_string()
870        );
871
872        // String without special characters (no quoting needed)
873        assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
874
875        // String with spaces
876        assert_eq!(
877            shell_kind.try_quote("hello world").unwrap().into_owned(),
878            "^\"hello world^\"".to_string()
879        );
880
881        // String with space and backslash (backslash not at end, so not doubled)
882        assert_eq!(
883            shell_kind.try_quote("path\\ test").unwrap().into_owned(),
884            "^\"path\\ test^\"".to_string()
885        );
886
887        // String ending with backslash (must be doubled before closing quote)
888        assert_eq!(
889            shell_kind.try_quote("test path\\").unwrap().into_owned(),
890            "^\"test path\\\\^\"".to_string()
891        );
892
893        // String ending with multiple backslashes (all doubled before closing quote)
894        assert_eq!(
895            shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
896            "^\"test path\\\\\\\\^\"".to_string()
897        );
898
899        // String with embedded quote (quote is escaped, backslash before it is doubled)
900        assert_eq!(
901            shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
902            "^\"test\\\\\\^\"quote^\"".to_string()
903        );
904
905        // String with multiple backslashes before embedded quote (all doubled)
906        assert_eq!(
907            shell_kind
908                .try_quote("test\\\\\"quote")
909                .unwrap()
910                .into_owned(),
911            "^\"test\\\\\\\\\\^\"quote^\"".to_string()
912        );
913
914        // String with backslashes not before quotes (path without spaces doesn't need quoting)
915        assert_eq!(
916            shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
917            "C:\\path\\to\\file"
918        );
919    }
920
921    #[test]
922    fn test_try_quote_nu_command() {
923        let shell_kind = ShellKind::Nushell;
924        assert_eq!(
925            shell_kind.try_quote("'uname'").unwrap().into_owned(),
926            "\"'uname'\"".to_string()
927        );
928        assert_eq!(
929            shell_kind
930                .try_quote_prefix_aware("'uname'")
931                .unwrap()
932                .into_owned(),
933            "^\"'uname'\"".to_string()
934        );
935        assert_eq!(
936            shell_kind.try_quote("^uname").unwrap().into_owned(),
937            "'^uname'".to_string()
938        );
939        assert_eq!(
940            shell_kind
941                .try_quote_prefix_aware("^uname")
942                .unwrap()
943                .into_owned(),
944            "^uname".to_string()
945        );
946        assert_eq!(
947            shell_kind.try_quote("^'uname'").unwrap().into_owned(),
948            "\"^'uname'\"".to_string()
949        );
950        assert_eq!(
951            shell_kind
952                .try_quote_prefix_aware("^'uname'")
953                .unwrap()
954                .into_owned(),
955            "^'uname'".to_string()
956        );
957        assert_eq!(
958            shell_kind.try_quote("'uname a'").unwrap().into_owned(),
959            "\"'uname a'\"".to_string()
960        );
961        assert_eq!(
962            shell_kind
963                .try_quote_prefix_aware("'uname a'")
964                .unwrap()
965                .into_owned(),
966            "^\"'uname a'\"".to_string()
967        );
968        assert_eq!(
969            shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
970            "\"^'uname a'\"".to_string()
971        );
972        assert_eq!(
973            shell_kind
974                .try_quote_prefix_aware("^'uname a'")
975                .unwrap()
976                .into_owned(),
977            "^'uname a'".to_string()
978        );
979        assert_eq!(
980            shell_kind.try_quote("uname").unwrap().into_owned(),
981            "uname".to_string()
982        );
983        assert_eq!(
984            shell_kind
985                .try_quote_prefix_aware("uname")
986                .unwrap()
987                .into_owned(),
988            "uname".to_string()
989        );
990    }
991
992    #[test]
993    fn test_try_quote_csh() {
994        for shell_kind in [ShellKind::Csh, ShellKind::Tcsh] {
995            assert_eq!(shell_kind.try_quote("plain").unwrap(), "plain");
996            assert_eq!(
997                shell_kind.try_quote("--flag=a/b.c").unwrap(),
998                "'--flag=a/b.c'"
999            );
1000            assert_eq!(shell_kind.try_quote("=0").unwrap(), "'=0'");
1001            assert_eq!(shell_kind.try_quote("%agent").unwrap(), "'%agent'");
1002            assert_eq!(shell_kind.try_quote("50%").unwrap(), "50%");
1003            assert_eq!(shell_kind.try_quote("").unwrap(), "''");
1004            assert_eq!(
1005                shell_kind.try_quote("/home/bang!home/x").unwrap(),
1006                "'/home/bang'\\!'home/x'"
1007            );
1008            assert_eq!(shell_kind.try_quote("a\\!b c").unwrap(), "'a'\\\\\\!'b c'");
1009            assert_eq!(
1010                shell_kind.try_quote("O'Brien!").unwrap(),
1011                "'O'\\''Brien'\\!"
1012            );
1013            assert_eq!(shell_kind.try_quote("!").unwrap(), "\\!");
1014            assert_eq!(shell_kind.try_quote("\\'").unwrap(), "\\\\\\'");
1015            assert_eq!(
1016                shell_kind
1017                    .try_quote("/tmp/a\\'; echo INJECTED; true \\'")
1018                    .unwrap(),
1019                "'/tmp/a'\\\\\\''; echo INJECTED; true '\\\\\\'"
1020            );
1021            assert_eq!(
1022                shell_kind.try_quote("say \\\"hi\\\"").unwrap(),
1023                "'say '\\\\'\"hi'\\\\'\"'"
1024            );
1025            assert_eq!(
1026                shell_kind.try_quote("line\nbreak").unwrap(),
1027                "'line\\\nbreak'"
1028            );
1029            assert_eq!(
1030                shell_kind.try_quote("line\rbreak").unwrap(),
1031                "'line\rbreak'"
1032            );
1033            assert_eq!(
1034                shell_kind.try_quote("~/*$HOME `id`").unwrap(),
1035                "'~/*$HOME `id`'"
1036            );
1037            assert_eq!(shell_kind.try_quote("nul\0byte"), None);
1038        }
1039    }
1040
1041    #[test]
1042    fn test_try_quote_nushell() {
1043        let shell_kind = ShellKind::Nushell;
1044        assert_eq!(shell_kind.try_quote("plain").unwrap(), "plain");
1045        assert_eq!(shell_kind.try_quote("").unwrap(), "''");
1046        assert_eq!(shell_kind.try_quote("=").unwrap(), "'='");
1047        assert_eq!(shell_kind.try_quote("=a").unwrap(), "'=a'");
1048        assert_eq!(shell_kind.try_quote("+=").unwrap(), "'+='");
1049        assert_eq!(
1050            shell_kind.try_quote("--flag=a/b.c").unwrap(),
1051            "'--flag=a/b.c'"
1052        );
1053        assert_eq!(
1054            shell_kind
1055                .try_quote("/home/a^b/.zed_server/server")
1056                .unwrap(),
1057            "'/home/a^b/.zed_server/server'"
1058        );
1059        assert_eq!(
1060            shell_kind.try_quote("$env.HOME (1 + 1) `id` a\\b").unwrap(),
1061            "'$env.HOME (1 + 1) `id` a\\b'"
1062        );
1063        assert_eq!(
1064            shell_kind.try_quote("O'Brien \\ \"x\" $y").unwrap(),
1065            "\"O'Brien \\\\ \\\"x\\\" $y\""
1066        );
1067        assert_eq!(shell_kind.try_quote("nul\0byte"), None);
1068    }
1069
1070    #[cfg(unix)]
1071    #[test]
1072    fn test_quoted_arguments_survive_real_shells() {
1073        let arguments = [
1074            "SAFE; /usr/bin/printf INJECTED\r",
1075            "/tmp/a\\'; /usr/bin/printf INJECTED; true \\'",
1076            "a\\\\b \\\"c\\\" \\'d\\' \\$e \\!f",
1077            "line\nbreak",
1078            "bang!\nnew\\line",
1079            "O'Brien!",
1080            "a\\!b",
1081            "a\\\\!b",
1082            "!1x",
1083            "!",
1084            "!!",
1085            "!$ !* !:1 !-1 !# !?x?",
1086            "ending!",
1087            "trailing\\",
1088            "~/*$HOME `id` $(id) (1 + 1) \"quoted\"",
1089            "\t tab # hash",
1090            "=",
1091            "=a",
1092            "=0",
1093            "=1",
1094            "+=",
1095            "-=",
1096            "/=",
1097            "a=b",
1098            "--flag=a/b.c",
1099            "1..3",
1100            "/home/a^b/.zed_server/server",
1101            "钥匙🔑",
1102            "",
1103        ];
1104        let csh_settings = [
1105            "",
1106            "set backslash_quote\n",
1107            "set histchars = ''\n",
1108            "set histchars = \\!^\n",
1109            "set backslash_quote\nset histchars = ''\n",
1110            "set backslash_quote\nunset histchars\nset history = 100\n",
1111        ];
1112        let shells = [
1113            ("tcsh", ShellKind::Tcsh, &["-f"][..], &csh_settings[..]),
1114            ("csh", ShellKind::Csh, &["-f"][..], &csh_settings[..]),
1115            (
1116                "nu",
1117                ShellKind::Nushell,
1118                &["--no-config-file"][..],
1119                &[""][..],
1120            ),
1121        ];
1122        for (shell_name, shell_kind, shell_options, settings) in shells {
1123            let Ok(shell) = which::which(shell_name) else {
1124                continue;
1125            };
1126            let shell = shell.to_string_lossy().into_owned();
1127            let separator = shell_kind.sequential_and_commands_separator();
1128            let printf = shell_kind.prepend_command_prefix("/usr/bin/printf");
1129            for (setting, argument) in settings
1130                .iter()
1131                .flat_map(|setting| arguments.iter().map(move |argument| (*setting, *argument)))
1132            {
1133                let quoted = shell_kind.try_quote(argument).unwrap();
1134                let inner = format!("{setting}cd /{separator} {printf} %s {quoted}");
1135                let mut inner_arguments = shell_options.to_vec();
1136                inner_arguments.extend(["-c", &inner]);
1137                let nested = format!(
1138                    "{setting}{} {}",
1139                    shell_kind.prepend_command_prefix(&shell),
1140                    inner_arguments
1141                        .iter()
1142                        .map(|inner_argument| shell_kind.try_quote(inner_argument).unwrap())
1143                        .collect::<Vec<_>>()
1144                        .join(" ")
1145                );
1146                for script in [inner.clone(), nested] {
1147                    let output = smol::block_on(
1148                        smol::process::Command::new(&shell)
1149                            .args(shell_options)
1150                            .args(["-c", &script])
1151                            .env_clear()
1152                            .env("HOME", "/")
1153                            .env("PATH", "/usr/bin:/bin")
1154                            .output(),
1155                    )
1156                    .unwrap();
1157                    assert_eq!(
1158                        (
1159                            output.status.success(),
1160                            String::from_utf8_lossy(&output.stdout).into_owned(),
1161                            String::from_utf8_lossy(&output.stderr).into_owned(),
1162                        ),
1163                        (true, argument.to_owned(), String::new()),
1164                        "{shell} {script:?}"
1165                    );
1166                }
1167            }
1168        }
1169    }
1170
1171    #[cfg(unix)]
1172    #[test]
1173    fn test_quoted_csh_commands_survive_real_shells() {
1174        use std::os::unix::fs::PermissionsExt as _;
1175
1176        let directory = tempfile::tempdir().unwrap();
1177        let binary_directory = directory.path().join("bin");
1178        std::fs::create_dir(&binary_directory).unwrap();
1179        for program in ["%agent", "50%", "agent"] {
1180            let script = binary_directory.join(program);
1181            std::fs::write(&script, "#!/bin/sh\nprintf '%s' \"$1\"\n").unwrap();
1182            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1183        }
1184        for (shell_name, shell_kind) in [("tcsh", ShellKind::Tcsh), ("csh", ShellKind::Csh)] {
1185            let Ok(shell) = which::which(shell_name) else {
1186                continue;
1187            };
1188            for program in ["%agent", "50%", "agent"] {
1189                let (shell_program, shell_arguments) =
1190                    ShellBuilder::new(&Shell::Program(shell.display().to_string()), false)
1191                        .non_interactive()
1192                        .literal_args()
1193                        .build(Some(program.to_owned()), &[String::from("OK")]);
1194                let script = shell_arguments.last().unwrap();
1195                assert_eq!(
1196                    script,
1197                    &format!("{} OK", shell_kind.try_quote(program).unwrap())
1198                );
1199                let output = smol::block_on(
1200                    smol::process::Command::new(&shell_program)
1201                        .arg("-f")
1202                        .args(&shell_arguments)
1203                        .env_clear()
1204                        .env("HOME", "/")
1205                        .env("PATH", &binary_directory)
1206                        .output(),
1207                )
1208                .unwrap();
1209                assert_eq!(
1210                    (
1211                        output.status.success(),
1212                        String::from_utf8_lossy(&output.stdout).into_owned(),
1213                        String::from_utf8_lossy(&output.stderr).into_owned(),
1214                    ),
1215                    (true, String::from("OK"), String::new()),
1216                    "{shell_program} {shell_arguments:?}"
1217                );
1218            }
1219        }
1220    }
1221
1222    #[test]
1223    fn test_try_quote_single_quote_paths() {
1224        let path_with_quote = r"C:\Temp\O'Brien\repo";
1225        let shlex_shells = [
1226            ShellKind::Posix,
1227            ShellKind::Fish,
1228            ShellKind::Csh,
1229            ShellKind::Tcsh,
1230            ShellKind::Rc,
1231            ShellKind::Xonsh,
1232            ShellKind::Elvish,
1233            ShellKind::Nushell,
1234        ];
1235
1236        for shell_kind in shlex_shells {
1237            let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
1238            assert_ne!(quoted, path_with_quote);
1239            assert_eq!(
1240                shlex::split(&quoted),
1241                Some(vec![path_with_quote.to_string()])
1242            );
1243
1244            if shell_kind == ShellKind::Nushell {
1245                let prefixed = shell_kind.prepend_command_prefix(&quoted);
1246                assert!(prefixed.starts_with('^'));
1247            }
1248        }
1249
1250        for shell_kind in [ShellKind::PowerShell, ShellKind::Pwsh] {
1251            let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
1252            assert!(quoted.starts_with('\''));
1253            assert!(quoted.ends_with('\''));
1254            assert!(quoted.contains("O''Brien"));
1255        }
1256    }
1257
1258    #[test]
1259    fn test_to_shell_variable() {
1260        assert_eq!(
1261            ShellKind::PowerShell.to_shell_variable("${FOO}"),
1262            "$env:FOO"
1263        );
1264        assert_eq!(ShellKind::Pwsh.to_shell_variable("${FOO}"), "$env:FOO");
1265        assert_eq!(ShellKind::Cmd.to_shell_variable("${FOO}"), "%FOO%");
1266        assert_eq!(ShellKind::Nushell.to_shell_variable("${FOO}"), "$env.FOO");
1267        assert_eq!(ShellKind::Posix.to_shell_variable("${FOO}"), "${FOO}");
1268
1269        assert_eq!(ShellKind::PowerShell.to_shell_variable("$FOO"), "$env:FOO");
1270        assert_eq!(
1271            ShellKind::PowerShell.to_shell_variable("${日本}"),
1272            "$env:日本"
1273        );
1274        assert_eq!(
1275            ShellKind::PowerShell.to_shell_variable("${FOO:-bar}"),
1276            "${FOO:-bar}"
1277        );
1278    }
1279
1280    #[test]
1281    fn test_to_shell_variable_malformed_is_passed_through() {
1282        for input in ["${", "${FOO", "${café", "${}", "${日本"] {
1283            for shell_kind in [
1284                ShellKind::PowerShell,
1285                ShellKind::Pwsh,
1286                ShellKind::Cmd,
1287                ShellKind::Nushell,
1288            ] {
1289                assert_eq!(shell_kind.to_shell_variable(input), input);
1290            }
1291        }
1292    }
1293}