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