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    redirect_stdin: bool,
214    kind: ShellKind,
215}
216
217impl ShellBuilder {
218    /// Create a new ShellBuilder as configured.
219    pub fn new(remote_system_shell: Option<&str>, shell: &Shell) -> Self {
220        let (program, args) = match remote_system_shell {
221            Some(program) => (program.to_string(), Vec::new()),
222            None => match shell {
223                Shell::System => (get_system_shell(), Vec::new()),
224                Shell::Program(shell) => (shell.clone(), Vec::new()),
225                Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
226            },
227        };
228
229        let kind = ShellKind::new(&program);
230        Self {
231            program,
232            args,
233            interactive: true,
234            kind,
235            redirect_stdin: false,
236        }
237    }
238    pub fn non_interactive(mut self) -> Self {
239        self.interactive = false;
240        self
241    }
242
243    /// Returns the label to show in the terminal tab
244    pub fn command_label(&self, command_label: &str) -> String {
245        match self.kind {
246            ShellKind::PowerShell => {
247                format!("{} -C '{}'", self.program, command_label)
248            }
249            ShellKind::Cmd => {
250                format!("{} /C '{}'", self.program, command_label)
251            }
252            ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => {
253                let interactivity = self.interactive.then_some("-i ").unwrap_or_default();
254                format!(
255                    "{} {interactivity}-c '$\"{}\"'",
256                    self.program, command_label
257                )
258            }
259        }
260    }
261
262    pub fn redirect_stdin_to_dev_null(mut self) -> Self {
263        self.redirect_stdin = true;
264        self
265    }
266
267    /// Returns the program and arguments to run this task in a shell.
268    pub fn build(
269        mut self,
270        task_command: Option<String>,
271        task_args: &[String],
272    ) -> (String, Vec<String>) {
273        if let Some(task_command) = task_command {
274            let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
275                command.push(' ');
276                command.push_str(&self.kind.to_shell_variable(arg));
277                command
278            });
279            if self.redirect_stdin {
280                match self.kind {
281                    ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => {
282                        combined_command.push_str(" </dev/null");
283                    }
284                    ShellKind::PowerShell => {
285                        combined_command.insert_str(0, "$null | ");
286                    }
287                    ShellKind::Cmd => {
288                        combined_command.push_str("< NUL");
289                    }
290                }
291            }
292
293            self.args
294                .extend(self.kind.args_for_shell(self.interactive, combined_command));
295        }
296
297        (self.program, self.args)
298    }
299}
300
301#[cfg(test)]
302mod test {
303    use super::*;
304
305    #[test]
306    fn test_nu_shell_variable_substitution() {
307        let shell = Shell::Program("nu".to_owned());
308        let shell_builder = ShellBuilder::new(None, &shell);
309
310        let (program, args) = shell_builder.build(
311            Some("echo".into()),
312            &[
313                "${hello}".to_string(),
314                "$world".to_string(),
315                "nothing".to_string(),
316                "--$something".to_string(),
317                "$".to_string(),
318                "${test".to_string(),
319            ],
320        );
321
322        assert_eq!(program, "nu");
323        assert_eq!(
324            args,
325            vec![
326                "-i",
327                "-c",
328                "echo $env.hello $env.world nothing --($env.something) $ ${test"
329            ]
330        );
331    }
332}