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    prefer_local: crate::PreferLocal,
264    throws: bool,
265}
266
267impl Default for Shell {
268    fn default() -> Self {
269        Self::new()
270    }
271}
272
273impl Shell {
274    pub fn new() -> Self {
275        Self {
276            cwd: None,
277            env: None,
278            prefer_local: crate::PreferLocal::Off,
279            throws: true,
280        }
281    }
282
283    /// Default working directory (`None`: the process cwd).
284    pub fn cwd(&mut self, cwd: Option<impl Into<PathBuf>>) -> &mut Self {
285        self.cwd = cwd.map(Into::into);
286        self
287    }
288
289    /// Default environment (`None`: the process environment).
290    pub fn env(&mut self, env: Option<HashMap<String, String>>) -> &mut Self {
291        self.env = env;
292        self
293    }
294
295    /// Prefer project-local executables (a command-stream extension).
296    pub fn prefer_local(&mut self, preference: crate::PreferLocal) -> &mut Self {
297        self.prefer_local = preference;
298        self
299    }
300
301    pub fn nothrow(&mut self) -> &mut Self {
302        self.throws = false;
303        self
304    }
305
306    pub fn throws(&mut self, throws: bool) -> &mut Self {
307        self.throws = throws;
308        self
309    }
310
311    /// Parse a template into a runnable command. Parse errors (and invalid
312    /// values) are returned here, like the JavaScript `$` call throwing.
313    pub fn command(
314        &self,
315        strings: &[&str],
316        values: Vec<ShellValue>,
317    ) -> Result<ShellCommand, ShellError> {
318        let mut cmd = ShellCommand::parse(strings, values)?;
319        cmd.throws = self.throws;
320        if let Some(cwd) = &self.cwd {
321            cmd = cmd.cwd(cwd.clone());
322        }
323        if let Some(env) = &self.env {
324            cmd = cmd.env(env.clone());
325        }
326        cmd = cmd.prefer_local(self.prefer_local.clone());
327        Ok(cmd)
328    }
329}
330
331/// Parse a template with the default [`Shell`] settings (Bun's `$`).
332pub fn shell(strings: &[&str], values: Vec<ShellValue>) -> Result<ShellCommand, ShellError> {
333    Shell::new().command(strings, values)
334}
335
336/// A parsed script plus its settings (Bun's `ShellPromise`). Nothing runs
337/// until [`ShellCommand::run`] is awaited.
338pub struct ShellCommand {
339    script: ParsedScript,
340    cwd: Option<PathBuf>,
341    env: HashMap<String, String>,
342    prefer_local: crate::PreferLocal,
343    quiet: bool,
344    throws: bool,
345}
346
347// Environment values often hold secrets (tokens, auth headers), so `Debug`
348// prints only the variable names.
349impl fmt::Debug for ShellCommand {
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        let mut env: Vec<&str> = self.env.keys().map(String::as_str).collect();
352        env.sort_unstable();
353        f.debug_struct("ShellCommand")
354            .field("script", &self.script)
355            .field("cwd", &self.cwd)
356            .field("env", &env)
357            .field("prefer_local", &self.prefer_local)
358            .field("quiet", &self.quiet)
359            .field("throws", &self.throws)
360            .finish()
361    }
362}
363
364/// The parsed form of a template.
365#[derive(Debug)]
366pub(crate) struct ParsedScript {
367    pub(crate) ast: parser::Script,
368    /// Strings referenced by `\x08__bunstr_N\x08` placeholders (already
369    /// substituted into the AST by the parser; kept for inspection).
370    #[cfg_attr(not(test), allow(dead_code))]
371    pub(crate) jsstrings: Vec<String>,
372    /// Values referenced by `\x08__bun_N\x08` placeholders (buffers).
373    pub(crate) jsobjs: Vec<ShellValue>,
374}
375
376impl ShellCommand {
377    fn parse(strings: &[&str], values: Vec<ShellValue>) -> Result<Self, ShellError> {
378        let src = template::build_shell_source(strings, values).map_err(ShellError::parse)?;
379        let ast = parser::parse(&src.script, &src.jsstrings, src.jsobjs.len())
380            .map_err(ShellError::parse)?;
381        Ok(Self {
382            script: ParsedScript {
383                ast,
384                jsstrings: src.jsstrings,
385                jsobjs: src.jsobjs,
386            },
387            cwd: None,
388            env: std::env::vars().collect(),
389            prefer_local: crate::PreferLocal::Off,
390            quiet: false,
391            throws: true,
392        })
393    }
394
395    /// Working directory (`""`, `"."` and `"./"` mean the process cwd).
396    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
397        let cwd = cwd.into();
398        let s = cwd.to_string_lossy();
399        self.cwd = if s.is_empty() || s == "." || s == "./" {
400            std::env::current_dir().ok()
401        } else {
402            Some(cwd)
403        };
404        self
405    }
406
407    /// Replace the environment (Bun's `.env({...})`).
408    pub fn env(mut self, env: HashMap<String, String>) -> Self {
409        self.env = env;
410        self
411    }
412
413    /// Prefer project-local executables (a command-stream extension).
414    pub fn prefer_local(mut self, preference: crate::PreferLocal) -> Self {
415        self.prefer_local = preference;
416        self
417    }
418
419    /// Capture output only, without echoing it to the process stdout/stderr.
420    pub fn quiet(mut self) -> Self {
421        self.quiet = true;
422        self
423    }
424
425    /// Resolve with the output even when the exit code is non-zero.
426    pub fn nothrow(mut self) -> Self {
427        self.throws = false;
428        self
429    }
430
431    pub fn throws(mut self, throws: bool) -> Self {
432        self.throws = throws;
433        self
434    }
435
436    /// Run the script to completion.
437    pub async fn run(mut self) -> Result<ShellOutput, ShellError> {
438        let cwd = self
439            .cwd
440            .clone()
441            .or_else(|| std::env::current_dir().ok())
442            .unwrap_or_else(|| PathBuf::from("."));
443        if let Some((key, path)) =
444            crate::local_bin::preferred_path(Some(&self.env), &cwd, &self.prefer_local)
445        {
446            self.env.insert(key, path);
447        }
448        // Sorted, so the child environment does not depend on hash order.
449        let mut env: Vec<(String, String)> = self.env.into_iter().collect();
450        env.sort_unstable_by(|a, b| a.0.cmp(&b.0));
451        let (interp, mut root) = interpreter::Interpreter::new(interpreter::InterpreterOptions {
452            jsobjs: self.script.jsobjs,
453            env: env.into_iter().collect(),
454            cwd: self.cwd.map(|p| p.to_string_lossy().into_owned()),
455            quiet: self.quiet,
456            argv: std::env::args().collect(),
457        })?;
458        let out = interp.run(&self.script.ast, &mut root).await?;
459        let output = ShellOutput {
460            stdout: out.stdout,
461            stderr: out.stderr,
462            exit_code: out.exit_code,
463        };
464        if self.throws && output.exit_code != 0 {
465            return Err(ShellError::exit(output));
466        }
467        Ok(output)
468    }
469
470    /// Run quietly and return stdout as text.
471    pub async fn text(self) -> Result<String, ShellError> {
472        Ok(self.quiet().run().await?.text())
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    fn parse_error(strings: &[&str], values: Vec<ShellValue>) -> ShellError {
481        match shell(strings, values) {
482            Ok(_) => panic!("expected a parse error for {strings:?}"),
483            Err(e) => e,
484        }
485    }
486
487    #[test]
488    fn frontend_errors_are_parse_errors_with_the_js_message() {
489        let e = parse_error(&["echo ("], vec![]);
490        assert_eq!(e.kind, ShellErrorKind::Parse);
491        assert_eq!(e.message, "Unclosed subshell");
492        let e = parse_error(&["echo $(echo"], vec![]);
493        assert_eq!(e.message, "Unclosed command substitution");
494        let e = parse_error(&["echo hi &"], vec![]);
495        assert_eq!(
496            e.message,
497            "Background commands \"&\" are not supported yet."
498        );
499        let e = parse_error(&["echo ", ""], vec![ShellValue::Str("a\0b".into())]);
500        assert_eq!(e.kind, ShellErrorKind::Parse);
501    }
502
503    #[test]
504    fn debug_shows_env_names_but_not_values() {
505        let env = HashMap::from([("TOKEN".to_string(), "s3cret".to_string())]);
506        let cmd = shell(&["echo hi"], vec![]).unwrap().env(env);
507        let debug = format!("{cmd:?}");
508        assert!(debug.contains("TOKEN"), "{debug}");
509        assert!(!debug.contains("s3cret"), "{debug}");
510    }
511
512    #[test]
513    fn parse_keeps_interpolated_strings_and_objects() {
514        let cmd = shell(
515            &["cat ", " < ", ""],
516            vec![ShellValue::from("a b"), ShellValue::Bytes(b"x".to_vec())],
517        )
518        .unwrap();
519        assert_eq!(cmd.script.jsstrings, ["a b"]);
520        assert_eq!(cmd.script.jsobjs.len(), 1);
521        assert_eq!(cmd.script.ast.stmts.len(), 1);
522    }
523
524    #[test]
525    fn braces_and_escape() {
526        assert_eq!(braces("x{a,b}").unwrap(), ["xa", "xb"]);
527        assert_eq!(
528            braces(&"{a,b}".repeat(17)).unwrap_err(),
529            BraceError::TooManyExpansions(131072)
530        );
531        assert_eq!(escape("a b"), "\"a b\"");
532        assert_eq!(escape("ab"), "ab");
533    }
534}