1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::{borrow::Cow, fmt, path::Path, sync::LazyLock};
4
5/// Shell configuration to open the terminal with.
6#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)]
7#[serde(rename_all = "snake_case")]
8pub enum Shell {
9 /// Use the system's default terminal configuration in /etc/passwd
10 #[default]
11 System,
12 /// Use a specific program with no arguments.
13 Program(String),
14 /// Use a specific program with arguments.
15 WithArguments {
16 /// The program to run.
17 program: String,
18 /// The arguments to pass to the program.
19 args: Vec<String>,
20 /// An optional string to override the title of the terminal tab
21 title_override: Option<String>,
22 },
23}
24
25impl Shell {
26 pub fn program(&self) -> String {
27 match self {
28 Shell::Program(program) => program.clone(),
29 Shell::WithArguments { program, .. } => program.clone(),
30 Shell::System => get_system_shell(),
31 }
32 }
33
34 pub fn program_and_args(&self) -> (String, &[String]) {
35 match self {
36 Shell::Program(program) => (program.clone(), &[]),
37 Shell::WithArguments { program, args, .. } => (program.clone(), args),
38 Shell::System => (get_system_shell(), &[]),
39 }
40 }
41
42 pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
43 match self {
44 Shell::Program(program) => ShellKind::new(program, is_windows),
45 Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
46 Shell::System => ShellKind::system(),
47 }
48 }
49}
50
51#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub enum ShellKind {
53 #[default]
54 Posix,
55 Csh,
56 Tcsh,
57 Rc,
58 Fish,
59 PowerShell,
60 Nushell,
61 Cmd,
62 Xonsh,
63 Elvish,
64}
65
66pub fn get_system_shell() -> String {
67 if cfg!(windows) {
68 get_windows_system_shell()
69 } else {
70 std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
71 }
72}
73
74pub fn get_default_system_shell() -> String {
75 if cfg!(windows) {
76 get_windows_system_shell()
77 } else {
78 "/bin/sh".to_string()
79 }
80}
81
82/// Get the default system shell, preferring bash on Windows.
83pub fn get_default_system_shell_preferring_bash() -> String {
84 if cfg!(windows) {
85 get_windows_bash().unwrap_or_else(|| get_windows_system_shell())
86 } else {
87 "/bin/sh".to_string()
88 }
89}
90
91pub fn get_windows_bash() -> Option<String> {
92 use std::path::PathBuf;
93
94 fn find_bash_in_scoop() -> Option<PathBuf> {
95 let bash_exe =
96 PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\bash.exe");
97 bash_exe.exists().then_some(bash_exe)
98 }
99
100 fn find_bash_in_git() -> Option<PathBuf> {
101 // /path/to/git/cmd/git.exe/../../bin/bash.exe
102 let git = which::which("git").ok()?;
103 let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
104 git_bash.exists().then_some(git_bash)
105 }
106
107 static BASH: LazyLock<Option<String>> = LazyLock::new(|| {
108 let bash = find_bash_in_scoop()
109 .or_else(|| find_bash_in_git())
110 .map(|p| p.to_string_lossy().into_owned());
111 if let Some(ref path) = bash {
112 log::info!("Found bash at {}", path);
113 }
114 bash
115 });
116
117 (*BASH).clone()
118}
119
120pub fn get_windows_system_shell() -> String {
121 use std::path::PathBuf;
122
123 fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
124 #[cfg(target_pointer_width = "64")]
125 let env_var = if find_alternate {
126 "ProgramFiles(x86)"
127 } else {
128 "ProgramFiles"
129 };
130
131 #[cfg(target_pointer_width = "32")]
132 let env_var = if find_alternate {
133 "ProgramW6432"
134 } else {
135 "ProgramFiles"
136 };
137
138 let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
139 install_base_dir
140 .read_dir()
141 .ok()?
142 .filter_map(Result::ok)
143 .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
144 .filter_map(|entry| {
145 let dir_name = entry.file_name();
146 let dir_name = dir_name.to_string_lossy();
147
148 let version = if find_preview {
149 let dash_index = dir_name.find('-')?;
150 if &dir_name[dash_index + 1..] != "preview" {
151 return None;
152 };
153 dir_name[..dash_index].parse::<u32>().ok()?
154 } else {
155 dir_name.parse::<u32>().ok()?
156 };
157
158 let exe_path = entry.path().join("pwsh.exe");
159 if exe_path.exists() {
160 Some((version, exe_path))
161 } else {
162 None
163 }
164 })
165 .max_by_key(|(version, _)| *version)
166 .map(|(_, path)| path)
167 }
168
169 fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
170 let msix_app_dir =
171 PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
172 if !msix_app_dir.exists() {
173 return None;
174 }
175
176 let prefix = if find_preview {
177 "Microsoft.PowerShellPreview_"
178 } else {
179 "Microsoft.PowerShell_"
180 };
181 msix_app_dir
182 .read_dir()
183 .ok()?
184 .filter_map(|entry| {
185 let entry = entry.ok()?;
186 if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
187 return None;
188 }
189
190 if !entry.file_name().to_string_lossy().starts_with(prefix) {
191 return None;
192 }
193
194 let exe_path = entry.path().join("pwsh.exe");
195 exe_path.exists().then_some(exe_path)
196 })
197 .next()
198 }
199
200 fn find_pwsh_in_scoop() -> Option<PathBuf> {
201 let pwsh_exe =
202 PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
203 pwsh_exe.exists().then_some(pwsh_exe)
204 }
205
206 static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
207 find_pwsh_in_programfiles(false, false)
208 .or_else(|| find_pwsh_in_programfiles(true, false))
209 .or_else(|| find_pwsh_in_msix(false))
210 .or_else(|| find_pwsh_in_programfiles(false, true))
211 .or_else(|| find_pwsh_in_msix(true))
212 .or_else(|| find_pwsh_in_programfiles(true, true))
213 .or_else(find_pwsh_in_scoop)
214 .map(|p| p.to_string_lossy().into_owned())
215 .inspect(|shell| log::info!("Found powershell in: {}", shell))
216 .unwrap_or_else(|| {
217 log::warn!("Powershell not found, falling back to `cmd`");
218 "cmd.exe".to_string()
219 })
220 });
221
222 (*SYSTEM_SHELL).clone()
223}
224
225impl fmt::Display for ShellKind {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 match self {
228 ShellKind::Posix => write!(f, "sh"),
229 ShellKind::Csh => write!(f, "csh"),
230 ShellKind::Tcsh => write!(f, "tcsh"),
231 ShellKind::Fish => write!(f, "fish"),
232 ShellKind::PowerShell => write!(f, "powershell"),
233 ShellKind::Nushell => write!(f, "nu"),
234 ShellKind::Cmd => write!(f, "cmd"),
235 ShellKind::Rc => write!(f, "rc"),
236 ShellKind::Xonsh => write!(f, "xonsh"),
237 ShellKind::Elvish => write!(f, "elvish"),
238 }
239 }
240}
241
242impl ShellKind {
243 pub fn system() -> Self {
244 Self::new(&get_system_shell(), cfg!(windows))
245 }
246
247 pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
248 let program = program.as_ref();
249 let program = program
250 .file_stem()
251 .unwrap_or_else(|| program.as_os_str())
252 .to_string_lossy();
253
254 match &*program {
255 "powershell" | "pwsh" => ShellKind::PowerShell,
256 "cmd" => ShellKind::Cmd,
257 "nu" => ShellKind::Nushell,
258 "fish" => ShellKind::Fish,
259 "csh" => ShellKind::Csh,
260 "tcsh" => ShellKind::Tcsh,
261 "rc" => ShellKind::Rc,
262 "xonsh" => ShellKind::Xonsh,
263 "elvish" => ShellKind::Elvish,
264 "sh" | "bash" | "zsh" => ShellKind::Posix,
265 _ if is_windows => ShellKind::PowerShell,
266 // Some other shell detected, the user might install and use a
267 // unix-like shell.
268 _ => ShellKind::Posix,
269 }
270 }
271
272 pub fn to_shell_variable(self, input: &str) -> String {
273 match self {
274 Self::PowerShell => Self::to_powershell_variable(input),
275 Self::Cmd => Self::to_cmd_variable(input),
276 Self::Posix => input.to_owned(),
277 Self::Fish => input.to_owned(),
278 Self::Csh => input.to_owned(),
279 Self::Tcsh => input.to_owned(),
280 Self::Rc => input.to_owned(),
281 Self::Nushell => Self::to_nushell_variable(input),
282 Self::Xonsh => input.to_owned(),
283 Self::Elvish => input.to_owned(),
284 }
285 }
286
287 fn to_cmd_variable(input: &str) -> String {
288 if let Some(var_str) = input.strip_prefix("${") {
289 if var_str.find(':').is_none() {
290 // If the input starts with "${", remove the trailing "}"
291 format!("%{}%", &var_str[..var_str.len() - 1])
292 } else {
293 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
294 // which will result in the task failing to run in such cases.
295 input.into()
296 }
297 } else if let Some(var_str) = input.strip_prefix('$') {
298 // If the input starts with "$", directly append to "$env:"
299 format!("%{}%", var_str)
300 } else {
301 // If no prefix is found, return the input as is
302 input.into()
303 }
304 }
305
306 fn to_powershell_variable(input: &str) -> String {
307 if let Some(var_str) = input.strip_prefix("${") {
308 if var_str.find(':').is_none() {
309 // If the input starts with "${", remove the trailing "}"
310 format!("$env:{}", &var_str[..var_str.len() - 1])
311 } else {
312 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
313 // which will result in the task failing to run in such cases.
314 input.into()
315 }
316 } else if let Some(var_str) = input.strip_prefix('$') {
317 // If the input starts with "$", directly append to "$env:"
318 format!("$env:{}", var_str)
319 } else {
320 // If no prefix is found, return the input as is
321 input.into()
322 }
323 }
324
325 fn to_nushell_variable(input: &str) -> String {
326 let mut result = String::new();
327 let mut source = input;
328 let mut is_start = true;
329
330 loop {
331 match source.chars().next() {
332 None => return result,
333 Some('$') => {
334 source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
335 is_start = false;
336 }
337 Some(_) => {
338 is_start = false;
339 let chunk_end = source.find('$').unwrap_or(source.len());
340 let (chunk, rest) = source.split_at(chunk_end);
341 result.push_str(chunk);
342 source = rest;
343 }
344 }
345 }
346 }
347
348 fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
349 if source.starts_with("env.") {
350 text.push('$');
351 return source;
352 }
353
354 match source.chars().next() {
355 Some('{') => {
356 let source = &source[1..];
357 if let Some(end) = source.find('}') {
358 let var_name = &source[..end];
359 if !var_name.is_empty() {
360 if !is_start {
361 text.push_str("(");
362 }
363 text.push_str("$env.");
364 text.push_str(var_name);
365 if !is_start {
366 text.push_str(")");
367 }
368 &source[end + 1..]
369 } else {
370 text.push_str("${}");
371 &source[end + 1..]
372 }
373 } else {
374 text.push_str("${");
375 source
376 }
377 }
378 Some(c) if c.is_alphabetic() || c == '_' => {
379 let end = source
380 .find(|c: char| !c.is_alphanumeric() && c != '_')
381 .unwrap_or(source.len());
382 let var_name = &source[..end];
383 if !is_start {
384 text.push_str("(");
385 }
386 text.push_str("$env.");
387 text.push_str(var_name);
388 if !is_start {
389 text.push_str(")");
390 }
391 &source[end..]
392 }
393 _ => {
394 text.push('$');
395 source
396 }
397 }
398 }
399
400 pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
401 match self {
402 ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
403 ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
404 ShellKind::Posix
405 | ShellKind::Nushell
406 | ShellKind::Fish
407 | ShellKind::Csh
408 | ShellKind::Tcsh
409 | ShellKind::Rc
410 | ShellKind::Xonsh
411 | ShellKind::Elvish => interactive
412 .then(|| "-i".to_owned())
413 .into_iter()
414 .chain(["-c".to_owned(), combined_command])
415 .collect(),
416 }
417 }
418
419 pub const fn command_prefix(&self) -> Option<char> {
420 match self {
421 ShellKind::PowerShell => Some('&'),
422 ShellKind::Nushell => Some('^'),
423 ShellKind::Posix
424 | ShellKind::Csh
425 | ShellKind::Tcsh
426 | ShellKind::Rc
427 | ShellKind::Fish
428 | ShellKind::Cmd
429 | ShellKind::Xonsh
430 | ShellKind::Elvish => None,
431 }
432 }
433
434 pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
435 match self.command_prefix() {
436 Some(prefix) if !command.starts_with(prefix) => {
437 Cow::Owned(format!("{prefix}{command}"))
438 }
439 _ => Cow::Borrowed(command),
440 }
441 }
442
443 pub const fn sequential_commands_separator(&self) -> char {
444 match self {
445 ShellKind::Cmd => '&',
446 ShellKind::Posix
447 | ShellKind::Csh
448 | ShellKind::Tcsh
449 | ShellKind::Rc
450 | ShellKind::Fish
451 | ShellKind::PowerShell
452 | ShellKind::Nushell
453 | ShellKind::Xonsh
454 | ShellKind::Elvish => ';',
455 }
456 }
457
458 pub const fn sequential_and_commands_separator(&self) -> &'static str {
459 match self {
460 ShellKind::Cmd
461 | ShellKind::Posix
462 | ShellKind::Csh
463 | ShellKind::Tcsh
464 | ShellKind::Rc
465 | ShellKind::Fish
466 | ShellKind::PowerShell
467 | ShellKind::Xonsh => "&&",
468 ShellKind::Nushell | ShellKind::Elvish => ";",
469 }
470 }
471
472 pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
473 shlex::try_quote(arg).ok().map(|arg| match self {
474 // If we are running in PowerShell, we want to take extra care when escaping strings.
475 // In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
476 ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")),
477 ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
478 ShellKind::Posix
479 | ShellKind::Csh
480 | ShellKind::Tcsh
481 | ShellKind::Rc
482 | ShellKind::Fish
483 | ShellKind::Nushell
484 | ShellKind::Xonsh
485 | ShellKind::Elvish => arg,
486 })
487 }
488
489 /// Quotes the given argument if necessary, taking into account the command prefix.
490 ///
491 /// In other words, this will consider quoting arg without its command prefix to not break the command.
492 /// You should use this over `try_quote` when you want to quote a shell command.
493 pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
494 if let Some(char) = self.command_prefix() {
495 if let Some(arg) = arg.strip_prefix(char) {
496 // we have a command that is prefixed
497 for quote in ['\'', '"'] {
498 if let Some(arg) = arg
499 .strip_prefix(quote)
500 .and_then(|arg| arg.strip_suffix(quote))
501 {
502 // and the command itself is wrapped as a literal, that
503 // means the prefix exists to interpret a literal as a
504 // command. So strip the quotes, quote the command, and
505 // re-add the quotes if they are missing after requoting
506 let quoted = self.try_quote(arg)?;
507 return Some(if quoted.starts_with(['\'', '"']) {
508 Cow::Owned(self.prepend_command_prefix("ed).into_owned())
509 } else {
510 Cow::Owned(
511 self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
512 .into_owned(),
513 )
514 });
515 }
516 }
517 return self
518 .try_quote(arg)
519 .map(|quoted| Cow::Owned(self.prepend_command_prefix("ed).into_owned()));
520 }
521 }
522 self.try_quote(arg)
523 }
524
525 pub fn split(&self, input: &str) -> Option<Vec<String>> {
526 shlex::split(input)
527 }
528
529 pub const fn activate_keyword(&self) -> &'static str {
530 match self {
531 ShellKind::Cmd => "",
532 ShellKind::Nushell => "overlay use",
533 ShellKind::PowerShell => ".",
534 ShellKind::Fish
535 | ShellKind::Csh
536 | ShellKind::Tcsh
537 | ShellKind::Posix
538 | ShellKind::Rc
539 | ShellKind::Xonsh
540 | ShellKind::Elvish => "source",
541 }
542 }
543
544 pub const fn clear_screen_command(&self) -> &'static str {
545 match self {
546 ShellKind::Cmd => "cls",
547 ShellKind::Posix
548 | ShellKind::Csh
549 | ShellKind::Tcsh
550 | ShellKind::Rc
551 | ShellKind::Fish
552 | ShellKind::PowerShell
553 | ShellKind::Nushell
554 | ShellKind::Xonsh
555 | ShellKind::Elvish => "clear",
556 }
557 }
558
559 #[cfg(windows)]
560 /// We do not want to escape arguments if we are using CMD as our shell.
561 /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
562 pub const fn tty_escape_args(&self) -> bool {
563 match self {
564 ShellKind::Cmd => false,
565 ShellKind::Posix
566 | ShellKind::Csh
567 | ShellKind::Tcsh
568 | ShellKind::Rc
569 | ShellKind::Fish
570 | ShellKind::PowerShell
571 | ShellKind::Nushell
572 | ShellKind::Xonsh
573 | ShellKind::Elvish => true,
574 }
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 // Examples
583 // WSL
584 // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
585 // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
586 // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
587 // PowerShell from Nushell
588 // 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\""
589 // PowerShell from CMD
590 // 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\\\"\"\"
591
592 #[test]
593 fn test_try_quote_powershell() {
594 let shell_kind = ShellKind::PowerShell;
595 assert_eq!(
596 shell_kind
597 .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
598 .unwrap()
599 .into_owned(),
600 "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest `\"test_foo.py::test_foo`\"\"".to_string()
601 );
602 }
603
604 #[test]
605 fn test_try_quote_cmd() {
606 let shell_kind = ShellKind::Cmd;
607 assert_eq!(
608 shell_kind
609 .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
610 .unwrap()
611 .into_owned(),
612 "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"".to_string()
613 );
614 }
615
616 #[test]
617 fn test_try_quote_nu_command() {
618 let shell_kind = ShellKind::Nushell;
619 assert_eq!(
620 shell_kind.try_quote("'uname'").unwrap().into_owned(),
621 "\"'uname'\"".to_string()
622 );
623 assert_eq!(
624 shell_kind
625 .try_quote_prefix_aware("'uname'")
626 .unwrap()
627 .into_owned(),
628 "\"'uname'\"".to_string()
629 );
630 assert_eq!(
631 shell_kind.try_quote("^uname").unwrap().into_owned(),
632 "'^uname'".to_string()
633 );
634 assert_eq!(
635 shell_kind
636 .try_quote_prefix_aware("^uname")
637 .unwrap()
638 .into_owned(),
639 "^uname".to_string()
640 );
641 assert_eq!(
642 shell_kind.try_quote("^'uname'").unwrap().into_owned(),
643 "'^'\"'uname\'\"".to_string()
644 );
645 assert_eq!(
646 shell_kind
647 .try_quote_prefix_aware("^'uname'")
648 .unwrap()
649 .into_owned(),
650 "^'uname'".to_string()
651 );
652 assert_eq!(
653 shell_kind.try_quote("'uname a'").unwrap().into_owned(),
654 "\"'uname a'\"".to_string()
655 );
656 assert_eq!(
657 shell_kind
658 .try_quote_prefix_aware("'uname a'")
659 .unwrap()
660 .into_owned(),
661 "\"'uname a'\"".to_string()
662 );
663 assert_eq!(
664 shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
665 "'^'\"'uname a'\"".to_string()
666 );
667 assert_eq!(
668 shell_kind
669 .try_quote_prefix_aware("^'uname a'")
670 .unwrap()
671 .into_owned(),
672 "^'uname a'".to_string()
673 );
674 assert_eq!(
675 shell_kind.try_quote("uname").unwrap().into_owned(),
676 "uname".to_string()
677 );
678 assert_eq!(
679 shell_kind
680 .try_quote_prefix_aware("uname")
681 .unwrap()
682 .into_owned(),
683 "uname".to_string()
684 );
685 }
686}