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