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_to_use_in_label: &str) -> String {
245        if command_to_use_in_label.trim().is_empty() {
246            self.program.clone()
247        } else {
248            match self.kind {
249                ShellKind::PowerShell => {
250                    format!("{} -C '{}'", self.program, command_to_use_in_label)
251                }
252                ShellKind::Cmd => {
253                    format!("{} /C '{}'", self.program, command_to_use_in_label)
254                }
255                ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => {
256                    let interactivity = self.interactive.then_some("-i ").unwrap_or_default();
257                    format!(
258                        "{PROGRAM} {interactivity}-c '{command_to_use_in_label}'",
259                        PROGRAM = self.program
260                    )
261                }
262            }
263        }
264    }
265
266    pub fn redirect_stdin_to_dev_null(mut self) -> Self {
267        self.redirect_stdin = true;
268        self
269    }
270
271    /// Returns the program and arguments to run this task in a shell.
272    pub fn build(
273        mut self,
274        task_command: Option<String>,
275        task_args: &[String],
276    ) -> (String, Vec<String>) {
277        if let Some(task_command) = task_command {
278            let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
279                command.push(' ');
280                command.push_str(&self.kind.to_shell_variable(arg));
281                command
282            });
283            if self.redirect_stdin {
284                match self.kind {
285                    ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish | ShellKind::Csh => {
286                        combined_command.push_str(" </dev/null");
287                    }
288                    ShellKind::PowerShell => {
289                        combined_command.insert_str(0, "$null | ");
290                    }
291                    ShellKind::Cmd => {
292                        combined_command.push_str("< NUL");
293                    }
294                }
295            }
296
297            self.args
298                .extend(self.kind.args_for_shell(self.interactive, combined_command));
299        }
300
301        (self.program, self.args)
302    }
303}
304
305#[cfg(test)]
306mod test {
307    use super::*;
308
309    #[test]
310    fn test_nu_shell_variable_substitution() {
311        let shell = Shell::Program("nu".to_owned());
312        let shell_builder = ShellBuilder::new(None, &shell);
313
314        let (program, args) = shell_builder.build(
315            Some("echo".into()),
316            &[
317                "${hello}".to_string(),
318                "$world".to_string(),
319                "nothing".to_string(),
320                "--$something".to_string(),
321                "$".to_string(),
322                "${test".to_string(),
323            ],
324        );
325
326        assert_eq!(program, "nu");
327        assert_eq!(
328            args,
329            vec![
330                "-i",
331                "-c",
332                "echo $env.hello $env.world nothing --($env.something) $ ${test"
333            ]
334        );
335    }
336}