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("ed).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("ed).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("ed).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}