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