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    /// Pre-installed "legacy" powershell for windows
  60    PowerShell,
  61    /// PowerShell 7.x
  62    Pwsh,
  63    Nushell,
  64    Cmd,
  65    Xonsh,
  66    Elvish,
  67}
  68
  69pub fn get_system_shell() -> String {
  70    if cfg!(windows) {
  71        get_windows_system_shell()
  72    } else {
  73        std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
  74    }
  75}
  76
  77pub fn get_default_system_shell() -> String {
  78    if cfg!(windows) {
  79        get_windows_system_shell()
  80    } else {
  81        "/bin/sh".to_string()
  82    }
  83}
  84
  85/// Get the default system shell, preferring bash on Windows.
  86pub fn get_default_system_shell_preferring_bash() -> String {
  87    if cfg!(windows) {
  88        get_windows_bash().unwrap_or_else(|| get_windows_system_shell())
  89    } else {
  90        "/bin/sh".to_string()
  91    }
  92}
  93
  94pub fn get_windows_bash() -> Option<String> {
  95    use std::path::PathBuf;
  96
  97    fn find_bash_in_scoop() -> Option<PathBuf> {
  98        let bash_exe =
  99            PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\bash.exe");
 100        bash_exe.exists().then_some(bash_exe)
 101    }
 102
 103    fn find_bash_in_git() -> Option<PathBuf> {
 104        // /path/to/git/cmd/git.exe/../../bin/bash.exe
 105        let git = which::which("git").ok()?;
 106        let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
 107        git_bash.exists().then_some(git_bash)
 108    }
 109
 110    static BASH: LazyLock<Option<String>> = LazyLock::new(|| {
 111        let bash = find_bash_in_scoop()
 112            .or_else(|| find_bash_in_git())
 113            .map(|p| p.to_string_lossy().into_owned());
 114        if let Some(ref path) = bash {
 115            log::info!("Found bash at {}", path);
 116        }
 117        bash
 118    });
 119
 120    (*BASH).clone()
 121}
 122
 123pub fn get_windows_system_shell() -> String {
 124    use std::path::PathBuf;
 125
 126    fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
 127        #[cfg(target_pointer_width = "64")]
 128        let env_var = if find_alternate {
 129            "ProgramFiles(x86)"
 130        } else {
 131            "ProgramFiles"
 132        };
 133
 134        #[cfg(target_pointer_width = "32")]
 135        let env_var = if find_alternate {
 136            "ProgramW6432"
 137        } else {
 138            "ProgramFiles"
 139        };
 140
 141        let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
 142        install_base_dir
 143            .read_dir()
 144            .ok()?
 145            .filter_map(Result::ok)
 146            .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
 147            .filter_map(|entry| {
 148                let dir_name = entry.file_name();
 149                let dir_name = dir_name.to_string_lossy();
 150
 151                let version = if find_preview {
 152                    let dash_index = dir_name.find('-')?;
 153                    if &dir_name[dash_index + 1..] != "preview" {
 154                        return None;
 155                    };
 156                    dir_name[..dash_index].parse::<u32>().ok()?
 157                } else {
 158                    dir_name.parse::<u32>().ok()?
 159                };
 160
 161                let exe_path = entry.path().join("pwsh.exe");
 162                if exe_path.exists() {
 163                    Some((version, exe_path))
 164                } else {
 165                    None
 166                }
 167            })
 168            .max_by_key(|(version, _)| *version)
 169            .map(|(_, path)| path)
 170    }
 171
 172    fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
 173        let msix_app_dir =
 174            PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
 175        if !msix_app_dir.exists() {
 176            return None;
 177        }
 178
 179        let prefix = if find_preview {
 180            "Microsoft.PowerShellPreview_"
 181        } else {
 182            "Microsoft.PowerShell_"
 183        };
 184        msix_app_dir
 185            .read_dir()
 186            .ok()?
 187            .filter_map(|entry| {
 188                let entry = entry.ok()?;
 189                if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
 190                    return None;
 191                }
 192
 193                if !entry.file_name().to_string_lossy().starts_with(prefix) {
 194                    return None;
 195                }
 196
 197                let exe_path = entry.path().join("pwsh.exe");
 198                exe_path.exists().then_some(exe_path)
 199            })
 200            .next()
 201    }
 202
 203    fn find_pwsh_in_scoop() -> Option<PathBuf> {
 204        let pwsh_exe =
 205            PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
 206        pwsh_exe.exists().then_some(pwsh_exe)
 207    }
 208
 209    static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
 210        let locations = [
 211            || find_pwsh_in_programfiles(false, false),
 212            || find_pwsh_in_programfiles(true, false),
 213            || find_pwsh_in_msix(false),
 214            || find_pwsh_in_programfiles(false, true),
 215            || find_pwsh_in_msix(true),
 216            || find_pwsh_in_programfiles(true, true),
 217            || find_pwsh_in_scoop(),
 218            || which::which_global("pwsh.exe").ok(),
 219            || which::which_global("powershell.exe").ok(),
 220        ];
 221
 222        locations
 223            .into_iter()
 224            .find_map(|f| f())
 225            .map(|p| p.to_string_lossy().trim().to_owned())
 226            .inspect(|shell| log::info!("Found powershell in: {}", shell))
 227            .unwrap_or_else(|| {
 228                log::warn!("Powershell not found, falling back to `cmd`");
 229                "cmd.exe".to_string()
 230            })
 231    });
 232
 233    (*SYSTEM_SHELL).clone()
 234}
 235
 236impl fmt::Display for ShellKind {
 237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 238        match self {
 239            ShellKind::Posix => write!(f, "sh"),
 240            ShellKind::Csh => write!(f, "csh"),
 241            ShellKind::Tcsh => write!(f, "tcsh"),
 242            ShellKind::Fish => write!(f, "fish"),
 243            ShellKind::PowerShell => write!(f, "powershell"),
 244            ShellKind::Pwsh => write!(f, "pwsh"),
 245            ShellKind::Nushell => write!(f, "nu"),
 246            ShellKind::Cmd => write!(f, "cmd"),
 247            ShellKind::Rc => write!(f, "rc"),
 248            ShellKind::Xonsh => write!(f, "xonsh"),
 249            ShellKind::Elvish => write!(f, "elvish"),
 250        }
 251    }
 252}
 253
 254impl ShellKind {
 255    pub fn system() -> Self {
 256        Self::new(&get_system_shell(), cfg!(windows))
 257    }
 258
 259    /// Returns whether this shell's command chaining syntax can be parsed by brush-parser.
 260    ///
 261    /// This is used to determine if we can safely parse shell commands to extract sub-commands
 262    /// for security purposes (e.g., preventing shell injection in "always allow" patterns).
 263    ///
 264    /// The brush-parser handles `;` (sequential execution) and `|` (piping), which are
 265    /// supported by all common shells. It also handles `&&` and `||` for conditional
 266    /// execution, `$()` and backticks for command substitution, and process substitution.
 267    ///
 268    /// # Shell Notes
 269    ///
 270    /// - **Nushell**: Uses `;` for sequential execution. The `and`/`or` keywords are boolean
 271    ///   operators on values (e.g., `$true and $false`), not command chaining operators.
 272    /// - **Elvish**: Uses `;` to separate pipelines, which brush-parser handles. Elvish does
 273    ///   not have `&&` or `||` operators. Its `and`/`or` are special commands that operate
 274    ///   on values, not command chaining (e.g., `and $true $false`).
 275    /// - **Rc (Plan 9)**: Uses `;` for sequential execution and `|` for piping. Does not
 276    ///   have `&&`/`||` operators for conditional chaining.
 277    pub fn supports_posix_chaining(&self) -> bool {
 278        matches!(
 279            self,
 280            ShellKind::Posix
 281                | ShellKind::Fish
 282                | ShellKind::PowerShell
 283                | ShellKind::Pwsh
 284                | ShellKind::Cmd
 285                | ShellKind::Xonsh
 286                | ShellKind::Csh
 287                | ShellKind::Tcsh
 288                | ShellKind::Nushell
 289                | ShellKind::Elvish
 290                | ShellKind::Rc
 291        )
 292    }
 293
 294    pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
 295        let program = program.as_ref();
 296        let program = program
 297            .file_stem()
 298            .unwrap_or_else(|| program.as_os_str())
 299            .to_string_lossy();
 300
 301        match &*program {
 302            "powershell" => ShellKind::PowerShell,
 303            "pwsh" => ShellKind::Pwsh,
 304            "cmd" => ShellKind::Cmd,
 305            "nu" => ShellKind::Nushell,
 306            "fish" => ShellKind::Fish,
 307            "csh" => ShellKind::Csh,
 308            "tcsh" => ShellKind::Tcsh,
 309            "rc" => ShellKind::Rc,
 310            "xonsh" => ShellKind::Xonsh,
 311            "elvish" => ShellKind::Elvish,
 312            "sh" | "bash" | "zsh" => ShellKind::Posix,
 313            _ if is_windows => ShellKind::PowerShell,
 314            // Some other shell detected, the user might install and use a
 315            // unix-like shell.
 316            _ => ShellKind::Posix,
 317        }
 318    }
 319
 320    pub fn to_shell_variable(self, input: &str) -> String {
 321        match self {
 322            Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
 323            Self::Cmd => Self::to_cmd_variable(input),
 324            Self::Posix => input.to_owned(),
 325            Self::Fish => input.to_owned(),
 326            Self::Csh => input.to_owned(),
 327            Self::Tcsh => input.to_owned(),
 328            Self::Rc => input.to_owned(),
 329            Self::Nushell => Self::to_nushell_variable(input),
 330            Self::Xonsh => input.to_owned(),
 331            Self::Elvish => input.to_owned(),
 332        }
 333    }
 334
 335    fn to_cmd_variable(input: &str) -> String {
 336        if let Some(var_str) = input.strip_prefix("${") {
 337            if var_str.find(':').is_none() {
 338                // If the input starts with "${", remove the trailing "}"
 339                format!("%{}%", &var_str[..var_str.len() - 1])
 340            } else {
 341                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
 342                // which will result in the task failing to run in such cases.
 343                input.into()
 344            }
 345        } else if let Some(var_str) = input.strip_prefix('$') {
 346            // If the input starts with "$", directly append to "$env:"
 347            format!("%{}%", var_str)
 348        } else {
 349            // If no prefix is found, return the input as is
 350            input.into()
 351        }
 352    }
 353
 354    fn to_powershell_variable(input: &str) -> String {
 355        if let Some(var_str) = input.strip_prefix("${") {
 356            if var_str.find(':').is_none() {
 357                // If the input starts with "${", remove the trailing "}"
 358                format!("$env:{}", &var_str[..var_str.len() - 1])
 359            } else {
 360                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
 361                // which will result in the task failing to run in such cases.
 362                input.into()
 363            }
 364        } else if let Some(var_str) = input.strip_prefix('$') {
 365            // If the input starts with "$", directly append to "$env:"
 366            format!("$env:{}", var_str)
 367        } else {
 368            // If no prefix is found, return the input as is
 369            input.into()
 370        }
 371    }
 372
 373    fn to_nushell_variable(input: &str) -> String {
 374        let mut result = String::new();
 375        let mut source = input;
 376        let mut is_start = true;
 377
 378        loop {
 379            match source.chars().next() {
 380                None => return result,
 381                Some('$') => {
 382                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
 383                    is_start = false;
 384                }
 385                Some(_) => {
 386                    is_start = false;
 387                    let chunk_end = source.find('$').unwrap_or(source.len());
 388                    let (chunk, rest) = source.split_at(chunk_end);
 389                    result.push_str(chunk);
 390                    source = rest;
 391                }
 392            }
 393        }
 394    }
 395
 396    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
 397        if source.starts_with("env.") {
 398            text.push('$');
 399            return source;
 400        }
 401
 402        match source.chars().next() {
 403            Some('{') => {
 404                let source = &source[1..];
 405                if let Some(end) = source.find('}') {
 406                    let var_name = &source[..end];
 407                    if !var_name.is_empty() {
 408                        if !is_start {
 409                            text.push_str("(");
 410                        }
 411                        text.push_str("$env.");
 412                        text.push_str(var_name);
 413                        if !is_start {
 414                            text.push_str(")");
 415                        }
 416                        &source[end + 1..]
 417                    } else {
 418                        text.push_str("${}");
 419                        &source[end + 1..]
 420                    }
 421                } else {
 422                    text.push_str("${");
 423                    source
 424                }
 425            }
 426            Some(c) if c.is_alphabetic() || c == '_' => {
 427                let end = source
 428                    .find(|c: char| !c.is_alphanumeric() && c != '_')
 429                    .unwrap_or(source.len());
 430                let var_name = &source[..end];
 431                if !is_start {
 432                    text.push_str("(");
 433                }
 434                text.push_str("$env.");
 435                text.push_str(var_name);
 436                if !is_start {
 437                    text.push_str(")");
 438                }
 439                &source[end..]
 440            }
 441            _ => {
 442                text.push('$');
 443                source
 444            }
 445        }
 446    }
 447
 448    pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
 449        match self {
 450            ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
 451            ShellKind::Cmd => vec![
 452                "/S".to_owned(),
 453                "/C".to_owned(),
 454                format!("\"{combined_command}\""),
 455            ],
 456            ShellKind::Posix
 457            | ShellKind::Nushell
 458            | ShellKind::Fish
 459            | ShellKind::Csh
 460            | ShellKind::Tcsh
 461            | ShellKind::Rc
 462            | ShellKind::Xonsh
 463            | ShellKind::Elvish => interactive
 464                .then(|| "-i".to_owned())
 465                .into_iter()
 466                .chain(["-c".to_owned(), combined_command])
 467                .collect(),
 468        }
 469    }
 470
 471    pub const fn command_prefix(&self) -> Option<char> {
 472        match self {
 473            ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
 474            ShellKind::Nushell => Some('^'),
 475            ShellKind::Posix
 476            | ShellKind::Csh
 477            | ShellKind::Tcsh
 478            | ShellKind::Rc
 479            | ShellKind::Fish
 480            | ShellKind::Cmd
 481            | ShellKind::Xonsh
 482            | ShellKind::Elvish => None,
 483        }
 484    }
 485
 486    pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
 487        match self.command_prefix() {
 488            Some(prefix) if !command.starts_with(prefix) => {
 489                Cow::Owned(format!("{prefix}{command}"))
 490            }
 491            _ => Cow::Borrowed(command),
 492        }
 493    }
 494
 495    pub const fn sequential_commands_separator(&self) -> char {
 496        match self {
 497            ShellKind::Cmd => '&',
 498            ShellKind::Posix
 499            | ShellKind::Csh
 500            | ShellKind::Tcsh
 501            | ShellKind::Rc
 502            | ShellKind::Fish
 503            | ShellKind::PowerShell
 504            | ShellKind::Pwsh
 505            | ShellKind::Nushell
 506            | ShellKind::Xonsh
 507            | ShellKind::Elvish => ';',
 508        }
 509    }
 510
 511    pub const fn sequential_and_commands_separator(&self) -> &'static str {
 512        match self {
 513            ShellKind::Cmd
 514            | ShellKind::Posix
 515            | ShellKind::Csh
 516            | ShellKind::Tcsh
 517            | ShellKind::Rc
 518            | ShellKind::Fish
 519            | ShellKind::Pwsh
 520            | ShellKind::Xonsh => "&&",
 521            ShellKind::PowerShell | ShellKind::Nushell | ShellKind::Elvish => ";",
 522        }
 523    }
 524
 525    pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
 526        match self {
 527            ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
 528            ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
 529            ShellKind::Cmd => Some(Self::quote_cmd(arg)),
 530            ShellKind::Posix
 531            | ShellKind::Csh
 532            | ShellKind::Tcsh
 533            | ShellKind::Rc
 534            | ShellKind::Fish
 535            | ShellKind::Nushell
 536            | ShellKind::Xonsh
 537            | ShellKind::Elvish => shlex::try_quote(arg).ok(),
 538        }
 539    }
 540
 541    fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
 542        if arg.is_empty() {
 543            return Cow::Borrowed("\"\"");
 544        }
 545
 546        let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
 547        if !needs_quoting {
 548            return Cow::Borrowed(arg);
 549        }
 550
 551        let mut result = String::with_capacity(arg.len() + 2);
 552
 553        if enclose {
 554            result.push('"');
 555        }
 556
 557        let chars: Vec<char> = arg.chars().collect();
 558        let mut i = 0;
 559
 560        while i < chars.len() {
 561            if chars[i] == '\\' {
 562                let mut num_backslashes = 0;
 563                while i < chars.len() && chars[i] == '\\' {
 564                    num_backslashes += 1;
 565                    i += 1;
 566                }
 567
 568                if i < chars.len() && chars[i] == '"' {
 569                    // Backslashes followed by quote: double the backslashes and escape the quote
 570                    for _ in 0..(num_backslashes * 2 + 1) {
 571                        result.push('\\');
 572                    }
 573                    result.push('"');
 574                    i += 1;
 575                } else if i >= chars.len() {
 576                    // Trailing backslashes: double them (they precede the closing quote)
 577                    for _ in 0..(num_backslashes * 2) {
 578                        result.push('\\');
 579                    }
 580                } else {
 581                    // Backslashes not followed by quote: output as-is
 582                    for _ in 0..num_backslashes {
 583                        result.push('\\');
 584                    }
 585                }
 586            } else if chars[i] == '"' {
 587                // Quote not preceded by backslash: escape it
 588                result.push('\\');
 589                result.push('"');
 590                i += 1;
 591            } else {
 592                result.push(chars[i]);
 593                i += 1;
 594            }
 595        }
 596
 597        if enclose {
 598            result.push('"');
 599        }
 600        Cow::Owned(result)
 601    }
 602
 603    fn needs_quoting_powershell(s: &str) -> bool {
 604        s.is_empty()
 605            || s.chars().any(|c| {
 606                c.is_whitespace()
 607                    || matches!(
 608                        c,
 609                        '"' | '`'
 610                            | '$'
 611                            | '&'
 612                            | '|'
 613                            | '<'
 614                            | '>'
 615                            | ';'
 616                            | '('
 617                            | ')'
 618                            | '['
 619                            | ']'
 620                            | '{'
 621                            | '}'
 622                            | ','
 623                            | '\''
 624                            | '@'
 625                    )
 626            })
 627    }
 628
 629    fn need_quotes_powershell(arg: &str) -> bool {
 630        let mut quote_count = 0;
 631        for c in arg.chars() {
 632            if c == '"' {
 633                quote_count += 1;
 634            } else if c.is_whitespace() && (quote_count % 2 == 0) {
 635                return true;
 636            }
 637        }
 638        false
 639    }
 640
 641    fn escape_powershell_quotes(s: &str) -> String {
 642        let mut result = String::with_capacity(s.len() + 4);
 643        result.push('\'');
 644        for c in s.chars() {
 645            if c == '\'' {
 646                result.push('\'');
 647            }
 648            result.push(c);
 649        }
 650        result.push('\'');
 651        result
 652    }
 653
 654    pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
 655        let ps_will_quote = Self::need_quotes_powershell(arg);
 656        let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
 657
 658        if !Self::needs_quoting_powershell(arg) {
 659            return crt_quoted;
 660        }
 661
 662        Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
 663    }
 664
 665    pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
 666        if arg.is_empty() {
 667            return Cow::Borrowed("''");
 668        }
 669
 670        if !Self::needs_quoting_powershell(arg) {
 671            return Cow::Borrowed(arg);
 672        }
 673
 674        Cow::Owned(Self::escape_powershell_quotes(arg))
 675    }
 676
 677    pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
 678        let crt_quoted = Self::quote_windows(arg, true);
 679
 680        let needs_cmd_escaping = crt_quoted.contains(['"', '%', '^', '<', '>', '&', '|', '(', ')']);
 681
 682        if !needs_cmd_escaping {
 683            return crt_quoted;
 684        }
 685
 686        let mut result = String::with_capacity(crt_quoted.len() * 2);
 687        for c in crt_quoted.chars() {
 688            match c {
 689                '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
 690                    result.push('^');
 691                    result.push(c);
 692                }
 693                '%' => {
 694                    result.push_str("%%cd:~,%");
 695                }
 696                _ => result.push(c),
 697            }
 698        }
 699        Cow::Owned(result)
 700    }
 701
 702    /// Quotes the given argument if necessary, taking into account the command prefix.
 703    ///
 704    /// In other words, this will consider quoting arg without its command prefix to not break the command.
 705    /// You should use this over `try_quote` when you want to quote a shell command.
 706    pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
 707        if let Some(char) = self.command_prefix() {
 708            if let Some(arg) = arg.strip_prefix(char) {
 709                // we have a command that is prefixed
 710                for quote in ['\'', '"'] {
 711                    if let Some(arg) = arg
 712                        .strip_prefix(quote)
 713                        .and_then(|arg| arg.strip_suffix(quote))
 714                    {
 715                        // and the command itself is wrapped as a literal, that
 716                        // means the prefix exists to interpret a literal as a
 717                        // command. So strip the quotes, quote the command, and
 718                        // re-add the quotes if they are missing after requoting
 719                        let quoted = self.try_quote(arg)?;
 720                        return Some(if quoted.starts_with(['\'', '"']) {
 721                            Cow::Owned(self.prepend_command_prefix(&quoted).into_owned())
 722                        } else {
 723                            Cow::Owned(
 724                                self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
 725                                    .into_owned(),
 726                            )
 727                        });
 728                    }
 729                }
 730                return self
 731                    .try_quote(arg)
 732                    .map(|quoted| Cow::Owned(self.prepend_command_prefix(&quoted).into_owned()));
 733            }
 734        }
 735        self.try_quote(arg).map(|quoted| match quoted {
 736            unquoted @ Cow::Borrowed(_) => unquoted,
 737            Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix(&quoted).into_owned()),
 738        })
 739    }
 740
 741    pub fn split(&self, input: &str) -> Option<Vec<String>> {
 742        shlex::split(input)
 743    }
 744
 745    pub const fn activate_keyword(&self) -> &'static str {
 746        match self {
 747            ShellKind::Cmd => "",
 748            ShellKind::Nushell => "overlay use",
 749            ShellKind::PowerShell | ShellKind::Pwsh => ".",
 750            ShellKind::Fish
 751            | ShellKind::Csh
 752            | ShellKind::Tcsh
 753            | ShellKind::Posix
 754            | ShellKind::Rc
 755            | ShellKind::Xonsh
 756            | ShellKind::Elvish => "source",
 757        }
 758    }
 759
 760    pub const fn clear_screen_command(&self) -> &'static str {
 761        match self {
 762            ShellKind::Cmd => "cls",
 763            ShellKind::Posix
 764            | ShellKind::Csh
 765            | ShellKind::Tcsh
 766            | ShellKind::Rc
 767            | ShellKind::Fish
 768            | ShellKind::PowerShell
 769            | ShellKind::Pwsh
 770            | ShellKind::Nushell
 771            | ShellKind::Xonsh
 772            | ShellKind::Elvish => "clear",
 773        }
 774    }
 775
 776    #[cfg(windows)]
 777    /// We do not want to escape arguments if we are using CMD as our shell.
 778    /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
 779    pub const fn tty_escape_args(&self) -> bool {
 780        match self {
 781            ShellKind::Cmd => false,
 782            ShellKind::Posix
 783            | ShellKind::Csh
 784            | ShellKind::Tcsh
 785            | ShellKind::Rc
 786            | ShellKind::Fish
 787            | ShellKind::PowerShell
 788            | ShellKind::Pwsh
 789            | ShellKind::Nushell
 790            | ShellKind::Xonsh
 791            | ShellKind::Elvish => true,
 792        }
 793    }
 794}
 795
 796#[cfg(test)]
 797mod tests {
 798    use super::*;
 799
 800    // Examples
 801    // WSL
 802    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
 803    // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
 804    // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
 805    // PowerShell from Nushell
 806    // 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\""
 807    // PowerShell from CMD
 808    // 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\\\"\"\"
 809
 810    #[test]
 811    fn test_try_quote_powershell() {
 812        let shell_kind = ShellKind::PowerShell;
 813        assert_eq!(
 814            shell_kind
 815                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
 816                .unwrap()
 817                .into_owned(),
 818            "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
 819        );
 820    }
 821
 822    #[test]
 823    fn test_try_quote_cmd() {
 824        let shell_kind = ShellKind::Cmd;
 825        assert_eq!(
 826            shell_kind
 827                .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
 828                .unwrap()
 829                .into_owned(),
 830            "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
 831        );
 832    }
 833
 834    #[test]
 835    fn test_try_quote_powershell_edge_cases() {
 836        let shell_kind = ShellKind::PowerShell;
 837
 838        // Empty string
 839        assert_eq!(
 840            shell_kind.try_quote("").unwrap().into_owned(),
 841            "'\"\"'".to_string()
 842        );
 843
 844        // String without special characters (no quoting needed)
 845        assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
 846
 847        // String with spaces
 848        assert_eq!(
 849            shell_kind.try_quote("hello world").unwrap().into_owned(),
 850            "'hello world'".to_string()
 851        );
 852
 853        // String with dollar signs
 854        assert_eq!(
 855            shell_kind.try_quote("$variable").unwrap().into_owned(),
 856            "'$variable'".to_string()
 857        );
 858
 859        // String with backticks
 860        assert_eq!(
 861            shell_kind.try_quote("test`command").unwrap().into_owned(),
 862            "'test`command'".to_string()
 863        );
 864
 865        // String with multiple special characters
 866        assert_eq!(
 867            shell_kind
 868                .try_quote("test `\"$var`\" end")
 869                .unwrap()
 870                .into_owned(),
 871            "'test `\\\"$var`\\\" end'".to_string()
 872        );
 873
 874        // String with backslashes and colon (path without spaces doesn't need quoting)
 875        assert_eq!(
 876            shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
 877            "C:\\path\\to\\file"
 878        );
 879    }
 880
 881    #[test]
 882    fn test_try_quote_cmd_edge_cases() {
 883        let shell_kind = ShellKind::Cmd;
 884
 885        // Empty string
 886        assert_eq!(
 887            shell_kind.try_quote("").unwrap().into_owned(),
 888            "^\"^\"".to_string()
 889        );
 890
 891        // String without special characters (no quoting needed)
 892        assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
 893
 894        // String with spaces
 895        assert_eq!(
 896            shell_kind.try_quote("hello world").unwrap().into_owned(),
 897            "^\"hello world^\"".to_string()
 898        );
 899
 900        // String with space and backslash (backslash not at end, so not doubled)
 901        assert_eq!(
 902            shell_kind.try_quote("path\\ test").unwrap().into_owned(),
 903            "^\"path\\ test^\"".to_string()
 904        );
 905
 906        // String ending with backslash (must be doubled before closing quote)
 907        assert_eq!(
 908            shell_kind.try_quote("test path\\").unwrap().into_owned(),
 909            "^\"test path\\\\^\"".to_string()
 910        );
 911
 912        // String ending with multiple backslashes (all doubled before closing quote)
 913        assert_eq!(
 914            shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
 915            "^\"test path\\\\\\\\^\"".to_string()
 916        );
 917
 918        // String with embedded quote (quote is escaped, backslash before it is doubled)
 919        assert_eq!(
 920            shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
 921            "^\"test\\\\\\^\"quote^\"".to_string()
 922        );
 923
 924        // String with multiple backslashes before embedded quote (all doubled)
 925        assert_eq!(
 926            shell_kind
 927                .try_quote("test\\\\\"quote")
 928                .unwrap()
 929                .into_owned(),
 930            "^\"test\\\\\\\\\\^\"quote^\"".to_string()
 931        );
 932
 933        // String with backslashes not before quotes (path without spaces doesn't need quoting)
 934        assert_eq!(
 935            shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
 936            "C:\\path\\to\\file"
 937        );
 938    }
 939
 940    #[test]
 941    fn test_try_quote_nu_command() {
 942        let shell_kind = ShellKind::Nushell;
 943        assert_eq!(
 944            shell_kind.try_quote("'uname'").unwrap().into_owned(),
 945            "\"'uname'\"".to_string()
 946        );
 947        assert_eq!(
 948            shell_kind
 949                .try_quote_prefix_aware("'uname'")
 950                .unwrap()
 951                .into_owned(),
 952            "^\"'uname'\"".to_string()
 953        );
 954        assert_eq!(
 955            shell_kind.try_quote("^uname").unwrap().into_owned(),
 956            "'^uname'".to_string()
 957        );
 958        assert_eq!(
 959            shell_kind
 960                .try_quote_prefix_aware("^uname")
 961                .unwrap()
 962                .into_owned(),
 963            "^uname".to_string()
 964        );
 965        assert_eq!(
 966            shell_kind.try_quote("^'uname'").unwrap().into_owned(),
 967            "'^'\"'uname\'\"".to_string()
 968        );
 969        assert_eq!(
 970            shell_kind
 971                .try_quote_prefix_aware("^'uname'")
 972                .unwrap()
 973                .into_owned(),
 974            "^'uname'".to_string()
 975        );
 976        assert_eq!(
 977            shell_kind.try_quote("'uname a'").unwrap().into_owned(),
 978            "\"'uname a'\"".to_string()
 979        );
 980        assert_eq!(
 981            shell_kind
 982                .try_quote_prefix_aware("'uname a'")
 983                .unwrap()
 984                .into_owned(),
 985            "^\"'uname a'\"".to_string()
 986        );
 987        assert_eq!(
 988            shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
 989            "'^'\"'uname a'\"".to_string()
 990        );
 991        assert_eq!(
 992            shell_kind
 993                .try_quote_prefix_aware("^'uname a'")
 994                .unwrap()
 995                .into_owned(),
 996            "^'uname a'".to_string()
 997        );
 998        assert_eq!(
 999            shell_kind.try_quote("uname").unwrap().into_owned(),
1000            "uname".to_string()
1001        );
1002        assert_eq!(
1003            shell_kind
1004                .try_quote_prefix_aware("uname")
1005                .unwrap()
1006                .into_owned(),
1007            "uname".to_string()
1008        );
1009    }
1010}