shell_builder.rs

  1use std::borrow::Cow;
  2
  3use crate::shell::get_system_shell;
  4use crate::shell::{
  5    Shell, ShellKind, args_for_shell_option, to_shell_variable_option, try_quote_option,
  6    try_quote_prefix_aware_option,
  7};
  8
  9const POSIX_STDIN_REDIRECT_PREFIX: char = '(';
 10const POSIX_STDIN_REDIRECT_SUFFIX: &str = ") </dev/null";
 11const POWERSHELL_STDIN_REDIRECT_PREFIX: &str = "$null | & {";
 12const POWERSHELL_STDIN_REDIRECT_SUFFIX: &str = "}";
 13
 14/// ShellBuilder is used to turn a user-requested task into a
 15/// program that can be executed by the shell.
 16pub struct ShellBuilder {
 17    /// The shell to run
 18    program: String,
 19    args: Vec<String>,
 20    interactive: bool,
 21    /// Whether to redirect stdin to /dev/null for the spawned command as a subshell.
 22    redirect_stdin: bool,
 23    kind: Option<ShellKind>,
 24}
 25
 26impl ShellBuilder {
 27    /// Create a new ShellBuilder as configured.
 28    pub fn new(shell: &Shell) -> Self {
 29        let (program, args) = match shell {
 30            Shell::System => (get_system_shell(), Vec::new()),
 31            Shell::Program(shell) => (shell.clone(), Vec::new()),
 32            Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
 33        };
 34
 35        let kind = ShellKind::new(&program);
 36        Self {
 37            program,
 38            args,
 39            interactive: true,
 40            kind,
 41            redirect_stdin: false,
 42        }
 43    }
 44    pub fn non_interactive(mut self) -> Self {
 45        self.interactive = false;
 46        self
 47    }
 48
 49    /// Returns the label to show in the terminal tab
 50    pub fn command_label(&self, command_to_use_in_label: &str) -> String {
 51        if command_to_use_in_label.trim().is_empty() {
 52            self.program.clone()
 53        } else {
 54            match self.kind {
 55                Some(ShellKind::PowerShell) | Some(ShellKind::Pwsh) => {
 56                    format!("{} -C '{}'", self.program, command_to_use_in_label)
 57                }
 58                #[cfg(windows)]
 59                None => {
 60                    format!("{} -C '{}'", self.program, command_to_use_in_label)
 61                }
 62                Some(ShellKind::Cmd) => {
 63                    format!("{} /C \"{}\"", self.program, command_to_use_in_label)
 64                }
 65                Some(
 66                    ShellKind::Posix(_)
 67                    | ShellKind::Nushell
 68                    | ShellKind::Fish
 69                    | ShellKind::Csh
 70                    | ShellKind::Tcsh
 71                    | ShellKind::Rc
 72                    | ShellKind::Xonsh
 73                    | ShellKind::Elvish,
 74                ) => {
 75                    let interactivity = self.interactive.then_some("-i ").unwrap_or_default();
 76                    format!(
 77                        "{PROGRAM} {interactivity}-c '{command_to_use_in_label}'",
 78                        PROGRAM = self.program
 79                    )
 80                }
 81                #[cfg(unix)]
 82                None => {
 83                    let interactivity = self.interactive.then_some("-i ").unwrap_or_default();
 84                    format!(
 85                        "{PROGRAM} {interactivity}-c '{command_to_use_in_label}'",
 86                        PROGRAM = self.program
 87                    )
 88                }
 89            }
 90        }
 91    }
 92
 93    pub fn redirect_stdin_to_dev_null(mut self) -> Self {
 94        self.redirect_stdin = true;
 95        self
 96    }
 97
 98    fn apply_stdin_redirect(&self, combined_command: &mut String) {
 99        match self.kind {
100            Some(ShellKind::Fish) => {
101                combined_command.insert_str(0, "begin; ");
102                combined_command.push_str("; end </dev/null");
103            }
104            Some(
105                ShellKind::Posix(_)
106                | ShellKind::Nushell
107                | ShellKind::Csh
108                | ShellKind::Tcsh
109                | ShellKind::Rc
110                | ShellKind::Xonsh
111                | ShellKind::Elvish,
112            ) => {
113                combined_command.insert(0, POSIX_STDIN_REDIRECT_PREFIX);
114                combined_command.push_str(POSIX_STDIN_REDIRECT_SUFFIX);
115            }
116            #[cfg(unix)]
117            None => {
118                combined_command.insert(0, POSIX_STDIN_REDIRECT_PREFIX);
119                combined_command.push_str(POSIX_STDIN_REDIRECT_SUFFIX);
120            }
121            Some(ShellKind::PowerShell) | Some(ShellKind::Pwsh) => {
122                combined_command.insert_str(0, POWERSHELL_STDIN_REDIRECT_PREFIX);
123                combined_command.push_str(POWERSHELL_STDIN_REDIRECT_SUFFIX);
124            }
125            #[cfg(windows)]
126            None => {
127                combined_command.insert_str(0, POWERSHELL_STDIN_REDIRECT_PREFIX);
128                combined_command.push_str(POWERSHELL_STDIN_REDIRECT_SUFFIX);
129            }
130            Some(ShellKind::Cmd) => {
131                combined_command.push_str("< NUL");
132            }
133        }
134    }
135
136    /// Returns the program and arguments to run this task in a shell.
137    pub fn build(
138        mut self,
139        task_command: Option<String>,
140        task_args: &[String],
141    ) -> (String, Vec<String>) {
142        if let Some(task_command) = task_command {
143            let task_command = if !task_args.is_empty() {
144                match try_quote_prefix_aware_option(self.kind, &task_command) {
145                    Some(task_command) => task_command.into_owned(),
146                    None => task_command,
147                }
148            } else {
149                task_command
150            };
151            let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
152                command.push(' ');
153                let shell_variable = to_shell_variable_option(self.kind, arg);
154                command.push_str(&match try_quote_option(self.kind, &shell_variable) {
155                    Some(shell_variable) => shell_variable,
156                    None => Cow::Owned(shell_variable),
157                });
158                command
159            });
160            if self.redirect_stdin {
161                self.apply_stdin_redirect(&mut combined_command);
162            }
163
164            self.args.extend(args_for_shell_option(
165                self.kind,
166                self.interactive,
167                combined_command,
168            ));
169        }
170
171        (self.program, self.args)
172    }
173
174    // This should not exist, but our task infra is broken beyond repair right now
175    #[doc(hidden)]
176    pub fn build_no_quote(
177        mut self,
178        task_command: Option<String>,
179        task_args: &[String],
180    ) -> (String, Vec<String>) {
181        if let Some(task_command) = task_command {
182            let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
183                command.push(' ');
184                command.push_str(&to_shell_variable_option(self.kind, arg));
185                command
186            });
187            if self.redirect_stdin {
188                self.apply_stdin_redirect(&mut combined_command);
189            }
190
191            self.args.extend(args_for_shell_option(
192                self.kind,
193                self.interactive,
194                combined_command,
195            ));
196        }
197
198        (self.program, self.args)
199    }
200
201    /// Builds a `smol::process::Command` with the given task command and arguments.
202    ///
203    /// Prefer this over manually constructing a command with the output of `Self::build`,
204    /// as this method handles `cmd` weirdness on windows correctly.
205    pub fn build_smol_command(
206        self,
207        task_command: Option<String>,
208        task_args: &[String],
209    ) -> smol::process::Command {
210        smol::process::Command::from(self.build_std_command(task_command, task_args))
211    }
212
213    /// Builds a `std::process::Command` with the given task command and arguments.
214    ///
215    /// Prefer this over manually constructing a command with the output of `Self::build`,
216    /// as this method handles `cmd` weirdness on windows correctly.
217    pub fn build_std_command(
218        self,
219        mut task_command: Option<String>,
220        task_args: &[String],
221    ) -> std::process::Command {
222        #[cfg(windows)]
223        let kind = self.kind;
224        if task_args.is_empty() {
225            task_command = task_command
226                .as_ref()
227                .map(|cmd| try_quote_prefix_aware_option(self.kind, cmd).map(Cow::into_owned))
228                .unwrap_or(task_command);
229        }
230        let (program, args) = self.build(task_command, task_args);
231
232        let mut child = crate::command::new_std_command(program);
233
234        #[cfg(windows)]
235        if kind == Some(ShellKind::Cmd) {
236            use std::os::windows::process::CommandExt;
237
238            for arg in args {
239                child.raw_arg(arg);
240            }
241        } else {
242            child.args(args);
243        }
244
245        #[cfg(not(windows))]
246        child.args(args);
247
248        child
249    }
250
251    pub fn kind(&self) -> Option<ShellKind> {
252        self.kind
253    }
254}
255
256#[cfg(test)]
257mod test {
258    use super::*;
259
260    #[test]
261    fn test_nu_shell_variable_substitution() {
262        let shell = Shell::Program("nu".to_owned());
263        let shell_builder = ShellBuilder::new(&shell);
264
265        let (program, args) = shell_builder.build(
266            Some("echo".into()),
267            &[
268                "${hello}".to_string(),
269                "$world".to_string(),
270                "nothing".to_string(),
271                "--$something".to_string(),
272                "$".to_string(),
273                "${test".to_string(),
274            ],
275        );
276
277        assert_eq!(program, "nu");
278        assert_eq!(
279            args,
280            vec![
281                "-i",
282                "-c",
283                "echo '$env.hello' '$env.world' nothing '--($env.something)' '$' '${test'"
284            ]
285        );
286    }
287
288    #[test]
289    fn redirect_stdin_to_dev_null_precedence() {
290        let shell = Shell::Program("nu".to_owned());
291        let shell_builder = ShellBuilder::new(&shell);
292
293        let (program, args) = shell_builder
294            .redirect_stdin_to_dev_null()
295            .build(Some("echo".into()), &["nothing".to_string()]);
296
297        assert_eq!(program, "nu");
298        assert_eq!(args, vec!["-i", "-c", "(echo nothing) </dev/null"]);
299    }
300
301    #[test]
302    fn redirect_stdin_to_dev_null_fish() {
303        let shell = Shell::Program("fish".to_owned());
304        let shell_builder = ShellBuilder::new(&shell);
305
306        let (program, args) = shell_builder
307            .redirect_stdin_to_dev_null()
308            .build(Some("echo".into()), &["test".to_string()]);
309
310        assert_eq!(program, "fish");
311        assert_eq!(args, vec!["-i", "-c", "begin; echo test; end </dev/null"]);
312    }
313
314    #[test]
315    fn does_not_quote_sole_command_only() {
316        let shell = Shell::Program("fish".to_owned());
317        let shell_builder = ShellBuilder::new(&shell);
318
319        let (program, args) = shell_builder.build(Some("echo".into()), &[]);
320
321        assert_eq!(program, "fish");
322        assert_eq!(args, vec!["-i", "-c", "echo"]);
323
324        let shell = Shell::Program("fish".to_owned());
325        let shell_builder = ShellBuilder::new(&shell);
326
327        let (program, args) = shell_builder.build(Some("echo oo".into()), &[]);
328
329        assert_eq!(program, "fish");
330        assert_eq!(args, vec!["-i", "-c", "echo oo"]);
331    }
332}