Skip to main content

command_stream/bun_shell/
mod.rs

1//! A portable implementation of Bun Shell (`Bun.$`), ported from Bun's
2//! `src/runtime/shell` and `src/shell_parser` and kept behaviour-identical
3//! with the JavaScript port in `js/src/bun-shell/`.
4//!
5//! Scripts are written as template literals: `strings` are the raw template
6//! parts and `values` the interpolated values between them, exactly like
7//! `` $`echo ${name} | cat` `` in JavaScript:
8//!
9//! ```rust,no_run
10//! use command_stream::bun_shell::{shell, ShellValue};
11//!
12//! # async fn demo() -> Result<(), command_stream::bun_shell::ShellError> {
13//! let out = shell(&["echo ", " | cat"], vec![ShellValue::from("world")])?
14//!     .quiet()
15//!     .run()
16//!     .await?;
17//! assert_eq!(out.text(), "world\n");
18//! # Ok(())
19//! # }
20//! ```
21//!
22//! The conformance corpus in `conformance/bun-shell/` is the specification;
23//! `rust/tests/bun_shell_conformance.rs` runs it against this module.
24
25pub(crate) mod builtin;
26pub(crate) mod builtins;
27pub(crate) mod env;
28pub(crate) mod errno;
29pub(crate) mod io;
30pub(crate) mod node_path;
31pub(crate) mod subprocess;
32
33use std::collections::HashMap;
34use std::fmt;
35use std::path::PathBuf;
36use std::sync::{Arc, Mutex};
37
38mod braces;
39mod expansion;
40pub(crate) mod glob;
41mod interpreter;
42mod lexer;
43mod parser;
44mod template;
45
46pub use braces::BraceError;
47
48/// `$.braces(pattern)`: expand a brace pattern into words, e.g.
49/// `"echo {a,b}"` into `["echo a", "echo b"]`.
50pub fn braces(pattern: &str) -> Result<Vec<String>, BraceError> {
51    braces::braces(pattern)
52}
53
54/// `$.escape(s)`: escape a string for use in a script, quoting it when it
55/// contains special characters.
56pub fn escape(s: &str) -> String {
57    template::shell_escape(s)
58}
59
60/// A fixed-size byte buffer that receives output (`> ${buf}` in JavaScript,
61/// where `buf` is a `Uint8Array`). Output beyond its size is dropped.
62#[derive(Clone, Debug)]
63pub struct OutBuffer(Arc<Mutex<Vec<u8>>>);
64
65impl OutBuffer {
66    /// A zero-filled buffer of `size` bytes.
67    pub fn new(size: usize) -> Self {
68        Self(Arc::new(Mutex::new(vec![0; size])))
69    }
70
71    /// A copy of the current contents (always `size` bytes long).
72    pub fn contents(&self) -> Vec<u8> {
73        self.0.lock().map(|b| b.clone()).unwrap_or_default()
74    }
75
76    /// Run `f` with mutable access to the underlying bytes.
77    pub fn with<R>(&self, f: impl FnOnce(&mut [u8]) -> R) -> R {
78        let mut guard = self.0.lock().unwrap_or_else(|e| e.into_inner());
79        f(guard.as_mut_slice())
80    }
81
82    /// Whether both handles refer to the same buffer.
83    pub fn ptr_eq(&self, other: &Self) -> bool {
84        Arc::ptr_eq(&self.0, &other.0)
85    }
86}
87
88/// An interpolated template value (the JavaScript value kinds that Bun Shell
89/// accepts, minus the JS-only `Response`/`Blob`/`Bun.file` objects).
90#[derive(Clone, Debug)]
91pub enum ShellValue {
92    /// A string; it is escaped (quoted) when it contains special characters.
93    Str(String),
94    /// `{ raw: "..." }`: spliced into the script source unescaped.
95    Raw(String),
96    /// A JS number (stringified like JavaScript's `String(n)`).
97    Number(f64),
98    /// A JS bigint, given as its decimal digits.
99    BigInt(String),
100    Bool(bool),
101    Null,
102    Undefined,
103    /// An array: each element becomes a separate word (nested arrays flatten).
104    Array(Vec<ShellValue>),
105    /// A byte buffer used as input (`< ${bytes}`).
106    Bytes(Vec<u8>),
107    /// An output buffer (`> ${buf}`).
108    OutBuffer(OutBuffer),
109}
110
111impl From<&str> for ShellValue {
112    fn from(s: &str) -> Self {
113        Self::Str(s.to_string())
114    }
115}
116
117impl From<String> for ShellValue {
118    fn from(s: String) -> Self {
119        Self::Str(s)
120    }
121}
122
123impl From<&String> for ShellValue {
124    fn from(s: &String) -> Self {
125        Self::Str(s.clone())
126    }
127}
128
129impl From<&std::path::Path> for ShellValue {
130    fn from(p: &std::path::Path) -> Self {
131        Self::Str(p.to_string_lossy().into_owned())
132    }
133}
134
135impl From<PathBuf> for ShellValue {
136    fn from(p: PathBuf) -> Self {
137        Self::Str(p.to_string_lossy().into_owned())
138    }
139}
140
141impl From<bool> for ShellValue {
142    fn from(b: bool) -> Self {
143        Self::Bool(b)
144    }
145}
146
147macro_rules! number_from {
148    ($($t:ty),*) => {
149        $(impl From<$t> for ShellValue {
150            fn from(n: $t) -> Self {
151                Self::Number(n as f64)
152            }
153        })*
154    };
155}
156number_from!(i8, i16, i32, i64, u8, u16, u32, u64, usize, isize, f32, f64);
157
158impl<T: Into<ShellValue>> From<Vec<T>> for ShellValue {
159    fn from(v: Vec<T>) -> Self {
160        Self::Array(v.into_iter().map(Into::into).collect())
161    }
162}
163
164impl From<OutBuffer> for ShellValue {
165    fn from(b: OutBuffer) -> Self {
166        Self::OutBuffer(b)
167    }
168}
169
170/// The result of a finished script (Bun's `ShellOutput`).
171#[derive(Clone, Debug, Default, PartialEq, Eq)]
172pub struct ShellOutput {
173    pub stdout: Vec<u8>,
174    pub stderr: Vec<u8>,
175    pub exit_code: i32,
176}
177
178impl ShellOutput {
179    /// stdout decoded as UTF-8 (lossy).
180    pub fn text(&self) -> String {
181        String::from_utf8_lossy(&self.stdout).into_owned()
182    }
183
184    /// stdout parsed as JSON.
185    pub fn json(&self) -> Result<serde_json::Value, serde_json::Error> {
186        serde_json::from_slice(&self.stdout)
187    }
188
189    /// stdout split on `\n` (like Bun's `.lines()`, the last piece included).
190    pub fn lines(&self) -> Vec<String> {
191        self.text().split('\n').map(str::to_string).collect()
192    }
193}
194
195/// What kind of failure a [`ShellError`] is.
196#[derive(Clone, Copy, Debug, PartialEq, Eq)]
197pub enum ShellErrorKind {
198    /// The script (or an interpolated value) was rejected before running;
199    /// the equivalent of the JavaScript `$` call throwing synchronously.
200    Parse,
201    /// The script exited with a non-zero code in throwing mode (Bun's
202    /// `ShellError`); `output` holds its stdout/stderr/exit code.
203    Exit,
204    /// A system error while starting or running the script (e.g. the `cwd`
205    /// does not exist).
206    System,
207}
208
209/// A failed shell invocation.
210#[derive(Clone, Debug)]
211pub struct ShellError {
212    pub kind: ShellErrorKind,
213    /// The same message the JavaScript implementation throws.
214    pub message: String,
215    /// Set for [`ShellErrorKind::Exit`].
216    pub output: Option<ShellOutput>,
217}
218
219impl ShellError {
220    pub fn parse(message: impl Into<String>) -> Self {
221        Self {
222            kind: ShellErrorKind::Parse,
223            message: message.into(),
224            output: None,
225        }
226    }
227
228    pub fn system(message: impl Into<String>) -> Self {
229        Self {
230            kind: ShellErrorKind::System,
231            message: message.into(),
232            output: None,
233        }
234    }
235
236    pub fn exit(output: ShellOutput) -> Self {
237        Self {
238            kind: ShellErrorKind::Exit,
239            message: format!("Failed with exit code {}", output.exit_code),
240            output: Some(output),
241        }
242    }
243
244    pub fn exit_code(&self) -> Option<i32> {
245        self.output.as_ref().map(|o| o.exit_code)
246    }
247}
248
249impl fmt::Display for ShellError {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        f.write_str(&self.message)
252    }
253}
254
255impl std::error::Error for ShellError {}
256
257/// Default settings for new commands (Bun's `new $.Shell()`): each `Shell`
258/// has its own cwd, env and throwing mode.
259#[derive(Clone, Debug)]
260pub struct Shell {
261    cwd: Option<PathBuf>,
262    env: Option<HashMap<String, String>>,
263    throws: bool,
264}
265
266impl Default for Shell {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272impl Shell {
273    pub fn new() -> Self {
274        Self {
275            cwd: None,
276            env: None,
277            throws: true,
278        }
279    }
280
281    /// Default working directory (`None`: the process cwd).
282    pub fn cwd(&mut self, cwd: Option<impl Into<PathBuf>>) -> &mut Self {
283        self.cwd = cwd.map(Into::into);
284        self
285    }
286
287    /// Default environment (`None`: the process environment).
288    pub fn env(&mut self, env: Option<HashMap<String, String>>) -> &mut Self {
289        self.env = env;
290        self
291    }
292
293    pub fn nothrow(&mut self) -> &mut Self {
294        self.throws = false;
295        self
296    }
297
298    pub fn throws(&mut self, throws: bool) -> &mut Self {
299        self.throws = throws;
300        self
301    }
302
303    /// Parse a template into a runnable command. Parse errors (and invalid
304    /// values) are returned here, like the JavaScript `$` call throwing.
305    pub fn command(
306        &self,
307        strings: &[&str],
308        values: Vec<ShellValue>,
309    ) -> Result<ShellCommand, ShellError> {
310        let mut cmd = ShellCommand::parse(strings, values)?;
311        cmd.throws = self.throws;
312        if let Some(cwd) = &self.cwd {
313            cmd = cmd.cwd(cwd.clone());
314        }
315        if let Some(env) = &self.env {
316            cmd = cmd.env(env.clone());
317        }
318        Ok(cmd)
319    }
320}
321
322/// Parse a template with the default [`Shell`] settings (Bun's `$`).
323pub fn shell(strings: &[&str], values: Vec<ShellValue>) -> Result<ShellCommand, ShellError> {
324    Shell::new().command(strings, values)
325}
326
327/// A parsed script plus its settings (Bun's `ShellPromise`). Nothing runs
328/// until [`ShellCommand::run`] is awaited.
329pub struct ShellCommand {
330    script: ParsedScript,
331    cwd: Option<PathBuf>,
332    env: HashMap<String, String>,
333    quiet: bool,
334    throws: bool,
335}
336
337// Environment values often hold secrets (tokens, auth headers), so `Debug`
338// prints only the variable names.
339impl fmt::Debug for ShellCommand {
340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341        let mut env: Vec<&str> = self.env.keys().map(String::as_str).collect();
342        env.sort_unstable();
343        f.debug_struct("ShellCommand")
344            .field("script", &self.script)
345            .field("cwd", &self.cwd)
346            .field("env", &env)
347            .field("quiet", &self.quiet)
348            .field("throws", &self.throws)
349            .finish()
350    }
351}
352
353/// The parsed form of a template.
354#[derive(Debug)]
355pub(crate) struct ParsedScript {
356    pub(crate) ast: parser::Script,
357    /// Strings referenced by `\x08__bunstr_N\x08` placeholders (already
358    /// substituted into the AST by the parser; kept for inspection).
359    #[cfg_attr(not(test), allow(dead_code))]
360    pub(crate) jsstrings: Vec<String>,
361    /// Values referenced by `\x08__bun_N\x08` placeholders (buffers).
362    pub(crate) jsobjs: Vec<ShellValue>,
363}
364
365impl ShellCommand {
366    fn parse(strings: &[&str], values: Vec<ShellValue>) -> Result<Self, ShellError> {
367        let src = template::build_shell_source(strings, values).map_err(ShellError::parse)?;
368        let ast = parser::parse(&src.script, &src.jsstrings, src.jsobjs.len())
369            .map_err(ShellError::parse)?;
370        Ok(Self {
371            script: ParsedScript {
372                ast,
373                jsstrings: src.jsstrings,
374                jsobjs: src.jsobjs,
375            },
376            cwd: None,
377            env: std::env::vars().collect(),
378            quiet: false,
379            throws: true,
380        })
381    }
382
383    /// Working directory (`""`, `"."` and `"./"` mean the process cwd).
384    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
385        let cwd = cwd.into();
386        let s = cwd.to_string_lossy();
387        self.cwd = if s.is_empty() || s == "." || s == "./" {
388            std::env::current_dir().ok()
389        } else {
390            Some(cwd)
391        };
392        self
393    }
394
395    /// Replace the environment (Bun's `.env({...})`).
396    pub fn env(mut self, env: HashMap<String, String>) -> Self {
397        self.env = env;
398        self
399    }
400
401    /// Capture output only, without echoing it to the process stdout/stderr.
402    pub fn quiet(mut self) -> Self {
403        self.quiet = true;
404        self
405    }
406
407    /// Resolve with the output even when the exit code is non-zero.
408    pub fn nothrow(mut self) -> Self {
409        self.throws = false;
410        self
411    }
412
413    pub fn throws(mut self, throws: bool) -> Self {
414        self.throws = throws;
415        self
416    }
417
418    /// Run the script to completion.
419    pub async fn run(self) -> Result<ShellOutput, ShellError> {
420        // Sorted, so the child environment does not depend on hash order.
421        let mut env: Vec<(String, String)> = self.env.into_iter().collect();
422        env.sort_unstable_by(|a, b| a.0.cmp(&b.0));
423        let (interp, mut root) = interpreter::Interpreter::new(interpreter::InterpreterOptions {
424            jsobjs: self.script.jsobjs,
425            env: env.into_iter().collect(),
426            cwd: self.cwd.map(|p| p.to_string_lossy().into_owned()),
427            quiet: self.quiet,
428            argv: std::env::args().collect(),
429        })?;
430        let out = interp.run(&self.script.ast, &mut root).await?;
431        let output = ShellOutput {
432            stdout: out.stdout,
433            stderr: out.stderr,
434            exit_code: out.exit_code,
435        };
436        if self.throws && output.exit_code != 0 {
437            return Err(ShellError::exit(output));
438        }
439        Ok(output)
440    }
441
442    /// Run quietly and return stdout as text.
443    pub async fn text(self) -> Result<String, ShellError> {
444        Ok(self.quiet().run().await?.text())
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn parse_error(strings: &[&str], values: Vec<ShellValue>) -> ShellError {
453        match shell(strings, values) {
454            Ok(_) => panic!("expected a parse error for {strings:?}"),
455            Err(e) => e,
456        }
457    }
458
459    #[test]
460    fn frontend_errors_are_parse_errors_with_the_js_message() {
461        let e = parse_error(&["echo ("], vec![]);
462        assert_eq!(e.kind, ShellErrorKind::Parse);
463        assert_eq!(e.message, "Unclosed subshell");
464        let e = parse_error(&["echo $(echo"], vec![]);
465        assert_eq!(e.message, "Unclosed command substitution");
466        let e = parse_error(&["echo hi &"], vec![]);
467        assert_eq!(
468            e.message,
469            "Background commands \"&\" are not supported yet."
470        );
471        let e = parse_error(&["echo ", ""], vec![ShellValue::Str("a\0b".into())]);
472        assert_eq!(e.kind, ShellErrorKind::Parse);
473    }
474
475    #[test]
476    fn debug_shows_env_names_but_not_values() {
477        let env = HashMap::from([("TOKEN".to_string(), "s3cret".to_string())]);
478        let cmd = shell(&["echo hi"], vec![]).unwrap().env(env);
479        let debug = format!("{cmd:?}");
480        assert!(debug.contains("TOKEN"), "{debug}");
481        assert!(!debug.contains("s3cret"), "{debug}");
482    }
483
484    #[test]
485    fn parse_keeps_interpolated_strings_and_objects() {
486        let cmd = shell(
487            &["cat ", " < ", ""],
488            vec![ShellValue::from("a b"), ShellValue::Bytes(b"x".to_vec())],
489        )
490        .unwrap();
491        assert_eq!(cmd.script.jsstrings, ["a b"]);
492        assert_eq!(cmd.script.jsobjs.len(), 1);
493        assert_eq!(cmd.script.ast.stmts.len(), 1);
494    }
495
496    #[test]
497    fn braces_and_escape() {
498        assert_eq!(braces("x{a,b}").unwrap(), ["xa", "xb"]);
499        assert_eq!(
500            braces(&"{a,b}".repeat(17)).unwrap_err(),
501            BraceError::TooManyExpansions(131072)
502        );
503        assert_eq!(escape("a b"), "\"a b\"");
504        assert_eq!(escape("ab"), "ab");
505    }
506}