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