1use std::{borrow::Cow, fmt, path::Path, sync::LazyLock};
2
3#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
4pub enum ShellKind {
5 #[default]
6 Posix,
7 Csh,
8 Tcsh,
9 Rc,
10 Fish,
11 PowerShell,
12 Nushell,
13 Cmd,
14}
15
16pub fn get_system_shell() -> String {
17 if cfg!(windows) {
18 get_windows_system_shell()
19 } else {
20 std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
21 }
22}
23
24pub fn get_default_system_shell() -> String {
25 if cfg!(windows) {
26 get_windows_system_shell()
27 } else {
28 "/bin/sh".to_string()
29 }
30}
31
32pub fn get_default_system_shell_preferring_bash() -> String {
34 if cfg!(windows) {
35 get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell())
36 } else {
37 "/bin/sh".to_string()
38 }
39}
40
41pub fn get_windows_git_bash() -> Option<String> {
42 static GIT_BASH: LazyLock<Option<String>> = LazyLock::new(|| {
43 let git = which::which("git").ok()?;
45 let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
46 if git_bash.is_file() {
47 Some(git_bash.to_string_lossy().to_string())
48 } else {
49 None
50 }
51 });
52
53 (*GIT_BASH).clone()
54}
55
56pub fn get_windows_system_shell() -> String {
57 use std::path::PathBuf;
58
59 fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
60 #[cfg(target_pointer_width = "64")]
61 let env_var = if find_alternate {
62 "ProgramFiles(x86)"
63 } else {
64 "ProgramFiles"
65 };
66
67 #[cfg(target_pointer_width = "32")]
68 let env_var = if find_alternate {
69 "ProgramW6432"
70 } else {
71 "ProgramFiles"
72 };
73
74 let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
75 install_base_dir
76 .read_dir()
77 .ok()?
78 .filter_map(Result::ok)
79 .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
80 .filter_map(|entry| {
81 let dir_name = entry.file_name();
82 let dir_name = dir_name.to_string_lossy();
83
84 let version = if find_preview {
85 let dash_index = dir_name.find('-')?;
86 if &dir_name[dash_index + 1..] != "preview" {
87 return None;
88 };
89 dir_name[..dash_index].parse::<u32>().ok()?
90 } else {
91 dir_name.parse::<u32>().ok()?
92 };
93
94 let exe_path = entry.path().join("pwsh.exe");
95 if exe_path.exists() {
96 Some((version, exe_path))
97 } else {
98 None
99 }
100 })
101 .max_by_key(|(version, _)| *version)
102 .map(|(_, path)| path)
103 }
104
105 fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
106 let msix_app_dir =
107 PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
108 if !msix_app_dir.exists() {
109 return None;
110 }
111
112 let prefix = if find_preview {
113 "Microsoft.PowerShellPreview_"
114 } else {
115 "Microsoft.PowerShell_"
116 };
117 msix_app_dir
118 .read_dir()
119 .ok()?
120 .filter_map(|entry| {
121 let entry = entry.ok()?;
122 if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
123 return None;
124 }
125
126 if !entry.file_name().to_string_lossy().starts_with(prefix) {
127 return None;
128 }
129
130 let exe_path = entry.path().join("pwsh.exe");
131 exe_path.exists().then_some(exe_path)
132 })
133 .next()
134 }
135
136 fn find_pwsh_in_scoop() -> Option<PathBuf> {
137 let pwsh_exe =
138 PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
139 pwsh_exe.exists().then_some(pwsh_exe)
140 }
141
142 static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
143 find_pwsh_in_programfiles(false, false)
144 .or_else(|| find_pwsh_in_programfiles(true, false))
145 .or_else(|| find_pwsh_in_msix(false))
146 .or_else(|| find_pwsh_in_programfiles(false, true))
147 .or_else(|| find_pwsh_in_msix(true))
148 .or_else(|| find_pwsh_in_programfiles(true, true))
149 .or_else(find_pwsh_in_scoop)
150 .map(|p| p.to_string_lossy().into_owned())
151 .unwrap_or("powershell.exe".to_string())
152 });
153
154 (*SYSTEM_SHELL).clone()
155}
156
157impl fmt::Display for ShellKind {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 match self {
160 ShellKind::Posix => write!(f, "sh"),
161 ShellKind::Csh => write!(f, "csh"),
162 ShellKind::Tcsh => write!(f, "tcsh"),
163 ShellKind::Fish => write!(f, "fish"),
164 ShellKind::PowerShell => write!(f, "powershell"),
165 ShellKind::Nushell => write!(f, "nu"),
166 ShellKind::Cmd => write!(f, "cmd"),
167 ShellKind::Rc => write!(f, "rc"),
168 }
169 }
170}
171
172impl ShellKind {
173 pub fn system() -> Self {
174 Self::new(&get_system_shell())
175 }
176
177 pub fn new(program: impl AsRef<Path>) -> Self {
178 let program = program.as_ref();
179 let Some(program) = program.file_stem().and_then(|s| s.to_str()) else {
180 return if cfg!(windows) {
181 ShellKind::PowerShell
182 } else {
183 ShellKind::Posix
184 };
185 };
186 if program == "powershell" || program == "pwsh" {
187 ShellKind::PowerShell
188 } else if program == "cmd" {
189 ShellKind::Cmd
190 } else if program == "nu" {
191 ShellKind::Nushell
192 } else if program == "fish" {
193 ShellKind::Fish
194 } else if program == "csh" {
195 ShellKind::Csh
196 } else if program == "tcsh" {
197 ShellKind::Tcsh
198 } else if program == "rc" {
199 ShellKind::Rc
200 } else if program == "sh" || program == "bash" {
201 ShellKind::Posix
202 } else {
203 if cfg!(windows) {
204 ShellKind::PowerShell
205 } else {
206 ShellKind::Posix
209 }
210 }
211 }
212
213 pub fn to_shell_variable(self, input: &str) -> String {
214 match self {
215 Self::PowerShell => Self::to_powershell_variable(input),
216 Self::Cmd => Self::to_cmd_variable(input),
217 Self::Posix => input.to_owned(),
218 Self::Fish => input.to_owned(),
219 Self::Csh => input.to_owned(),
220 Self::Tcsh => input.to_owned(),
221 Self::Rc => input.to_owned(),
222 Self::Nushell => Self::to_nushell_variable(input),
223 }
224 }
225
226 fn to_cmd_variable(input: &str) -> String {
227 if let Some(var_str) = input.strip_prefix("${") {
228 if var_str.find(':').is_none() {
229 format!("%{}%", &var_str[..var_str.len() - 1])
231 } else {
232 input.into()
235 }
236 } else if let Some(var_str) = input.strip_prefix('$') {
237 format!("%{}%", var_str)
239 } else {
240 input.into()
242 }
243 }
244
245 fn to_powershell_variable(input: &str) -> String {
246 if let Some(var_str) = input.strip_prefix("${") {
247 if var_str.find(':').is_none() {
248 format!("$env:{}", &var_str[..var_str.len() - 1])
250 } else {
251 input.into()
254 }
255 } else if let Some(var_str) = input.strip_prefix('$') {
256 format!("$env:{}", var_str)
258 } else {
259 input.into()
261 }
262 }
263
264 fn to_nushell_variable(input: &str) -> String {
265 let mut result = String::new();
266 let mut source = input;
267 let mut is_start = true;
268
269 loop {
270 match source.chars().next() {
271 None => return result,
272 Some('$') => {
273 source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
274 is_start = false;
275 }
276 Some(_) => {
277 is_start = false;
278 let chunk_end = source.find('$').unwrap_or(source.len());
279 let (chunk, rest) = source.split_at(chunk_end);
280 result.push_str(chunk);
281 source = rest;
282 }
283 }
284 }
285 }
286
287 fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
288 if source.starts_with("env.") {
289 text.push('$');
290 return source;
291 }
292
293 match source.chars().next() {
294 Some('{') => {
295 let source = &source[1..];
296 if let Some(end) = source.find('}') {
297 let var_name = &source[..end];
298 if !var_name.is_empty() {
299 if !is_start {
300 text.push_str("(");
301 }
302 text.push_str("$env.");
303 text.push_str(var_name);
304 if !is_start {
305 text.push_str(")");
306 }
307 &source[end + 1..]
308 } else {
309 text.push_str("${}");
310 &source[end + 1..]
311 }
312 } else {
313 text.push_str("${");
314 source
315 }
316 }
317 Some(c) if c.is_alphabetic() || c == '_' => {
318 let end = source
319 .find(|c: char| !c.is_alphanumeric() && c != '_')
320 .unwrap_or(source.len());
321 let var_name = &source[..end];
322 if !is_start {
323 text.push_str("(");
324 }
325 text.push_str("$env.");
326 text.push_str(var_name);
327 if !is_start {
328 text.push_str(")");
329 }
330 &source[end..]
331 }
332 _ => {
333 text.push('$');
334 source
335 }
336 }
337 }
338
339 pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
340 match self {
341 ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
342 ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
343 ShellKind::Posix
344 | ShellKind::Nushell
345 | ShellKind::Fish
346 | ShellKind::Csh
347 | ShellKind::Tcsh
348 | ShellKind::Rc => interactive
349 .then(|| "-i".to_owned())
350 .into_iter()
351 .chain(["-c".to_owned(), combined_command])
352 .collect(),
353 }
354 }
355
356 pub const fn command_prefix(&self) -> Option<char> {
357 match self {
358 ShellKind::PowerShell => Some('&'),
359 ShellKind::Nushell => Some('^'),
360 _ => None,
361 }
362 }
363
364 pub const fn sequential_commands_separator(&self) -> char {
365 match self {
366 ShellKind::Cmd => '&',
367 _ => ';',
368 }
369 }
370
371 pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
372 shlex::try_quote(arg).ok().map(|arg| match self {
373 ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"")),
377 _ => arg,
378 })
379 }
380
381 pub const fn activate_keyword(&self) -> &'static str {
382 match self {
383 ShellKind::Cmd => "",
384 ShellKind::Nushell => "overlay use",
385 ShellKind::PowerShell => ".",
386 ShellKind::Fish => "source",
387 ShellKind::Csh => "source",
388 ShellKind::Tcsh => "source",
389 ShellKind::Posix | ShellKind::Rc => "source",
390 }
391 }
392
393 pub const fn clear_screen_command(&self) -> &'static str {
394 match self {
395 ShellKind::Cmd => "cls",
396 _ => "clear",
397 }
398 }
399}