shell_builder.rs

  1use std::fmt;
  2
  3use util::get_system_shell;
  4
  5use crate::Shell;
  6
  7#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
  8pub enum ShellKind {
  9    #[default]
 10    Posix,
 11    Csh,
 12    Fish,
 13    PowerShell,
 14    Nushell,
 15    Cmd,
 16}
 17
 18impl fmt::Display for ShellKind {
 19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 20        match self {
 21            ShellKind::Posix => write!(f, "sh"),
 22            ShellKind::Csh => write!(f, "csh"),
 23            ShellKind::Fish => write!(f, "fish"),
 24            ShellKind::PowerShell => write!(f, "powershell"),
 25            ShellKind::Nushell => write!(f, "nu"),
 26            ShellKind::Cmd => write!(f, "cmd"),
 27        }
 28    }
 29}
 30
 31impl ShellKind {
 32    pub fn system() -> Self {
 33        Self::new(&get_system_shell())
 34    }
 35
 36    pub fn new(program: &str) -> Self {
 37        #[cfg(windows)]
 38        let (_, program) = program.rsplit_once('\\').unwrap_or(("", program));
 39        #[cfg(not(windows))]
 40        let (_, program) = program.rsplit_once('/').unwrap_or(("", program));
 41        if program == "powershell"
 42            || program.ends_with("powershell.exe")
 43            || program == "pwsh"
 44            || program.ends_with("pwsh.exe")
 45        {
 46            ShellKind::PowerShell
 47        } else if program == "cmd" || program.ends_with("cmd.exe") {
 48            ShellKind::Cmd
 49        } else if program == "nu" {
 50            ShellKind::Nushell
 51        } else if program == "fish" {
 52            ShellKind::Fish
 53        } else if program == "csh" {
 54            ShellKind::Csh
 55        } else {
 56            // Some other shell detected, the user might install and use a
 57            // unix-like shell.
 58            ShellKind::Posix
 59        }
 60    }
 61
 62    fn to_shell_variable(self, input: &str) -> String {
 63        match self {
 64            Self::PowerShell => Self::to_powershell_variable(input),
 65            Self::Cmd => Self::to_cmd_variable(input),
 66            Self::Posix => input.to_owned(),
 67            Self::Fish => input.to_owned(),
 68            Self::Csh => input.to_owned(),
 69            Self::Nushell => Self::to_nushell_variable(input),
 70        }
 71    }
 72
 73    fn to_cmd_variable(input: &str) -> String {
 74        if let Some(var_str) = input.strip_prefix("${") {
 75            if var_str.find(':').is_none() {
 76                // If the input starts with "${", remove the trailing "}"
 77                format!("%{}%", &var_str[..var_str.len() - 1])
 78            } else {
 79                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
 80                // which will result in the task failing to run in such cases.
 81                input.into()
 82            }
 83        } else if let Some(var_str) = input.strip_prefix('$') {
 84            // If the input starts with "$", directly append to "$env:"
 85            format!("%{}%", var_str)
 86        } else {
 87            // If no prefix is found, return the input as is
 88            input.into()
 89        }
 90    }
 91    fn to_powershell_variable(input: &str) -> String {
 92        if let Some(var_str) = input.strip_prefix("${") {
 93            if var_str.find(':').is_none() {
 94                // If the input starts with "${", remove the trailing "}"
 95                format!("$env:{}", &var_str[..var_str.len() - 1])
 96            } else {
 97                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
 98                // which will result in the task failing to run in such cases.
 99                input.into()
100            }
101        } else if let Some(var_str) = input.strip_prefix('$') {
102            // If the input starts with "$", directly append to "$env:"
103            format!("$env:{}", var_str)
104        } else {
105            // If no prefix is found, return the input as is
106            input.into()
107        }
108    }
109
110    fn to_nushell_variable(input: &str) -> String {
111        let mut result = String::new();
112        let mut source = input;
113        let mut is_start = true;
114
115        loop {
116            match source.chars().next() {
117                None => return result,
118                Some('$') => {
119                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
120                    is_start = false;
121                }
122                Some(_) => {
123                    is_start = false;
124                    let chunk_end = source.find('$').unwrap_or(source.len());
125                    let (chunk, rest) = source.split_at(chunk_end);
126                    result.push_str(chunk);
127                    source = rest;
128                }
129            }
130        }
131    }
132
133    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
134        if source.starts_with("env.") {
135            text.push('$');
136            return source;
137        }
138
139        match source.chars().next() {
140            Some('{') => {
141                let source = &source[1..];
142                if let Some(end) = source.find('}') {
143                    let var_name = &source[..end];
144                    if !var_name.is_empty() {
145                        if !is_start {
146                            text.push_str("(");
147                        }
148                        text.push_str("$env.");
149                        text.push_str(var_name);
150                        if !is_start {
151                            text.push_str(")");
152                        }
153                        &source[end + 1..]
154                    } else {
155                        text.push_str("${}");
156                        &source[end + 1..]
157                    }
158                } else {
159                    text.push_str("${");
160                    source
161                }
162            }
163            Some(c) if c.is_alphabetic() || c == '_' => {
164                let end = source
165                    .find(|c: char| !c.is_alphanumeric() && c != '_')
166                    .unwrap_or(source.len());
167                let var_name = &source[..end];
168                if !is_start {
169                    text.push_str("(");
170                }
171                text.push_str("$env.");
172                text.push_str(var_name);
173                if !is_start {
174                    text.push_str(")");
175                }
176                &source[end..]
177            }
178            _ => {
179                text.push('$');
180                source
181            }
182        }
183    }
184
185    fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
186        match self {
187            ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
188            ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
189            ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => interactive
190                .then(|| "-i".to_owned())
191                .into_iter()
192                .chain(["-c".to_owned(), combined_command])
193                .collect(),
194        }
195    }
196
197    pub fn command_prefix(&self) -> Option<char> {
198        match self {
199            ShellKind::PowerShell => Some('&'),
200            ShellKind::Nushell => Some('^'),
201            _ => None,
202        }
203    }
204}
205
206/// ShellBuilder is used to turn a user-requested task into a
207/// program that can be executed by the shell.
208pub struct ShellBuilder {
209    /// The shell to run
210    program: String,
211    args: Vec<String>,
212    interactive: bool,
213    /// Whether to redirect stdin to /dev/null for the spawned command as a subshell.
214    redirect_stdin: bool,
215    kind: ShellKind,
216}
217
218impl ShellBuilder {
219    /// Create a new ShellBuilder as configured.
220    pub fn new(remote_system_shell: Option<&str>, shell: &Shell) -> Self {
221        let (program, args) = match remote_system_shell {
222            Some(program) => (program.to_string(), Vec::new()),
223            None => match shell {
224                Shell::System => (get_system_shell(), Vec::new()),
225                Shell::Program(shell) => (shell.clone(), Vec::new()),
226                Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
227            },
228        };
229
230        let kind = ShellKind::new(&program);
231        Self {
232            program,
233            args,
234            interactive: true,
235            kind,
236            redirect_stdin: false,
237        }
238    }
239    pub fn non_interactive(mut self) -> Self {
240        self.interactive = false;
241        self
242    }
243
244    /// Returns the label to show in the terminal tab
245    pub fn command_label(&self, command_to_use_in_label: &str) -> String {
246        if command_to_use_in_label.trim().is_empty() {
247            self.program.clone()
248        } else {
249            match self.kind {
250                ShellKind::PowerShell => {
251                    format!("{} -C '{}'", self.program, command_to_use_in_label)
252                }
253                ShellKind::Cmd => {
254                    format!("{} /C '{}'", self.program, command_to_use_in_label)
255                }
256                ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => {
257                    let interactivity = self.interactive.then_some("-i ").unwrap_or_default();
258                    format!(
259                        "{PROGRAM} {interactivity}-c '{command_to_use_in_label}'",
260                        PROGRAM = self.program
261                    )
262                }
263            }
264        }
265    }
266
267    pub fn redirect_stdin_to_dev_null(mut self) -> Self {
268        self.redirect_stdin = true;
269        self
270    }
271
272    /// Returns the program and arguments to run this task in a shell.
273    pub fn build(
274        mut self,
275        task_command: Option<String>,
276        task_args: &[String],
277    ) -> (String, Vec<String>) {
278        if let Some(task_command) = task_command {
279            let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
280                command.push(' ');
281                command.push_str(&self.kind.to_shell_variable(arg));
282                command
283            });
284            if self.redirect_stdin {
285                match self.kind {
286                    ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => {
287                        combined_command.insert(0, '(');
288                        combined_command.push_str(") </dev/null");
289                    }
290                    ShellKind::PowerShell => {
291                        combined_command.insert_str(0, "$null | & {");
292                        combined_command.push_str("}");
293                    }
294                    ShellKind::Cmd => {
295                        combined_command.push_str("< NUL");
296                    }
297                }
298            }
299
300            self.args
301                .extend(self.kind.args_for_shell(self.interactive, combined_command));
302        }
303
304        (self.program, self.args)
305    }
306}
307
308#[cfg(test)]
309mod test {
310    use super::*;
311
312    #[test]
313    fn test_nu_shell_variable_substitution() {
314        let shell = Shell::Program("nu".to_owned());
315        let shell_builder = ShellBuilder::new(None, &shell);
316
317        let (program, args) = shell_builder.build(
318            Some("echo".into()),
319            &[
320                "${hello}".to_string(),
321                "$world".to_string(),
322                "nothing".to_string(),
323                "--$something".to_string(),
324                "$".to_string(),
325                "${test".to_string(),
326            ],
327        );
328
329        assert_eq!(program, "nu");
330        assert_eq!(
331            args,
332            vec![
333                "-i",
334                "-c",
335                "echo $env.hello $env.world nothing --($env.something) $ ${test"
336            ]
337        );
338    }
339
340    #[test]
341    fn redirect_stdin_to_dev_null_precedence() {
342        let shell = Shell::Program("nu".to_owned());
343        let shell_builder = ShellBuilder::new(None, &shell);
344
345        let (program, args) = shell_builder
346            .redirect_stdin_to_dev_null()
347            .build(Some("echo".into()), &["nothing".to_string()]);
348
349        assert_eq!(program, "nu");
350        assert_eq!(args, vec!["-i", "-c", "(echo nothing) </dev/null"]);
351    }
352}