zed_util/
shell.rs

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
32/// Get the default system shell, preferring git-bash on Windows.
33pub 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        // /path/to/git/cmd/git.exe/../../bin/bash.exe
44        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                // Some other shell detected, the user might install and use a
207                // unix-like shell.
208                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                // If the input starts with "${", remove the trailing "}"
230                format!("%{}%", &var_str[..var_str.len() - 1])
231            } else {
232                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
233                // which will result in the task failing to run in such cases.
234                input.into()
235            }
236        } else if let Some(var_str) = input.strip_prefix('$') {
237            // If the input starts with "$", directly append to "$env:"
238            format!("%{}%", var_str)
239        } else {
240            // If no prefix is found, return the input as is
241            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                // If the input starts with "${", remove the trailing "}"
249                format!("$env:{}", &var_str[..var_str.len() - 1])
250            } else {
251                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
252                // which will result in the task failing to run in such cases.
253                input.into()
254            }
255        } else if let Some(var_str) = input.strip_prefix('$') {
256            // If the input starts with "$", directly append to "$env:"
257            format!("$env:{}", var_str)
258        } else {
259            // If no prefix is found, return the input as is
260            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            // If we are running in PowerShell, we want to take extra care when escaping strings.
374            // In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
375            // TODO double escaping backslashes is not necessary in PowerShell and probably CMD
376            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}