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