shell_builder.rs

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