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, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
7pub enum Shell {
8 /// Use the system's default terminal configuration in /etc/passwd
9 #[default]
10 System,
11 /// Use a specific program with no arguments.
12 Program(String),
13 /// Use a specific program with arguments.
14 WithArguments {
15 /// The program to run.
16 program: String,
17 /// The arguments to pass to the program.
18 args: Vec<String>,
19 /// An optional string to override the title of the terminal tab
20 title_override: Option<String>,
21 },
22}
23
24impl Shell {
25 pub fn program(&self) -> String {
26 match self {
27 Shell::Program(program) => program.clone(),
28 Shell::WithArguments { program, .. } => program.clone(),
29 Shell::System => get_system_shell(),
30 }
31 }
32
33 pub fn program_and_args(&self) -> (String, &[String]) {
34 match self {
35 Shell::Program(program) => (program.clone(), &[]),
36 Shell::WithArguments { program, args, .. } => (program.clone(), args),
37 Shell::System => (get_system_shell(), &[]),
38 }
39 }
40
41 pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
42 match self {
43 Shell::Program(program) => ShellKind::new(program, is_windows),
44 Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
45 Shell::System => ShellKind::system(),
46 }
47 }
48}
49
50#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum ShellKind {
52 #[default]
53 Posix,
54 Csh,
55 Tcsh,
56 Rc,
57 Fish,
58 PowerShell,
59 Nushell,
60 Cmd,
61 Xonsh,
62}
63
64pub fn get_system_shell() -> String {
65 if cfg!(windows) {
66 get_windows_system_shell()
67 } else {
68 std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
69 }
70}
71
72pub fn get_default_system_shell() -> String {
73 if cfg!(windows) {
74 get_windows_system_shell()
75 } else {
76 "/bin/sh".to_string()
77 }
78}
79
80/// Get the default system shell, preferring git-bash on Windows.
81pub fn get_default_system_shell_preferring_bash() -> String {
82 if cfg!(windows) {
83 get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell())
84 } else {
85 "/bin/sh".to_string()
86 }
87}
88
89pub fn get_windows_git_bash() -> Option<String> {
90 static GIT_BASH: LazyLock<Option<String>> = LazyLock::new(|| {
91 // /path/to/git/cmd/git.exe/../../bin/bash.exe
92 let git = which::which("git").ok()?;
93 let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
94 if git_bash.is_file() {
95 log::info!("Found git-bash at {}", git_bash.display());
96 Some(git_bash.to_string_lossy().to_string())
97 } else {
98 None
99 }
100 });
101
102 (*GIT_BASH).clone()
103}
104
105pub fn get_windows_system_shell() -> String {
106 use std::path::PathBuf;
107
108 fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
109 #[cfg(target_pointer_width = "64")]
110 let env_var = if find_alternate {
111 "ProgramFiles(x86)"
112 } else {
113 "ProgramFiles"
114 };
115
116 #[cfg(target_pointer_width = "32")]
117 let env_var = if find_alternate {
118 "ProgramW6432"
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
167 .read_dir()
168 .ok()?
169 .filter_map(|entry| {
170 let entry = entry.ok()?;
171 if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
172 return None;
173 }
174
175 if !entry.file_name().to_string_lossy().starts_with(prefix) {
176 return None;
177 }
178
179 let exe_path = entry.path().join("pwsh.exe");
180 exe_path.exists().then_some(exe_path)
181 })
182 .next()
183 }
184
185 fn find_pwsh_in_scoop() -> Option<PathBuf> {
186 let pwsh_exe =
187 PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
188 pwsh_exe.exists().then_some(pwsh_exe)
189 }
190
191 static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
192 find_pwsh_in_programfiles(false, false)
193 .or_else(|| find_pwsh_in_programfiles(true, false))
194 .or_else(|| find_pwsh_in_msix(false))
195 .or_else(|| find_pwsh_in_programfiles(false, true))
196 .or_else(|| find_pwsh_in_msix(true))
197 .or_else(|| find_pwsh_in_programfiles(true, true))
198 .or_else(find_pwsh_in_scoop)
199 .map(|p| p.to_string_lossy().into_owned())
200 .unwrap_or("powershell.exe".to_string())
201 });
202
203 (*SYSTEM_SHELL).clone()
204}
205
206impl fmt::Display for ShellKind {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 match self {
209 ShellKind::Posix => write!(f, "sh"),
210 ShellKind::Csh => write!(f, "csh"),
211 ShellKind::Tcsh => write!(f, "tcsh"),
212 ShellKind::Fish => write!(f, "fish"),
213 ShellKind::PowerShell => write!(f, "powershell"),
214 ShellKind::Nushell => write!(f, "nu"),
215 ShellKind::Cmd => write!(f, "cmd"),
216 ShellKind::Rc => write!(f, "rc"),
217 ShellKind::Xonsh => write!(f, "xonsh"),
218 }
219 }
220}
221
222impl ShellKind {
223 pub fn system() -> Self {
224 Self::new(&get_system_shell(), cfg!(windows))
225 }
226
227 pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
228 let program = program.as_ref();
229 let program = program
230 .file_stem()
231 .unwrap_or_else(|| program.as_os_str())
232 .to_string_lossy();
233
234 match &*program {
235 "powershell" | "pwsh" => ShellKind::PowerShell,
236 "cmd" => ShellKind::Cmd,
237 "nu" => ShellKind::Nushell,
238 "fish" => ShellKind::Fish,
239 "csh" => ShellKind::Csh,
240 "tcsh" => ShellKind::Tcsh,
241 "rc" => ShellKind::Rc,
242 "xonsh" => ShellKind::Xonsh,
243 "sh" | "bash" => ShellKind::Posix,
244 _ if is_windows => ShellKind::PowerShell,
245 // Some other shell detected, the user might install and use a
246 // unix-like shell.
247 _ => ShellKind::Posix,
248 }
249 }
250
251 pub fn to_shell_variable(self, input: &str) -> String {
252 match self {
253 Self::PowerShell => Self::to_powershell_variable(input),
254 Self::Cmd => Self::to_cmd_variable(input),
255 Self::Posix => input.to_owned(),
256 Self::Fish => input.to_owned(),
257 Self::Csh => input.to_owned(),
258 Self::Tcsh => input.to_owned(),
259 Self::Rc => input.to_owned(),
260 Self::Nushell => Self::to_nushell_variable(input),
261 Self::Xonsh => input.to_owned(),
262 }
263 }
264
265 fn to_cmd_variable(input: &str) -> String {
266 if let Some(var_str) = input.strip_prefix("${") {
267 if var_str.find(':').is_none() {
268 // If the input starts with "${", remove the trailing "}"
269 format!("%{}%", &var_str[..var_str.len() - 1])
270 } else {
271 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
272 // which will result in the task failing to run in such cases.
273 input.into()
274 }
275 } else if let Some(var_str) = input.strip_prefix('$') {
276 // If the input starts with "$", directly append to "$env:"
277 format!("%{}%", var_str)
278 } else {
279 // If no prefix is found, return the input as is
280 input.into()
281 }
282 }
283
284 fn to_powershell_variable(input: &str) -> String {
285 if let Some(var_str) = input.strip_prefix("${") {
286 if var_str.find(':').is_none() {
287 // If the input starts with "${", remove the trailing "}"
288 format!("$env:{}", &var_str[..var_str.len() - 1])
289 } else {
290 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
291 // which will result in the task failing to run in such cases.
292 input.into()
293 }
294 } else if let Some(var_str) = input.strip_prefix('$') {
295 // If the input starts with "$", directly append to "$env:"
296 format!("$env:{}", var_str)
297 } else {
298 // If no prefix is found, return the input as is
299 input.into()
300 }
301 }
302
303 fn to_nushell_variable(input: &str) -> String {
304 let mut result = String::new();
305 let mut source = input;
306 let mut is_start = true;
307
308 loop {
309 match source.chars().next() {
310 None => return result,
311 Some('$') => {
312 source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
313 is_start = false;
314 }
315 Some(_) => {
316 is_start = false;
317 let chunk_end = source.find('$').unwrap_or(source.len());
318 let (chunk, rest) = source.split_at(chunk_end);
319 result.push_str(chunk);
320 source = rest;
321 }
322 }
323 }
324 }
325
326 fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
327 if source.starts_with("env.") {
328 text.push('$');
329 return source;
330 }
331
332 match source.chars().next() {
333 Some('{') => {
334 let source = &source[1..];
335 if let Some(end) = source.find('}') {
336 let var_name = &source[..end];
337 if !var_name.is_empty() {
338 if !is_start {
339 text.push_str("(");
340 }
341 text.push_str("$env.");
342 text.push_str(var_name);
343 if !is_start {
344 text.push_str(")");
345 }
346 &source[end + 1..]
347 } else {
348 text.push_str("${}");
349 &source[end + 1..]
350 }
351 } else {
352 text.push_str("${");
353 source
354 }
355 }
356 Some(c) if c.is_alphabetic() || c == '_' => {
357 let end = source
358 .find(|c: char| !c.is_alphanumeric() && c != '_')
359 .unwrap_or(source.len());
360 let var_name = &source[..end];
361 if !is_start {
362 text.push_str("(");
363 }
364 text.push_str("$env.");
365 text.push_str(var_name);
366 if !is_start {
367 text.push_str(")");
368 }
369 &source[end..]
370 }
371 _ => {
372 text.push('$');
373 source
374 }
375 }
376 }
377
378 pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
379 match self {
380 ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
381 ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
382 ShellKind::Posix
383 | ShellKind::Nushell
384 | ShellKind::Fish
385 | ShellKind::Csh
386 | ShellKind::Tcsh
387 | ShellKind::Rc
388 | ShellKind::Xonsh => interactive
389 .then(|| "-i".to_owned())
390 .into_iter()
391 .chain(["-c".to_owned(), combined_command])
392 .collect(),
393 }
394 }
395
396 pub const fn command_prefix(&self) -> Option<char> {
397 match self {
398 ShellKind::PowerShell => Some('&'),
399 ShellKind::Nushell => Some('^'),
400 ShellKind::Posix
401 | ShellKind::Csh
402 | ShellKind::Tcsh
403 | ShellKind::Rc
404 | ShellKind::Fish
405 | ShellKind::Cmd
406 | ShellKind::Xonsh => None,
407 }
408 }
409
410 pub const fn sequential_commands_separator(&self) -> char {
411 match self {
412 ShellKind::Cmd => '&',
413 ShellKind::Posix
414 | ShellKind::Csh
415 | ShellKind::Tcsh
416 | ShellKind::Rc
417 | ShellKind::Fish
418 | ShellKind::PowerShell
419 | ShellKind::Nushell
420 | ShellKind::Xonsh => ';',
421 }
422 }
423
424 pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
425 shlex::try_quote(arg).ok().map(|arg| match self {
426 ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")),
427 ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
428 ShellKind::Posix
429 | ShellKind::Csh
430 | ShellKind::Tcsh
431 | ShellKind::Rc
432 | ShellKind::Fish
433 | ShellKind::Nushell
434 | ShellKind::Xonsh => arg,
435 })
436 }
437
438 pub const fn activate_keyword(&self) -> &'static str {
439 match self {
440 ShellKind::Cmd => "",
441 ShellKind::Nushell => "overlay use",
442 ShellKind::PowerShell => ".",
443 ShellKind::Fish
444 | ShellKind::Csh
445 | ShellKind::Tcsh
446 | ShellKind::Posix
447 | ShellKind::Rc
448 | ShellKind::Xonsh => "source",
449 }
450 }
451
452 pub const fn clear_screen_command(&self) -> &'static str {
453 match self {
454 ShellKind::Cmd => "cls",
455 ShellKind::Posix
456 | ShellKind::Csh
457 | ShellKind::Tcsh
458 | ShellKind::Rc
459 | ShellKind::Fish
460 | ShellKind::PowerShell
461 | ShellKind::Nushell
462 | ShellKind::Xonsh => "clear",
463 }
464 }
465
466 #[cfg(windows)]
467 /// We do not want to escape arguments if we are using CMD as our shell.
468 /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
469 pub const fn tty_escape_args(&self) -> bool {
470 match self {
471 ShellKind::Cmd => false,
472 ShellKind::Posix
473 | ShellKind::Csh
474 | ShellKind::Tcsh
475 | ShellKind::Rc
476 | ShellKind::Fish
477 | ShellKind::PowerShell
478 | ShellKind::Nushell
479 | ShellKind::Xonsh => true,
480 }
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 // Examples
489 // WSL
490 // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
491 // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
492 // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
493 // PowerShell from Nushell
494 // 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\""
495 // PowerShell from CMD
496 // 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\\\"\"\"
497
498 #[test]
499 fn test_try_quote_powershell() {
500 let shell_kind = ShellKind::PowerShell;
501 assert_eq!(
502 shell_kind
503 .try_quote("pwsh.exe -c \"echo \"hello there\"\"")
504 .unwrap()
505 .into_owned(),
506 "\"echo `\"hello there`\"\"".to_string()
507 );
508 }
509}