shell_builder.rs

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