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