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 let locations = [
208 || find_pwsh_in_programfiles(false, false),
209 || find_pwsh_in_programfiles(true, false),
210 || find_pwsh_in_msix(false),
211 || find_pwsh_in_programfiles(false, true),
212 || find_pwsh_in_msix(true),
213 || find_pwsh_in_programfiles(true, true),
214 || find_pwsh_in_scoop(),
215 || which::which_global("pwsh.exe").ok(),
216 || which::which_global("powershell.exe").ok(),
217 ];
218
219 locations
220 .into_iter()
221 .find_map(|f| f())
222 .map(|p| p.to_string_lossy().trim().to_owned())
223 .inspect(|shell| log::info!("Found powershell in: {}", shell))
224 .unwrap_or_else(|| {
225 log::warn!("Powershell not found, falling back to `cmd`");
226 "cmd.exe".to_string()
227 })
228 });
229
230 (*SYSTEM_SHELL).clone()
231}
232
233impl fmt::Display for ShellKind {
234 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235 match self {
236 ShellKind::Posix => write!(f, "sh"),
237 ShellKind::Csh => write!(f, "csh"),
238 ShellKind::Tcsh => write!(f, "tcsh"),
239 ShellKind::Fish => write!(f, "fish"),
240 ShellKind::PowerShell => write!(f, "powershell"),
241 ShellKind::Nushell => write!(f, "nu"),
242 ShellKind::Cmd => write!(f, "cmd"),
243 ShellKind::Rc => write!(f, "rc"),
244 ShellKind::Xonsh => write!(f, "xonsh"),
245 ShellKind::Elvish => write!(f, "elvish"),
246 }
247 }
248}
249
250impl ShellKind {
251 pub fn system() -> Self {
252 Self::new(&get_system_shell(), cfg!(windows))
253 }
254
255 pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
256 let program = program.as_ref();
257 let program = program
258 .file_stem()
259 .unwrap_or_else(|| program.as_os_str())
260 .to_string_lossy();
261
262 match &*program {
263 "powershell" | "pwsh" => ShellKind::PowerShell,
264 "cmd" => ShellKind::Cmd,
265 "nu" => ShellKind::Nushell,
266 "fish" => ShellKind::Fish,
267 "csh" => ShellKind::Csh,
268 "tcsh" => ShellKind::Tcsh,
269 "rc" => ShellKind::Rc,
270 "xonsh" => ShellKind::Xonsh,
271 "elvish" => ShellKind::Elvish,
272 "sh" | "bash" | "zsh" => ShellKind::Posix,
273 _ if is_windows => ShellKind::PowerShell,
274 // Some other shell detected, the user might install and use a
275 // unix-like shell.
276 _ => ShellKind::Posix,
277 }
278 }
279
280 pub fn to_shell_variable(self, input: &str) -> String {
281 match self {
282 Self::PowerShell => Self::to_powershell_variable(input),
283 Self::Cmd => Self::to_cmd_variable(input),
284 Self::Posix => input.to_owned(),
285 Self::Fish => input.to_owned(),
286 Self::Csh => input.to_owned(),
287 Self::Tcsh => input.to_owned(),
288 Self::Rc => input.to_owned(),
289 Self::Nushell => Self::to_nushell_variable(input),
290 Self::Xonsh => input.to_owned(),
291 Self::Elvish => input.to_owned(),
292 }
293 }
294
295 fn to_cmd_variable(input: &str) -> String {
296 if let Some(var_str) = input.strip_prefix("${") {
297 if var_str.find(':').is_none() {
298 // If the input starts with "${", remove the trailing "}"
299 format!("%{}%", &var_str[..var_str.len() - 1])
300 } else {
301 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
302 // which will result in the task failing to run in such cases.
303 input.into()
304 }
305 } else if let Some(var_str) = input.strip_prefix('$') {
306 // If the input starts with "$", directly append to "$env:"
307 format!("%{}%", var_str)
308 } else {
309 // If no prefix is found, return the input as is
310 input.into()
311 }
312 }
313
314 fn to_powershell_variable(input: &str) -> String {
315 if let Some(var_str) = input.strip_prefix("${") {
316 if var_str.find(':').is_none() {
317 // If the input starts with "${", remove the trailing "}"
318 format!("$env:{}", &var_str[..var_str.len() - 1])
319 } else {
320 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
321 // which will result in the task failing to run in such cases.
322 input.into()
323 }
324 } else if let Some(var_str) = input.strip_prefix('$') {
325 // If the input starts with "$", directly append to "$env:"
326 format!("$env:{}", var_str)
327 } else {
328 // If no prefix is found, return the input as is
329 input.into()
330 }
331 }
332
333 fn to_nushell_variable(input: &str) -> String {
334 let mut result = String::new();
335 let mut source = input;
336 let mut is_start = true;
337
338 loop {
339 match source.chars().next() {
340 None => return result,
341 Some('$') => {
342 source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
343 is_start = false;
344 }
345 Some(_) => {
346 is_start = false;
347 let chunk_end = source.find('$').unwrap_or(source.len());
348 let (chunk, rest) = source.split_at(chunk_end);
349 result.push_str(chunk);
350 source = rest;
351 }
352 }
353 }
354 }
355
356 fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
357 if source.starts_with("env.") {
358 text.push('$');
359 return source;
360 }
361
362 match source.chars().next() {
363 Some('{') => {
364 let source = &source[1..];
365 if let Some(end) = source.find('}') {
366 let var_name = &source[..end];
367 if !var_name.is_empty() {
368 if !is_start {
369 text.push_str("(");
370 }
371 text.push_str("$env.");
372 text.push_str(var_name);
373 if !is_start {
374 text.push_str(")");
375 }
376 &source[end + 1..]
377 } else {
378 text.push_str("${}");
379 &source[end + 1..]
380 }
381 } else {
382 text.push_str("${");
383 source
384 }
385 }
386 Some(c) if c.is_alphabetic() || c == '_' => {
387 let end = source
388 .find(|c: char| !c.is_alphanumeric() && c != '_')
389 .unwrap_or(source.len());
390 let var_name = &source[..end];
391 if !is_start {
392 text.push_str("(");
393 }
394 text.push_str("$env.");
395 text.push_str(var_name);
396 if !is_start {
397 text.push_str(")");
398 }
399 &source[end..]
400 }
401 _ => {
402 text.push('$');
403 source
404 }
405 }
406 }
407
408 pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
409 match self {
410 ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
411 ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
412 ShellKind::Posix
413 | ShellKind::Nushell
414 | ShellKind::Fish
415 | ShellKind::Csh
416 | ShellKind::Tcsh
417 | ShellKind::Rc
418 | ShellKind::Xonsh
419 | ShellKind::Elvish => interactive
420 .then(|| "-i".to_owned())
421 .into_iter()
422 .chain(["-c".to_owned(), combined_command])
423 .collect(),
424 }
425 }
426
427 pub const fn command_prefix(&self) -> Option<char> {
428 match self {
429 ShellKind::PowerShell => Some('&'),
430 ShellKind::Nushell => Some('^'),
431 ShellKind::Posix
432 | ShellKind::Csh
433 | ShellKind::Tcsh
434 | ShellKind::Rc
435 | ShellKind::Fish
436 | ShellKind::Cmd
437 | ShellKind::Xonsh
438 | ShellKind::Elvish => None,
439 }
440 }
441
442 pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
443 match self.command_prefix() {
444 Some(prefix) if !command.starts_with(prefix) => {
445 Cow::Owned(format!("{prefix}{command}"))
446 }
447 _ => Cow::Borrowed(command),
448 }
449 }
450
451 pub const fn sequential_commands_separator(&self) -> char {
452 match self {
453 ShellKind::Cmd => '&',
454 ShellKind::Posix
455 | ShellKind::Csh
456 | ShellKind::Tcsh
457 | ShellKind::Rc
458 | ShellKind::Fish
459 | ShellKind::PowerShell
460 | ShellKind::Nushell
461 | ShellKind::Xonsh
462 | ShellKind::Elvish => ';',
463 }
464 }
465
466 pub const fn sequential_and_commands_separator(&self) -> &'static str {
467 match self {
468 ShellKind::Cmd
469 | ShellKind::Posix
470 | ShellKind::Csh
471 | ShellKind::Tcsh
472 | ShellKind::Rc
473 | ShellKind::Fish
474 | ShellKind::PowerShell
475 | ShellKind::Xonsh => "&&",
476 ShellKind::Nushell | ShellKind::Elvish => ";",
477 }
478 }
479
480 pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
481 shlex::try_quote(arg).ok().map(|arg| match self {
482 // If we are running in PowerShell, we want to take extra care when escaping strings.
483 // In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
484 ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")),
485 ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
486 ShellKind::Posix
487 | ShellKind::Csh
488 | ShellKind::Tcsh
489 | ShellKind::Rc
490 | ShellKind::Fish
491 | ShellKind::Nushell
492 | ShellKind::Xonsh
493 | ShellKind::Elvish => arg,
494 })
495 }
496
497 /// Quotes the given argument if necessary, taking into account the command prefix.
498 ///
499 /// In other words, this will consider quoting arg without its command prefix to not break the command.
500 /// You should use this over `try_quote` when you want to quote a shell command.
501 pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
502 if let Some(char) = self.command_prefix() {
503 if let Some(arg) = arg.strip_prefix(char) {
504 // we have a command that is prefixed
505 for quote in ['\'', '"'] {
506 if let Some(arg) = arg
507 .strip_prefix(quote)
508 .and_then(|arg| arg.strip_suffix(quote))
509 {
510 // and the command itself is wrapped as a literal, that
511 // means the prefix exists to interpret a literal as a
512 // command. So strip the quotes, quote the command, and
513 // re-add the quotes if they are missing after requoting
514 let quoted = self.try_quote(arg)?;
515 return Some(if quoted.starts_with(['\'', '"']) {
516 Cow::Owned(self.prepend_command_prefix("ed).into_owned())
517 } else {
518 Cow::Owned(
519 self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
520 .into_owned(),
521 )
522 });
523 }
524 }
525 return self
526 .try_quote(arg)
527 .map(|quoted| Cow::Owned(self.prepend_command_prefix("ed).into_owned()));
528 }
529 }
530 self.try_quote(arg)
531 }
532
533 pub fn split(&self, input: &str) -> Option<Vec<String>> {
534 shlex::split(input)
535 }
536
537 pub const fn activate_keyword(&self) -> &'static str {
538 match self {
539 ShellKind::Cmd => "",
540 ShellKind::Nushell => "overlay use",
541 ShellKind::PowerShell => ".",
542 ShellKind::Fish
543 | ShellKind::Csh
544 | ShellKind::Tcsh
545 | ShellKind::Posix
546 | ShellKind::Rc
547 | ShellKind::Xonsh
548 | ShellKind::Elvish => "source",
549 }
550 }
551
552 pub const fn clear_screen_command(&self) -> &'static str {
553 match self {
554 ShellKind::Cmd => "cls",
555 ShellKind::Posix
556 | ShellKind::Csh
557 | ShellKind::Tcsh
558 | ShellKind::Rc
559 | ShellKind::Fish
560 | ShellKind::PowerShell
561 | ShellKind::Nushell
562 | ShellKind::Xonsh
563 | ShellKind::Elvish => "clear",
564 }
565 }
566
567 #[cfg(windows)]
568 /// We do not want to escape arguments if we are using CMD as our shell.
569 /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
570 pub const fn tty_escape_args(&self) -> bool {
571 match self {
572 ShellKind::Cmd => false,
573 ShellKind::Posix
574 | ShellKind::Csh
575 | ShellKind::Tcsh
576 | ShellKind::Rc
577 | ShellKind::Fish
578 | ShellKind::PowerShell
579 | ShellKind::Nushell
580 | ShellKind::Xonsh
581 | ShellKind::Elvish => true,
582 }
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 // Examples
591 // WSL
592 // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
593 // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
594 // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
595 // PowerShell from Nushell
596 // 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\""
597 // PowerShell from CMD
598 // 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\\\"\"\"
599
600 #[test]
601 fn test_try_quote_powershell() {
602 let shell_kind = ShellKind::PowerShell;
603 assert_eq!(
604 shell_kind
605 .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
606 .unwrap()
607 .into_owned(),
608 "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest `\"test_foo.py::test_foo`\"\"".to_string()
609 );
610 }
611
612 #[test]
613 fn test_try_quote_cmd() {
614 let shell_kind = ShellKind::Cmd;
615 assert_eq!(
616 shell_kind
617 .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
618 .unwrap()
619 .into_owned(),
620 "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"".to_string()
621 );
622 }
623
624 #[test]
625 fn test_try_quote_nu_command() {
626 let shell_kind = ShellKind::Nushell;
627 assert_eq!(
628 shell_kind.try_quote("'uname'").unwrap().into_owned(),
629 "\"'uname'\"".to_string()
630 );
631 assert_eq!(
632 shell_kind
633 .try_quote_prefix_aware("'uname'")
634 .unwrap()
635 .into_owned(),
636 "\"'uname'\"".to_string()
637 );
638 assert_eq!(
639 shell_kind.try_quote("^uname").unwrap().into_owned(),
640 "'^uname'".to_string()
641 );
642 assert_eq!(
643 shell_kind
644 .try_quote_prefix_aware("^uname")
645 .unwrap()
646 .into_owned(),
647 "^uname".to_string()
648 );
649 assert_eq!(
650 shell_kind.try_quote("^'uname'").unwrap().into_owned(),
651 "'^'\"'uname\'\"".to_string()
652 );
653 assert_eq!(
654 shell_kind
655 .try_quote_prefix_aware("^'uname'")
656 .unwrap()
657 .into_owned(),
658 "^'uname'".to_string()
659 );
660 assert_eq!(
661 shell_kind.try_quote("'uname a'").unwrap().into_owned(),
662 "\"'uname a'\"".to_string()
663 );
664 assert_eq!(
665 shell_kind
666 .try_quote_prefix_aware("'uname a'")
667 .unwrap()
668 .into_owned(),
669 "\"'uname a'\"".to_string()
670 );
671 assert_eq!(
672 shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
673 "'^'\"'uname a'\"".to_string()
674 );
675 assert_eq!(
676 shell_kind
677 .try_quote_prefix_aware("^'uname a'")
678 .unwrap()
679 .into_owned(),
680 "^'uname a'".to_string()
681 );
682 assert_eq!(
683 shell_kind.try_quote("uname").unwrap().into_owned(),
684 "uname".to_string()
685 );
686 assert_eq!(
687 shell_kind
688 .try_quote_prefix_aware("uname")
689 .unwrap()
690 .into_owned(),
691 "uname".to_string()
692 );
693 }
694}