From fa4604b05bae510864b2a52a509226c31eca43d2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:06:23 +0000 Subject: [PATCH 01/10] shell: expand `${NAME}` and the `${NAMEword}` forms The lexer reads `${NAME}`, `${0}` to `${9}` and `${NAMEword}` with the operators `:-`, `-`, `:+`, `+`, `:=`, `=`, `:?` and `?`. Other text after `${` stays text. `Expansion` expands the word of a form only when the word is the result. One walk (`walk_words`) expands a word with and without a `$(...)` in it. `end_field` is the one place that ends an argument before the end of a word. `:?` and `?` fail their command with `bun: NAME: word` and exit code 1. An error in an assignment value goes to stderr. `parse_script` in `bun_shell_parser` lexes and parses a script. When a pass that read a form fails, it reads the script again with every `${` as text. It does not do that when the pass read a `:?` or a `?` form. In the `$` template tag, a value after `$NAME`, `${`, `${NAME`, `${NAME:` or `${NAME` stays data (`ends_in_param_head`). --- docs/runtime/shell.mdx | 62 ++ src/runtime/shell/IOWriter.rs | 6 +- src/runtime/shell/interpreter.rs | 66 +- src/runtime/shell/mod.rs | 5 +- src/runtime/shell/shell_body.rs | 52 +- src/runtime/shell/states/Assigns.rs | 56 +- src/runtime/shell/states/Expansion.rs | 469 ++++++++++-- src/shell_parser/json_fmt.rs | 24 +- src/shell_parser/lib.rs | 4 +- src/shell_parser/parse.rs | 690 ++++++++++++++++-- test/cli/run/run-shell.test.ts | 282 +++++++ test/js/bun/shell/bunshell.test.ts | 642 ++++++++++++++++ test/js/bun/shell/env.positionals.test.ts | 22 + test/js/bun/shell/exec.test.ts | 35 + .../js/bun/shell/fixtures/positionals3.bun.sh | 8 + test/js/bun/shell/lex.test.ts | 458 ++++++++++++ test/js/bun/shell/parse.test.ts | 96 +++ test/js/bun/shell/yield.test.ts | 33 + 18 files changed, 2816 insertions(+), 194 deletions(-) create mode 100644 test/js/bun/shell/fixtures/positionals3.bun.sh diff --git a/docs/runtime/shell.mdx b/docs/runtime/shell.mdx index 4cc6ed402917..54d3eff94f39 100644 --- a/docs/runtime/shell.mdx +++ b/docs/runtime/shell.mdx @@ -327,6 +327,68 @@ const foo = "bar123; rm -rf /tmp"; await $`FOO=${foo} bun -e 'console.log(process.env.FOO)'`; // bar123; rm -rf /tmp\n ``` +### Reading variables + +Read a variable with `$NAME` or `${NAME}`. Use the braces when text follows the name, because `$FOObaz` reads the variable `FOObaz`: + +```sh script.sh icon="file-code" +FOO=bar +echo ${FOO}baz +``` + +```txt +barbaz +``` + +`${0}` to `${9}` read the same positional arguments as `$0` to `$9`. + +A form with an operator gives a default value, an alternative value, an assignment or an error: + +| Form | `NAME` is set | `NAME` is not set | +| ------------------------------- | ------------- | -------------------------------------------- | +| `${NAME:-word}`, `${NAME-word}` | its value | `word` | +| `${NAME:+word}`, `${NAME+word}` | `word` | nothing | +| `${NAME:=word}`, `${NAME=word}` | its value | `word`, and `NAME` is set to `word` | +| `${NAME:?word}`, `${NAME?word}` | its value | the command fails, and `word` is the message | + +With the `:`, a variable that is empty also counts as not set. + +```json package.json icon="file-json" +{ + "scripts": { + "dev": "next dev --port ${PORT:-3000}", + "deploy": "./deploy.sh --env ${DEPLOY_ENV:?set DEPLOY_ENV first}" + } +} +``` + +The word is expanded only when it is the result. It can hold quotes, variables, `$(...)` and other forms. Unquoted whitespace in the word separates arguments, so `${TOKEN:+--token $TOKEN}` gives two arguments or none. + +These forms differ from `sh` in a few ways: + +- `${NAME:?word}` fails its command with exit code 1 before the command runs. The script then continues with the next command. Put `&&` after the command to stop what follows it: `true "${NAME:?}" && ./deploy.sh`. +- Three cases do not fail the command. After `export NAME` with no value, `${NAME?word}` sees `NAME` as set. `VAR=$(cmd "${NAME:?}")` prints the message, but the assignment succeeds. An assignment that is a member of a pipeline, as in `VAR=${NAME:?} | cmd`, is not expanded. +- The value of a variable is one argument. Bun Shell does not split it at whitespace, and a `*` in it is not a glob. `${NAME:=a b}` is one argument. +- A form in double quotes that expands to nothing gives no argument, as `"$NAME"` does. +- A `*` or a `{a,b}` expands only in the last argument that a word gives. In `*.md${EXTRA:+ $EXTRA}`, the `*.md` stays as written when `EXTRA` is set. Put a space before the form. +- An assignment before a command does not read an earlier one of the same command. In `A=1 B=${A:-2} cmd`, `B` is `2`. +- Other `${...}` forms, such as `${#NAME}`, `${NAME%pattern}`, `${NAME/a/b}` and `${10}`, are not supported. Bun Shell does not expand them: the `$` is text, and the rest is read as ordinary shell syntax. +- When a form has no closing `}`, Bun Shell reads every `${` of the script as text. When that script also has a `:?` or a `?` form, Bun Shell reports `Unclosed parameter expansion` and runs nothing. + + + +In a `$` template literal, `${...}` is a JavaScript interpolation. `\${...}` is text there, because Bun reads the [`raw`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#raw_strings) template. Write `$NAME` to read a variable, and put it in double quotes when text follows the name. To use a form with an operator, put it in a constant `{ raw }` object (see `$.escape` under Utilities): + +```js +import { $ } from "bun"; + +await $`FOO=bar; echo "$FOO"baz`; // barbaz\n +await $`echo ${{ raw: "${PORT:-3000}" }}`; // 3000\n +await $`echo \${PORT:-3000}`; // ${PORT:-3000}\n +``` + + + ### Changing the environment variables By default, all commands use `process.env` as their environment variables. diff --git a/src/runtime/shell/IOWriter.rs b/src/runtime/shell/IOWriter.rs index dd80dc5f4f5a..d31f5144505e 100644 --- a/src/runtime/shell/IOWriter.rs +++ b/src/runtime/shell/IOWriter.rs @@ -86,6 +86,7 @@ pub(crate) enum WriterTag { Builtin, Cmd, CondExpr, + Assigns, Pipeline, /// `subproc::PipeReader::CapturedWriter` — heap-allocated, addressed via /// `ChildPtr::raw` rather than `node`. @@ -1161,13 +1162,16 @@ pub(crate) fn on_io_writer_chunk( err: Option, ) -> Yield { use crate::shell::builtin::Builtin; - use crate::shell::states::{cmd, cond_expr, pipeline}; + use crate::shell::states::{assigns, cmd, cond_expr, pipeline}; match child.tag { WriterTag::Builtin => Builtin::on_io_writer_chunk(interp, child.node, written, err), WriterTag::Cmd => cmd::Cmd::on_io_writer_chunk(interp, child.node, written, err), WriterTag::CondExpr => { cond_expr::CondExpr::on_io_writer_chunk(interp, child.node, written, err) } + WriterTag::Assigns => { + assigns::Assigns::on_io_writer_chunk(interp, child.node, written, err) + } WriterTag::Pipeline => { pipeline::Pipeline::on_io_writer_chunk(interp, child.node, written, err) } diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index d0b842454559..cb8c5aecc33f 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -119,6 +119,8 @@ pub(crate) enum Node { Subshell(Subshell), } +const _: () = assert!(size_of::() == 176); + impl Node { pub(crate) fn kind(&self) -> StateKind { match self { @@ -172,6 +174,22 @@ impl Node { Node::Subshell(s) => Some(&mut s.base), } } + + /// The streams of a state. `Assigns` and `Expansion` have none of their own. + pub(crate) fn io(&self) -> Option<&IO> { + match self { + Node::Free | Node::Assigns(_) | Node::Expansion(_) => None, + Node::Script(s) => Some(&s.io), + Node::Stmt(s) => Some(&s.io), + Node::Binary(s) => Some(&s.io), + Node::Pipeline(s) => Some(&s.io), + Node::Cmd(s) => Some(&s.io), + Node::If(s) => Some(&s.io), + Node::CondExpr(s) => Some(&s.io), + Node::Async(s) => Some(&s.io), + Node::Subshell(s) => Some(&s.io), + } + } } /// Generate `Interpreter::as_{,_mut}` typed accessors. These panic on @@ -419,22 +437,14 @@ impl Interpreter { out_parser: &mut Option>, out_lex_result: &mut Option>, ) -> crate::Result> { - use crate::shell::shell_body::{LexerAscii, LexerUnicode, ParseError, Parser}; - let lex_result = if bun_core::is_all_ascii(src) { - let mut lexer = LexerAscii::new(arena, src, jsstrings_to_escape, jsobjs_len); - lexer.lex().map_err(crate::Error::from)?; - lexer.get_result() - } else { - let mut lexer = LexerUnicode::new(arena, src, jsstrings_to_escape, jsobjs_len); - lexer.lex().map_err(crate::Error::from)?; - lexer.get_result() - }; - if !lex_result.errors.is_empty() { - *out_lex_result = Some(lex_result); - return Err(ParseError::Lex.into()); - } - *out_parser = Some(Parser::new(arena, lex_result)); - Ok(out_parser.as_mut().unwrap().parse()?) + bun_shell_parser::parse_script( + arena, + src, + jsstrings_to_escape, + jsobjs_len, + out_parser, + out_lex_result, + ) } /// Builds the root `ShellExecEnv` (export env from the event loop's @@ -1467,19 +1477,20 @@ impl Interpreter { } /// Appends the value of `$N` to - /// `out`. Takes the relevant interpreter fields by-part so callers can + /// `out`, and returns whether `$N` is set. Takes the relevant interpreter fields by-part so callers can /// split-borrow alongside the node arena. /// /// # Safety /// `command_ctx` must be null or point to a live `ContextData` that /// outlives this call. + #[inline(always)] // `Expansion` runs this for each `$N`, and `${Nword}` is a second caller. pub(crate) fn append_var_argv( out: &mut Vec, original_int: u8, event_loop: EventLoopHandle, command_ctx: *mut bun_options_types::context::ContextData, vm_args_utf8: &mut Vec>, - ) { + ) -> bool { let mut int = original_int; match event_loop { EventLoopHandle::Js { .. } => { @@ -1487,7 +1498,7 @@ impl Interpreter { if let Ok(p) = bun_core::self_exe_path() { out.extend_from_slice(p.as_bytes()); } - return; + return true; } int -= 1; @@ -1495,7 +1506,7 @@ impl Interpreter { .bun_vm() .cast::(); if vm_ptr.is_null() { - return; + return false; } // SAFETY: `bun_vm()` on a JS event loop returns the live // `*VirtualMachine` owning that loop. @@ -1503,7 +1514,7 @@ impl Interpreter { if let Some(main) = vm.main() { if int == 0 { out.extend_from_slice(main); - return; + return true; } int -= 1; } @@ -1511,7 +1522,7 @@ impl Interpreter { if let Some(worker) = vm.worker_ref() { let argv = worker.argv(); if int as usize >= argv.len() { - return; + return false; } if vm_args_utf8.len() != argv.len() { vm_args_utf8.reserve(argv.len()); @@ -1521,30 +1532,33 @@ impl Interpreter { } } out.extend_from_slice(vm_args_utf8[int as usize].slice()); - return; + return true; } if (int as usize) < vm.argv.len() { out.extend_from_slice(&vm.argv[int as usize]); + return true; } + false } EventLoopHandle::Mini(_) => { if command_ctx.is_null() { - return; + return false; } // SAFETY: `command_ctx` is the process-global `ContextData` // (see `init`); it outlives the interpreter. let ctx = unsafe { &*command_ctx }; if int as usize > ctx.passthrough.len() { - return; + return false; } if int == 0 { if let Some(last) = ctx.positionals.last() { out.extend_from_slice(last); } - return; + return true; } out.extend_from_slice(&ctx.passthrough[int as usize - 1]); + true } } } diff --git a/src/runtime/shell/mod.rs b/src/runtime/shell/mod.rs index c7402b5448b9..862f58768c69 100644 --- a/src/runtime/shell/mod.rs +++ b/src/runtime/shell/mod.rs @@ -143,7 +143,7 @@ pub(crate) use bun_shell_parser::{escape_8bit, needs_escape_utf8_ascii_latin1}; // layout) at the arena/state-machine boundary. pub(crate) mod ast { use bun_shell_parser::parse::ast as p; - pub(crate) use p::{BinaryOp, CondExprOp, IoKind, RedirectFlags}; + pub(crate) use p::{BinaryOp, CondExprOp, IoKind, ParamAction, RedirectFlags}; pub(crate) type SmolList = bun_shell_parser::parse::SmolList<'static, T, N>; pub(crate) type Script = p::Script<'static>; @@ -160,6 +160,9 @@ pub(crate) mod ast { pub(crate) type Assign = p::Assign<'static>; pub(crate) type Atom = p::Atom<'static>; pub(crate) type SimpleAtom = p::SimpleAtom<'static>; + pub(crate) type CmdSubst = p::CmdSubst<'static>; + pub(crate) type ParamExp = p::ParamExp<'static>; + pub(crate) type ParamTarget = p::ParamTarget<'static>; } // Canonical 4-variant shell error enum. Defined in diff --git a/src/runtime/shell/shell_body.rs b/src/runtime/shell/shell_body.rs index 8b5134f7e366..30c5de17effb 100644 --- a/src/runtime/shell/shell_body.rs +++ b/src/runtime/shell/shell_body.rs @@ -29,9 +29,8 @@ pub(crate) use super::subproc; // declared once in `shell/mod.rs` // (still-draft) JSC bridge below. This file keeps the JSC-coupled half // (ShellErr, GlobalJS/Mini, shell_cmd_from_js, ShellSrcBuilder, TestingAPIs). pub(crate) use bun_shell_parser::parse::{ - IfClauseTok, LEX_JS_OBJREF_PREFIX, LEX_JS_REF_TERMINATOR, LEX_JS_STRING_PREFIX, LexerAscii, - LexerUnicode, ParseError, Parser, Token, ast, is_if_clause_keyword_bunstr, needs_escape_bunstr, - needs_escape_utf8_ascii_latin1, + IfClauseTok, LEX_JS_OBJREF_PREFIX, LEX_JS_REF_TERMINATOR, LEX_JS_STRING_PREFIX, Token, ast, + is_if_clause_keyword_bunstr, needs_escape_bunstr, needs_escape_utf8_ascii_latin1, }; #[cfg(windows)] @@ -127,6 +126,9 @@ pub(crate) mod test { CloseParen, Var(&'a [u8]), VarArgv(u8), + ParamBegin(ast::ParamOp), + ParamSpace(&'a [u8]), + ParamEnd, Text(&'a [u8]), SingleQuotedText(&'a [u8]), DoubleQuotedText(&'a [u8]), @@ -142,6 +144,11 @@ pub(crate) mod test { match the_token { Token::Var(txt) => TestToken::Var(&buf[txt.start as usize..txt.end as usize]), Token::VarArgv(int) => TestToken::VarArgv(int), + Token::ParamBegin(op) => TestToken::ParamBegin(op), + Token::ParamSpace(txt) => { + TestToken::ParamSpace(&buf[txt.start as usize..txt.end as usize]) + } + Token::ParamEnd => TestToken::ParamEnd, Token::Text(txt) => TestToken::Text(&buf[txt.start as usize..txt.end as usize]), Token::SingleQuotedText(txt) => { TestToken::SingleQuotedText(&buf[txt.start as usize..txt.end as usize]) @@ -214,6 +221,13 @@ pub(crate) mod test { w.write_char('}') } T::VarArgv(n) => write!(w, "{{\"VarArgv\":{}}}", n), + T::ParamBegin(op) => write!(w, "{{\"ParamBegin\":\"{}\"}}", op.as_str()), + T::ParamSpace(s) => { + w.write_str("{\"ParamSpace\":")?; + encode_json_string(w, s)?; + w.write_char('}') + } + T::ParamEnd => unit!("ParamEnd"), T::Text(s) => { w.write_str("{\"Text\":")?; encode_json_string(w, s)?; @@ -551,7 +565,7 @@ impl<'a> ShellSrcBuilder<'a> { // marker recognized regardless of quote context (e.g. inside single quotes). if needs_escape_bunstr(&bunstr) || is_if_clause_keyword_bunstr(&bunstr) - || self.outbuf_ends_with_var_ref() + || bun_shell_parser::ends_in_param_head(self.outbuf) { self.append_js_str_ref(bunstr)?; return Ok(true); @@ -581,7 +595,7 @@ impl<'a> ShellSrcBuilder<'a> { if ALLOW_ESCAPE { if needs_escape_utf8_ascii_latin1(utf8) || IfClauseTok::from_text(utf8).is_some() - || self.outbuf_ends_with_var_ref() + || bun_shell_parser::ends_in_param_head(self.outbuf) { self.append_js_str_ref(BunString::clone_utf8(utf8))?; return Ok(true); @@ -592,17 +606,6 @@ impl<'a> ShellSrcBuilder<'a> { Ok(true) } - fn outbuf_ends_with_var_ref(&self) -> bool { - match self - .outbuf - .iter() - .rposition(|b| !(b.is_ascii_alphanumeric() || *b == b'_')) - { - Some(i) => self.outbuf[i] == b'$', - None => false, - } - } - pub(crate) fn append_utf16_impl(&mut self, utf16: &[u16]) -> Result<(), bun_alloc::AllocError> { let size = simdutf::length::utf8::from::utf16::le(utf16); self.outbuf.reserve(size); @@ -706,20 +709,13 @@ pub(crate) mod testing_apis { )?; let jsobjs_len: u32 = u32::try_from(marked_argument_buffer.size()).expect("int cast"); - let lex_result = 'brk: { - if strings::is_all_ascii(&script[..]) { - let mut lexer = LexerAscii::new(&arena, &script[..], &jsstrings[..], jsobjs_len); - if let Err(err) = lexer.lex() { + let lex_result = + match bun_shell_parser::lex_script(&arena, &script[..], &jsstrings[..], jsobjs_len) { + Ok(lex_result) => lex_result, + Err(err) => { return Err(global.throw_error(crate::Error::from(err), "failed to lex shell")); } - break 'brk lexer.get_result(); - } - let mut lexer = LexerUnicode::new(&arena, &script[..], &jsstrings[..], jsobjs_len); - if let Err(err) = lexer.lex() { - return Err(global.throw_error(crate::Error::from(err), "failed to lex shell")); - } - lexer.get_result() - }; + }; if !lex_result.errors.is_empty() { let str = lex_result.combine_errors(&arena); diff --git a/src/runtime/shell/states/Assigns.rs b/src/runtime/shell/states/Assigns.rs index 6fd1a9d0ddde..ae56ffaac56d 100644 --- a/src/runtime/shell/states/Assigns.rs +++ b/src/runtime/shell/states/Assigns.rs @@ -3,10 +3,12 @@ use crate::shell::ast; use crate::shell::interpreter::{Interpreter, Node, NodeId, ShellExecEnv, log}; +use crate::shell::io::OutKind; +use crate::shell::io_writer; use crate::shell::states::base::Base; use crate::shell::states::expansion::Expansion; use crate::shell::yield_::Yield; -use crate::shell::{EnvStr, ExitCode}; +use crate::shell::{EnvStr, ExitCode, ShellErr}; #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum AssignCtx { @@ -30,6 +32,8 @@ pub(crate) enum AssignsState { Expanding { idx: u32, }, + /// The error of a value is being written to stderr. + WaitingWriteErr, Done, } @@ -75,6 +79,7 @@ impl Assigns { let child = Expansion::init(interp, shell, atom, this, true); return Expansion::start(interp, child); } + AssignsState::WaitingWriteErr => return Yield::suspended(), AssignsState::Done => { let parent = interp.as_assigns(this).base.parent; return interp.child_done(parent, this, 0); @@ -91,10 +96,9 @@ impl Assigns { ) -> Yield { // Child is always an Expansion. if exit_code != 0 { + let err = Expansion::take_err(interp, child); interp.deinit_node(child); - interp.as_assigns_mut(this).state = AssignsState::Done; - let parent = interp.as_assigns(this).base.parent; - return interp.child_done(parent, this, 1); + return Self::fail(interp, this, err); } let out = Expansion::take_out(interp, child); @@ -139,6 +143,50 @@ impl Assigns { Yield::Next(this) } + /// A value did not expand: its error goes to the stderr of the statement or the `Cmd` of these assignments. + fn fail(interp: &Interpreter, this: NodeId, err: Option) -> Yield { + let parent = interp.as_assigns(this).base.parent; + let stderr = match (&err, interp.node(parent).io()) { + (Some(_), Some(io)) => io.stderr.clone(), + _ => OutKind::Ignore, + }; + let me = interp.as_assigns_mut(this); + match (stderr, err) { + (OutKind::Fd(fd), Some(err)) => { + me.state = AssignsState::WaitingWriteErr; + let child = io_writer::ChildPtr::new(this, io_writer::WriterTag::Assigns); + fd.writer + .enqueue(child, fd.captured, format!("{err}\n").as_bytes()) + } + (OutKind::Pipe, Some(err)) => { + // SAFETY: single trampoline frame; no other borrow of the env's + // (or its parent's) stderr buffer is live. + let stderr = unsafe { me.base.shell_mut().buffered_stderr_mut() }; + stderr.extend_from_slice(format!("{err}\n").as_bytes()); + me.state = AssignsState::Done; + interp.child_done(parent, this, 1) + } + _ => { + me.state = AssignsState::Done; + interp.child_done(parent, this, 1) + } + } + } + + /// The error of `fail` is written, or the write failed: the exit code is 1 in both cases. + pub(crate) fn on_io_writer_chunk( + interp: &Interpreter, + this: NodeId, + _written: usize, + _err: Option, + ) -> Yield { + let me = interp.as_assigns_mut(this); + debug_assert!(matches!(me.state, AssignsState::WaitingWriteErr)); + me.state = AssignsState::Done; + let parent = me.base.parent; + interp.child_done(parent, this, 1) + } + pub(crate) fn deinit(_interp: &Interpreter, this: NodeId) { log!("Assigns {} deinit", this); } diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs index 565198648b0b..7e83da0cb06b 100644 --- a/src/runtime/shell/states/Expansion.rs +++ b/src/runtime/shell/states/Expansion.rs @@ -9,10 +9,11 @@ use crate::shell::interpreter::{ EventLoopHandle, Interpreter, Node, NodeId, ShellExecEnv, ShellExecEnvKind, StateKind, log, }; use crate::shell::io::{IO, OutKind}; +use crate::shell::states::assigns::AssignCtx; use crate::shell::states::base::Base; use crate::shell::states::script::Script; use crate::shell::yield_::Yield; -use crate::shell::{ExitCode, ShellErr}; +use crate::shell::{EnvStr, ExitCode, ShellErr}; pub(crate) struct Expansion { pub(crate) base: Base, @@ -49,6 +50,7 @@ pub(crate) struct Expansion { /// [`ExpansionOut`] (both → `buf=[], bounds=[]`) and Cmd would push an /// empty arg for unset vars — diverging from POSIX field-splitting. pub(crate) has_quoted_empty: bool, + earlier_fields: EarlierFields, /// Exit code of a sole-command-substitution arg — propagated to `Cmd` /// so `$(false)` as argv0 fails. pub(crate) out_exit_code: ExitCode, @@ -59,7 +61,8 @@ pub enum ExpansionState { #[default] Idle, Walking, - CmdSubst, + /// With the words that are open, when the `$(...)` is in the word of a `${NAMEword}`. + CmdSubst(Option>), Glob, BraceExpand, Done, @@ -68,6 +71,40 @@ pub enum ExpansionState { Err(Box), } +const _: () = assert!(size_of::() == 168); + +/// What became of the fields of the word that ended before the one in `current_out`. +#[derive(Clone, Copy, PartialEq, Eq)] +enum EarlierFields { + /// No field ended: a leading `~` is for the field in `current_out`. + None, + /// Each had no bytes and no `""`, so that `out` has no field. + Empty, + /// A field is in `out`. `has_quoted_empty` is then of the field in `current_out`. + InOut, +} + +/// The word of a `${NAMEword}` while it expands. +struct WordFrame { + param: &'static ast::ParamExp, + /// The next atom of the word. + idx: u32, + /// Where the word starts in `current_out`. + start: u32, + /// Where the word starts in `meta_offsets`. + meta_start: u32, + /// Whitespace and a `$(...)` in the word end no argument. + one_string: bool, + /// `has_quoted_empty` before the word. + had_quoted_empty: bool, +} + +/// The words that are open: a word in the word of another form puts that one in `outer`. +pub struct WordFrames { + top: WordFrame, + outer: Vec, +} + #[derive(Default)] pub(crate) struct ExpansionOut { pub(crate) buf: Vec, @@ -104,6 +141,7 @@ impl Expansion { cmd_subst_quoted: false, assign_ctx, has_quoted_empty: false, + earlier_fields: EarlierFields::None, out_exit_code: 0, })) } @@ -126,7 +164,7 @@ impl Expansion { me.state = ExpansionState::Walking; continue; } - ExpansionState::CmdSubst | ExpansionState::Glob => { + ExpansionState::CmdSubst(_) | ExpansionState::Glob => { // Re-entered while a child is in flight. return Yield::suspended(); } @@ -163,7 +201,7 @@ impl Expansion { ast::Atom::Compound(c) => &c.atoms[me.word_idx as usize], }; let shell = me.base.shell(); - let is_cmd_subst = interp.vm_args_utf8.with_mut(|vm_args_utf8| { + let is_deferred = interp.vm_args_utf8.with_mut(|vm_args_utf8| { Self::expand_simple_no_io( shell, simple, @@ -176,69 +214,21 @@ impl Expansion { vm_args_utf8, ) }); - if !is_cmd_subst { + if !is_deferred { me.word_idx += 1; continue; } - // ── Command substitution: spawn a Script with stdout piped - // into a fresh owned buffer. - let ast::SimpleAtom::CmdSubst(sub) = simple else { - unreachable!() - }; - let quoted = sub.quoted; - me.state = ExpansionState::CmdSubst; - me.cmd_subst_quoted = quoted; - - let io = IO { - stdin: interp.root_io().stdin.clone(), - stdout: OutKind::Pipe, - stderr: interp.root_io().stderr.clone(), - }; - let duped = match me - .base - .shell_mut() - .dupe_for_subshell(&io, ShellExecEnvKind::CmdSubst) - { - Ok(d) => d, - Err(e) => { - drop(io); - interp.throw(ShellErr::new_sys(&e)); - return Yield::Failed(this); + return match simple { + ast::SimpleAtom::CmdSubst(sub) => { + Self::start_cmd_subst(interp, this, sub, None) } + _ => Self::expand_param_atom(interp, this), }; - let script = Script::init(interp, duped, &sub.script, this, io); - interp.as_expansion_mut(this).child_script = Some(script); - return Script::start(interp, script); } // All sub-atoms expanded — post-process leading tilde then finish. - if leading_tilde { - let home = me.base.shell().get_homedir(); - let len_before = me.current_out.len(); - match me.current_out.first() { - Some(b'/') | Some(b'\\') => { - me.current_out.splice(0..0, home.slice().iter().copied()); - } - Some(_) => me.current_out.insert(0, b'~'), - // `~""` expands to $HOME, - // but `~$unset` expands to nothing (word is dropped). - None if me.has_quoted_empty => { - me.current_out.extend_from_slice(home.slice()); - } - None => {} - } - // The first two arms prepend; shift the recorded brace - // metacharacter offsets so they keep pointing at the same - // bytes. The `extend_from_slice` arm only runs when - // `current_out` (and therefore `meta_offsets`) is - // empty, so the shift is a no-op there. - let prepended = (me.current_out.len() - len_before) as u32; - if prepended != 0 { - for off in &mut me.meta_offsets { - *off += prepended; - } - } - home.deref(); + if leading_tilde && me.earlier_fields == EarlierFields::None { + Self::apply_leading_tilde(me); } // Brace expansion // first, then glob, else flush as a single word. @@ -261,11 +251,76 @@ impl Expansion { interp.child_done(parent, this, exit) } + /// Starts the `Script` of a `$(...)`, with its stdout piped into a buffer that `child_done` reads. + #[inline(always)] // `next` runs this for each `$(...)`, and `walk_words` is a second caller. + fn start_cmd_subst( + interp: &Interpreter, + this: NodeId, + sub: &ast::CmdSubst, + words: Option>, + ) -> Yield { + let me = interp.as_expansion_mut(this); + me.state = ExpansionState::CmdSubst(words); + me.cmd_subst_quoted = sub.quoted; + + let io = IO { + stdin: interp.root_io().stdin.clone(), + stdout: OutKind::Pipe, + stderr: interp.root_io().stderr.clone(), + }; + let duped = match me + .base + .shell_mut() + .dupe_for_subshell(&io, ShellExecEnvKind::CmdSubst) + { + Ok(d) => d, + Err(e) => { + drop(io); + interp.throw(ShellErr::new_sys(&e)); + return Yield::Failed(this); + } + }; + let script = Script::init(interp, duped, &sub.script, this, io); + interp.as_expansion_mut(this).child_script = Some(script); + Script::start(interp, script) + } + + /// Puts the leading `~`, which the walk left out, in front of the first field. + #[inline(always)] // `next` runs this for each word with a leading `~`, and `end_field` is a second caller. + fn apply_leading_tilde(me: &mut Expansion) { + let home = me.base.shell().get_homedir(); + let len_before = me.current_out.len(); + match me.current_out.first() { + Some(b'/') | Some(b'\\') => { + me.current_out.splice(0..0, home.slice().iter().copied()); + } + Some(_) => me.current_out.insert(0, b'~'), + // `~""` expands to $HOME, but `~$unset` expands to nothing (word is dropped). + None if me.has_quoted_empty => { + me.current_out.extend_from_slice(home.slice()); + } + None => {} + } + // The first two arms prepend: shift the recorded metacharacter offsets to the same bytes. + let prepended = (me.current_out.len() - len_before) as u32; + if prepended != 0 { + for off in &mut me.meta_offsets { + *off += prepended; + } + } + home.deref(); + } + /// Re-tokenize `current_out` (the /// fully-expanded word with `{`/`,`/`}` markers preserved by /// `expand_simple_no_io`) and push each variant as a separate argv word. fn do_brace_expand(me: &mut Expansion) { use bun_shell_parser::braces; + if me.current_out.is_empty() { + Self::push_current_out(me); + me.state = ExpansionState::Done; + return; + } // Only the `{`/`,`/`}` bytes recorded in `meta_offsets` (written // by literal BraceBegin/Comma/BraceEnd atoms) are brace-expansion // metacharacters. Bytes from Text/Var/cmd-subst expansion — notably JS @@ -333,15 +388,12 @@ impl Expansion { // Push each variant as its own word; word boundaries are recorded // via `bounds`. for s in expanded { - if !me.out.buf.is_empty() { - me.out.bounds.push(me.out.buf.len() as u32); - } - me.out.buf.extend_from_slice(&s); + Self::push_variant(me, &s); } let node = me.node; let atom = node.get(); - if atom.has_glob_expansion() { + if atom.has_glob_expansion() && Self::has_glob_syntax(me) { // brace + glob composes. Keep // `current_out` (the original pattern, e.g. `src/*.{ts,tsx}`) so // the glob walker brace-expands and globs it; its matches are @@ -421,6 +473,13 @@ impl Expansion { pattern } + /// Whether the field in `current_out` has a `*` of the script. The hint of the atom is for the whole word. + fn has_glob_syntax(me: &Expansion) -> bool { + me.meta_offsets + .iter() + .any(|&offset| me.current_out.get(offset as usize) == Some(&b'*')) + } + /// Kick off an off-thread /// glob walk for the assembled pattern in `current_out`. fn transition_to_glob_state(interp: &Interpreter, this: NodeId) -> Yield { @@ -429,6 +488,11 @@ impl Expansion { let cwd: Vec; { let me = interp.as_expansion_mut(this); + if !Self::has_glob_syntax(me) { + Self::push_current_out(me); + me.state = ExpansionState::Done; + return Yield::Next(this); + } me.state = ExpansionState::Glob; pattern = Self::neutralize_glob_metachars(&me.current_out, &me.meta_offsets); cwd = me.base.shell().cwd().to_vec(); @@ -455,7 +519,8 @@ impl Expansion { /// Appends the no-IO expansion of /// one [`ast::SimpleAtom`] to `out`. Returns `true` for `CmdSubst` so the - /// caller spawns a `Script` for it. + /// caller spawns a `Script` for it, and for a `Param`, which goes to `expand_param_atom`. + #[inline(always)] fn expand_simple_no_io( shell: &ShellExecEnv, atom: &ast::SimpleAtom, @@ -467,7 +532,6 @@ impl Expansion { command_ctx: *mut bun_options_types::context::ContextData, vm_args_utf8: &mut Vec>, ) -> bool { - use crate::shell::env_str::EnvStr; match atom { ast::SimpleAtom::Text(txt) => out.extend_from_slice(txt), ast::SimpleAtom::QuotedEmpty => { @@ -492,6 +556,7 @@ impl Expansion { // SAFETY: `command_ctx` is the live VM ctx; `vm_args_utf8` borrows it. Interpreter::append_var_argv(out, *int, event_loop, command_ctx, vm_args_utf8); } + ast::SimpleAtom::Param(_) | ast::SimpleAtom::ParamSpace(_) => return true, ast::SimpleAtom::Asterisk => { meta_offsets.push(out.len() as u32); out.push(b'*'); @@ -529,18 +594,254 @@ impl Expansion { false } + /// Expands the `Param` at `word_idx`. + #[cold] + fn expand_param_atom(interp: &Interpreter, this: NodeId) -> Yield { + let me = interp.as_expansion_mut(this); + let node = me.node; + let param = match node.get() { + ast::Atom::Simple(ast::SimpleAtom::Param(param)) => *param, + ast::Atom::Compound(compound) => match compound.atoms[me.word_idx as usize] { + ast::SimpleAtom::Param(param) => param, + _ => unreachable!("expand_simple_no_io leaves a Param"), + }, + ast::Atom::Simple(_) => unreachable!("expand_simple_no_io leaves a Param"), + }; + // An assignment value and a `[[ ]]` operand are one string. + let one_string = me.assign_ctx || matches!(interp.node(me.base.parent), Node::CondExpr(_)); + match Self::open_word(interp, me, param, one_string) { + Some(top) => Self::walk_words( + interp, + this, + WordFrames { + top, + outer: Vec::new(), + }, + ), + None => { + me.word_idx += 1; + Yield::Next(this) + } + } + } + + /// Appends the value of the parameter when that is the result. Else the word is the result: gives its frame. + fn open_word( + interp: &Interpreter, + me: &mut Expansion, + param: &'static ast::ParamExp, + one_string: bool, + ) -> Option { + let len = me.current_out.len(); + let is_set = match param.target { + ast::ParamTarget::Name(name) => { + let shell = me.base.shell(); + let value = shell + .shell_env + .get(EnvStr::init_slice(name)) + .or_else(|| shell.export_env.get(EnvStr::init_slice(name))); + match value { + Some(value) => { + me.current_out.extend_from_slice(value.slice()); + value.deref(); + true + } + None => false, + } + } + ast::ParamTarget::Positional(int) => interp.vm_args_utf8.with_mut(|vm_args_utf8| { + Interpreter::append_var_argv( + &mut me.current_out, + int, + interp.event_loop, + interp.command_ctx, + vm_args_utf8, + ) + }), + }; + let is_absent = param.op.is_absent(is_set, me.current_out.len() == len); + let is_alternate = param.op.action == ast::ParamAction::Alternate; + if is_absent == is_alternate { + // `+` gives nothing for an absent parameter. The other operators give the value of a present one. + if is_alternate { + me.current_out.truncate(len); + } + return None; + } + me.current_out.truncate(len); + Some(WordFrame { + param, + idx: 0, + start: len as u32, + meta_start: me.meta_offsets.len() as u32, + one_string: one_string + || matches!( + param.op.action, + ast::ParamAction::Assign | ast::ParamAction::Error + ), + had_quoted_empty: me.has_quoted_empty, + }) + } + + /// Expands the open words, up to the end of the outermost one or to a `$(...)`, which `child_done` resumes from. + #[cold] + fn walk_words(interp: &Interpreter, this: NodeId, mut words: WordFrames) -> Yield { + loop { + let me = interp.as_expansion_mut(this); + let Some(atom) = words.top.param.word.get(words.top.idx as usize) else { + if !Self::close_word(me, &words.top) { + return Yield::Next(this); + } + let Some(outer) = words.outer.pop() else { + me.word_idx += 1; + return Yield::Next(this); + }; + words.top = outer; + words.top.idx += 1; + continue; + }; + match atom { + ast::SimpleAtom::Param(param) => { + if let Some(inner) = Self::open_word(interp, me, param, words.top.one_string) { + words.outer.push(core::mem::replace(&mut words.top, inner)); + continue; + } + } + ast::SimpleAtom::ParamSpace(space) => { + if words.top.one_string { + me.current_out.extend_from_slice(space); + } else { + Self::end_field(me); + } + } + ast::SimpleAtom::CmdSubst(sub) => { + return Self::start_cmd_subst(interp, this, sub, Some(Box::new(words))); + } + _ => { + let shell = me.base.shell(); + let _ = interp.vm_args_utf8.with_mut(|vm_args_utf8| { + Self::expand_simple_no_io( + shell, + atom, + &mut me.current_out, + &mut me.meta_offsets, + &mut me.has_quoted_empty, + true, + interp.event_loop, + interp.command_ctx, + vm_args_utf8, + ) + }); + } + } + words.top.idx += 1; + } + } + + /// Ends a word. The word of a `=` is assigned and the word of a `?` is the error. `false`: `state` is an error. + fn close_word(me: &mut Expansion, frame: &WordFrame) -> bool { + if !matches!( + frame.param.op.action, + ast::ParamAction::Assign | ast::ParamAction::Error + ) { + return true; + } + // The word is a value here: a `*` or a `""` in it is not syntax of the argument. + let word = me.current_out.split_off(frame.start as usize); + me.meta_offsets.truncate(frame.meta_start as usize); + me.has_quoted_empty = frame.had_quoted_empty; + + use std::io::Write as _; + let mut message: Vec = Vec::new(); + match (frame.param.op.action, frame.param.target) { + (ast::ParamAction::Assign, ast::ParamTarget::Name(name)) => { + me.current_out.extend_from_slice(&word); + let value = EnvStr::init_ref_counted(word.into_boxed_slice()); + me.base + .shell_mut() + .assign_var(EnvStr::init_slice(name), value, AssignCtx::Shell); + value.deref(); + return true; + } + (ast::ParamAction::Assign, ast::ParamTarget::Positional(int)) => { + // A write to a `Vec` cannot fail. + let _ = write!(message, "${int}: cannot assign in this way"); + } + (_, target) => { + match target { + ast::ParamTarget::Name(name) => message.extend_from_slice(name), + ast::ParamTarget::Positional(int) => { + let _ = write!(message, "{int}"); + } + } + message.extend_from_slice(b": "); + if !word.is_empty() { + message.extend_from_slice(&word); + } else if frame.param.op.colon { + message.extend_from_slice(b"parameter null or not set"); + } else { + message.extend_from_slice(b"parameter not set"); + } + } + } + me.state = ExpansionState::Err(Box::new(ShellErr::Custom(message.into_boxed_slice()))); + false + } + + /// Ends the field in `current_out` before the end of the walk. With no bytes and no `""`, it is no argument. + fn end_field(me: &mut Expansion) { + if me.earlier_fields == EarlierFields::None { + me.earlier_fields = EarlierFields::Empty; + let node = me.node; + if matches!(node.get(), ast::Atom::Compound(c) + if c.atoms.first().is_some_and(|a| matches!(a, ast::SimpleAtom::Tilde))) + { + Self::apply_leading_tilde(me); + } + } + if me.current_out.is_empty() && !me.has_quoted_empty { + return; + } + Self::push_current_out(me); + me.has_quoted_empty = false; + me.earlier_fields = EarlierFields::InOut; + } + /// Flush `current_out` into `out` /// as the next argv word. The word boundary is recorded as the *previous* /// end-offset so the consumer's `[prev..bound]` slicing reconstructs each /// word and the trailing `[prev..]` slice yields the final one. fn push_current_out(me: &mut Expansion) { - if !me.out.buf.is_empty() { - me.out.bounds.push(me.out.buf.len() as u32); + if me.earlier_fields == EarlierFields::InOut && !Self::bound_next_field(me) { + return; } me.out.buf.append(&mut me.current_out); me.meta_offsets.clear(); } + /// Records where the field in `current_out` starts in `out`. `false`: with no bytes and no `""`, it is no argument. + #[cold] + fn bound_next_field(me: &mut Expansion) -> bool { + if me.current_out.is_empty() && !me.has_quoted_empty { + // `take_out` reports the `""` of a field that is alone in `out` and has no bytes. + me.has_quoted_empty = me.out.buf.is_empty() && me.out.bounds.is_empty(); + return false; + } + me.out.bounds.push(me.out.buf.len() as u32); + true + } + + /// Adds a result of brace expansion or of a glob to `out`. + fn push_variant(me: &mut Expansion, variant: &[u8]) { + if me.earlier_fields == EarlierFields::InOut { + me.out.bounds.push(me.out.buf.len() as u32); + } + me.out.buf.extend_from_slice(variant); + if !me.out.buf.is_empty() { + me.earlier_fields = EarlierFields::InOut; + } + } + /// Newlines→spaces, trim, then split on whitespace runs into separate /// argv words. fn post_subshell_expansion(me: &mut Expansion, mut stdout: Vec) { @@ -583,7 +884,7 @@ impl Expansion { if c == b' ' { prev_ws = true; me.current_out.extend_from_slice(&s[a..i]); - Self::push_current_out(me); + Self::end_field(me); } } me.current_out.extend_from_slice(&s[a..]); @@ -634,9 +935,11 @@ impl Expansion { let no_split = { let me = interp.as_expansion(this); - me.cmd_subst_quoted || me.assign_ctx + me.cmd_subst_quoted + || me.assign_ctx + || matches!(&me.state, ExpansionState::CmdSubst(Some(words)) if words.top.one_string) }; - { + let words = { let me = interp.as_expansion_mut(this); if exit_code != 0 && sole_cmd_subst { me.out_exit_code = exit_code; @@ -654,12 +957,23 @@ impl Expansion { } else { Self::post_subshell_expansion(me, stdout); } - me.word_idx += 1; - me.state = ExpansionState::Walking; me.child_script = None; - } + match core::mem::replace(&mut me.state, ExpansionState::Walking) { + ExpansionState::CmdSubst(Some(words)) => Some(words), + _ => { + me.word_idx += 1; + None + } + } + }; interp.deinit_node(child); - Yield::Next(this) + match words { + Some(mut words) => { + words.top.idx += 1; + Self::walk_words(interp, this, *words) + } + None => Yield::Next(this), + } } /// Main-thread re-entry for the @@ -718,10 +1032,7 @@ impl Expansion { // Push each match as its own argv word. The // walker arena owns the strings, so they were `to_vec`'d already. for entry in result { - if !me.out.buf.is_empty() { - me.out.bounds.push(me.out.buf.len() as u32); - } - me.out.buf.extend_from_slice(&entry); + Self::push_variant(me, &entry); } me.state = ExpansionState::Done; } diff --git a/src/shell_parser/json_fmt.rs b/src/shell_parser/json_fmt.rs index acf004f6d88b..a429ea1864c9 100644 --- a/src/shell_parser/json_fmt.rs +++ b/src/shell_parser/json_fmt.rs @@ -17,7 +17,8 @@ use core::fmt::{self, Write}; use super::parse::SmolList; use super::parse::ast::{ Assign, Atom, Binary, BinaryOp, Cmd, CmdSubst, CompoundAtom, CondExpr, Expr, If, JSBuf, - Pipeline, PipelineItem, Redirect, RedirectFlags, Script, SimpleAtom, Stmt, Subshell, + ParamExp, ParamTarget, Pipeline, PipelineItem, Redirect, RedirectFlags, Script, SimpleAtom, + Stmt, Subshell, }; // ───────────────────────────── primitives ───────────────────────────── @@ -267,6 +268,14 @@ fn write_simple_atom(w: &mut impl Write, s: &SimpleAtom<'_>) -> fmt::Result { SimpleAtom::VarArgv(n) => { write!(w, "{{\"VarArgv\":{}", n)?; } + SimpleAtom::Param(p) => { + w.write_str("{\"param\":")?; + write_param(w, p)?; + } + SimpleAtom::ParamSpace(t) => { + w.write_str("{\"param_space\":")?; + encode_json_string(w, t)?; + } SimpleAtom::Text(t) => { w.write_str("{\"Text\":")?; encode_json_string(w, t)?; @@ -286,6 +295,19 @@ fn write_simple_atom(w: &mut impl Write, s: &SimpleAtom<'_>) -> fmt::Result { w.write_char('}') } +fn write_param(w: &mut impl Write, p: &ParamExp<'_>) -> fmt::Result { + match p.target { + ParamTarget::Name(name) => { + w.write_str("{\"name\":")?; + encode_json_string(w, name)?; + } + ParamTarget::Positional(n) => write!(w, "{{\"argv\":{}", n)?, + } + write!(w, ",\"op\":\"{}\",\"word\":", p.op.as_str())?; + write_array(w, p.word, write_simple_atom)?; + w.write_char('}') +} + fn write_cmd_subst(w: &mut impl Write, c: &CmdSubst<'_>) -> fmt::Result { w.write_str("{\"script\":")?; write_script(w, &c.script)?; diff --git a/src/shell_parser/lib.rs b/src/shell_parser/lib.rs index 5034c759041a..c31d7365aec4 100644 --- a/src/shell_parser/lib.rs +++ b/src/shell_parser/lib.rs @@ -17,6 +17,6 @@ pub mod parse; pub mod json_fmt; pub use parse::{ - LexResult, LexerError, ParseError, Parser, ast, escape_8bit, escape_bun_str, - needs_escape_bunstr, needs_escape_utf8_ascii_latin1, + LexResult, LexerError, ParseError, Parser, ast, ends_in_param_head, escape_8bit, + escape_bun_str, lex_script, needs_escape_bunstr, needs_escape_utf8_ascii_latin1, parse_script, }; diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs index 534aba820824..97bd6b40c9f4 100644 --- a/src/shell_parser/parse.rs +++ b/src/shell_parser/parse.rs @@ -660,10 +660,85 @@ pub mod ast { } } + /// What the operator of a `${NAMEword}` does with the word. See [`ParamOp::is_absent`]. + #[derive(Clone, Copy, PartialEq, Eq)] + pub enum ParamAction { + /// `-`: the word is the result when the parameter is absent. + Default, + /// `+`: the word is the result when the parameter is present. When it is absent the result is nothing. + Alternate, + /// `=`: when the parameter is absent, the word is assigned to it and is the result. + Assign, + /// `?`: when the parameter is absent, the command fails and the word is the message. + Error, + } + + /// The operator of a `${NAMEword}`. + #[derive(Clone, Copy, PartialEq, Eq)] + pub struct ParamOp { + pub action: ParamAction, + /// The operator starts with `:`, so an empty value counts as absent. + pub colon: bool, + } + + impl ParamOp { + /// Reads an operator at the start of `src`. Returns it with its length. + pub(crate) fn parse(src: &[u8]) -> Option<(ParamOp, usize)> { + let (colon, at) = match src.first() { + Some(b':') => (true, 1), + _ => (false, 0), + }; + let action = match src.get(at)? { + b'-' => ParamAction::Default, + b'+' => ParamAction::Alternate, + b'=' => ParamAction::Assign, + b'?' => ParamAction::Error, + _ => return None, + }; + Some((ParamOp { action, colon }, at + 1)) + } + + pub const fn as_str(self) -> &'static str { + match (self.action, self.colon) { + (ParamAction::Default, true) => ":-", + (ParamAction::Default, false) => "-", + (ParamAction::Alternate, true) => ":+", + (ParamAction::Alternate, false) => "+", + (ParamAction::Assign, true) => ":=", + (ParamAction::Assign, false) => "=", + (ParamAction::Error, true) => ":?", + (ParamAction::Error, false) => "?", + } + } + + /// Whether a parameter that is `set`, with an `empty` value, counts as absent. + pub const fn is_absent(self, set: bool, empty: bool) -> bool { + !set || (self.colon && empty) + } + } + + /// The parameter of a `${NAMEword}`. + #[derive(Clone, Copy)] + pub enum ParamTarget<'arena> { + Name(&'arena [u8]), + /// `$0`..`$9` + Positional(u8), + } + + /// `${NAMEword}` + pub struct ParamExp<'arena> { + pub target: ParamTarget<'arena>, + pub op: ParamOp, + pub word: &'arena [SimpleAtom<'arena>], + } + #[derive(Clone)] pub enum SimpleAtom<'arena> { Var(&'arena [u8]), VarArgv(u8), + Param(&'arena ParamExp<'arena>), + /// Unquoted whitespace in the word of a `Param`. It ends an argument where arguments are split. + ParamSpace(&'arena [u8]), Text(&'arena [u8]), /// An empty string from a quoted context (e.g. "", '', or ${''}). Preserved as an /// explicit empty argument during expansion, unlike unquoted empty text which is dropped. @@ -693,8 +768,12 @@ pub mod ast { pub struct CompoundAtom<'arena> { pub atoms: &'arena [SimpleAtom<'arena>], pub(crate) brace_expansion_hint: bool, + /// Also set for a `*` in the word of a `Param`, which is a glob only when that word is the result. pub(crate) glob_hint: bool, } + + const _: () = assert!(size_of::>() == 24); + const _: () = assert!(size_of::>() == 32); } // ───────────────────────────── Parser ───────────────────────────── @@ -1623,6 +1702,23 @@ impl<'bump> Parser<'bump> { } } } + Token::ParamBegin(_) => { + let (param, has_glob) = self.parse_param()?; + has_glob_syntax |= has_glob; + atoms.push(ast::SimpleAtom::Param(param)); + if self.delimits(self.peek()) { + let _ = self.r#match(TokenTag::Delimit); + // The hint is a field of a compound atom, so this lone `Param` is one. + if has_glob && atoms.len() == 1 { + return Ok(Some(ast::Atom::Compound(ast::CompoundAtom { + atoms: atoms.into_bump_slice(), + brace_expansion_hint: false, + glob_hint: true, + }))); + } + break; + } + } Token::OpenParen | Token::CloseParen => { self.add_error(format_args!( "Unexpected token: `{}`", @@ -1643,6 +1739,8 @@ impl<'bump> Parser<'bump> { | Token::Newline | Token::CmdSubstQuoted | Token::CmdSubstEnd + | Token::ParamSpace(_) + | Token::ParamEnd | Token::JSObjRef(_) | Token::Delimit | Token::Eof @@ -1663,6 +1761,122 @@ impl<'bump> Parser<'bump> { }) } + /// A `ParamBegin`, its parameter, the tokens of the word and the `ParamEnd`. Also: whether the word has a `*`. + #[cold] + fn parse_param(&mut self) -> ParseResult<(&'bump ast::ParamExp<'bump>, bool)> { + if !self.stack_check.is_safe_to_recurse() { + self.add_error(format_args!("Nesting depth exceeded"))?; + return Err(ParseError::NestingTooDeep.into()); + } + let Token::ParamBegin(op) = self.advance() else { + unreachable!() + }; + let target = match self.advance() { + Token::Var(range) => ast::ParamTarget::Name(self.text(range)), + Token::VarArgv(int) => ast::ParamTarget::Positional(int), + _ => unreachable!("the lexer puts the parameter after a ParamBegin"), + }; + + let mut atoms = bun_alloc::ArenaVec::new_in(self.alloc); + let mut has_glob = false; + loop { + let peeked = self.peek(); + match peeked { + Token::ParamEnd => { + let _ = self.advance(); + break; + } + Token::SingleQuotedText(range) + | Token::DoubleQuotedText(range) + | Token::Text(range) => { + let _ = self.advance(); + let txt = self.text(range); + // As in sh, a `~` is `$HOME` only when it is the whole word or a `/` follows it. + let is_tilde_prefix = atoms.is_empty() + && peeked.tag() == TokenTag::Text + && (txt.starts_with(b"~/") + || (txt == b"~" && self.peek().tag() == TokenTag::ParamEnd)); + if txt.is_empty() { + atoms.push(ast::SimpleAtom::QuotedEmpty); + } else if is_tilde_prefix { + atoms.push(ast::SimpleAtom::Tilde); + if txt.len() > 1 { + atoms.push(ast::SimpleAtom::Text(&txt[1..])); + } + } else { + atoms.push(ast::SimpleAtom::Text(txt)); + } + } + Token::Var(range) => { + let _ = self.advance(); + atoms.push(ast::SimpleAtom::Var(self.text(range))); + } + Token::VarArgv(int) => { + let _ = self.advance(); + atoms.push(ast::SimpleAtom::VarArgv(int)); + } + Token::ParamSpace(range) => { + let _ = self.advance(); + atoms.push(ast::SimpleAtom::ParamSpace(self.text(range))); + } + Token::Asterisk => { + let _ = self.advance(); + has_glob = true; + atoms.push(ast::SimpleAtom::Asterisk); + } + Token::DoubleAsterisk => { + let _ = self.advance(); + has_glob = true; + atoms.push(ast::SimpleAtom::DoubleAsterisk); + } + Token::CmdSubstBegin => { + let _ = self.advance(); + let is_quoted = self.r#match(TokenTag::CmdSubstQuoted); + let mut subparser = self.make_subparser(SubshellKind::CmdSubst); + let script = subparser.parse_impl(); + self.continue_from_subparser(&mut subparser); + atoms.push(ast::SimpleAtom::CmdSubst(ast::CmdSubst { + script: script?, + quoted: is_quoted, + })); + } + Token::ParamBegin(_) => { + let (inner, inner_has_glob) = self.parse_param()?; + has_glob |= inner_has_glob; + atoms.push(ast::SimpleAtom::Param(inner)); + } + Token::Pipe + | Token::DoublePipe + | Token::Ampersand + | Token::DoubleAmpersand + | Token::Redirect(_) + | Token::Semicolon + | Token::Newline + | Token::BraceBegin + | Token::Comma + | Token::BraceEnd + | Token::CmdSubstQuoted + | Token::CmdSubstEnd + | Token::OpenParen + | Token::CloseParen + | Token::JSObjRef(_) + | Token::DoubleBracketOpen + | Token::DoubleBracketClose + | Token::Delimit + | Token::Eof => { + unreachable!("the lexer puts no such token in the word of a ParamBegin") + } + } + } + + let param = self.allocate(ast::ParamExp { + target, + op, + word: atoms.into_bump_slice(), + }); + Ok((param, has_glob)) + } + fn allocate(&self, val: T) -> &'bump mut T { self.alloc.alloc(val) } @@ -1686,6 +1900,8 @@ impl<'bump> Parser<'bump> { IfClauseTok::from_text(self.text(range)) } + // `parse_atom` runs these three for each word: the inliner leaves calls to them in the arms it rates cold. + #[inline(always)] fn advance(&mut self) -> Token { if !self.is_at_end() { self.current += 1; @@ -1693,6 +1909,7 @@ impl<'bump> Parser<'bump> { self.prev() } + #[inline(always)] fn is_at_end(&self) -> bool { self.peek().tag() == TokenTag::Eof || (self.inside_subshell.is_some() @@ -1765,6 +1982,7 @@ impl<'bump> Parser<'bump> { } /// Consumes token if it matches + #[inline(always)] fn r#match(&mut self, toktag: TokenTag) -> bool { if self.peek().tag() == toktag { let _ = self.advance(); @@ -1971,6 +2189,9 @@ pub enum TokenTag { CloseParen, Var, VarArgv, + ParamBegin, + ParamSpace, + ParamEnd, Text, SingleQuotedText, DoubleQuotedText, @@ -2022,6 +2243,11 @@ pub enum Token { Var(TextRange), VarArgv(u8), + /// `${NAME`: the next token is the parameter (`Var` or `VarArgv`), then the word up to its `ParamEnd`. + ParamBegin(ast::ParamOp), + /// A run of unquoted whitespace in the word of a `ParamBegin`. + ParamSpace(TextRange), + ParamEnd, Text(TextRange), /// Quotation information is lost from the lexer -> parser stage and it is /// helpful to disambiguate from regular text and quoted text @@ -2036,6 +2262,8 @@ pub enum Token { Eof, } +const _: () = assert!(size_of::() == 12); + #[derive(Clone, Copy)] pub struct TextRange { pub start: u32, @@ -2080,6 +2308,9 @@ impl Token { Token::CloseParen => b"`)", Token::Var(r) => &strpool[r.start as usize..r.end as usize], Token::VarArgv(n) => VARARGV_STRINGS[n as usize], + Token::ParamBegin(_) => b"`${`", + Token::ParamSpace(r) => &strpool[r.start as usize..r.end as usize], + Token::ParamEnd => b"`}`", Token::Text(r) => &strpool[r.start as usize..r.end as usize], Token::SingleQuotedText(r) => &strpool[r.start as usize..r.end as usize], Token::DoubleQuotedText(r) => &strpool[r.start as usize..r.end as usize], @@ -2094,8 +2325,7 @@ impl Token { // ───────────────────────────── Lexer ───────────────────────────── -pub type LexerAscii<'bump> = Lexer<'bump, { StringEncoding::Ascii }>; -pub type LexerUnicode<'bump> = Lexer<'bump, { StringEncoding::Wtf8 }>; +const _: () = assert!(size_of::>() == 200); #[derive(Clone, Copy)] pub struct LexResult<'bump> { @@ -2118,6 +2348,111 @@ pub struct LexError { pub(crate) msg: TextRange, } +/// One pass of the lexer over a script. +struct LexPass<'bump> { + /// `Err`: the lexer stopped before the end of the script. + result: Result, LexerError>, + /// The pass read a `${NAME}` or a `${NAMEword}`, and no `${NAME:?word}` or `${NAME?word}`. + can_be_text: bool, +} + +#[inline] +fn lex_pass<'bump>( + bump: &'bump Bump, + src: &'bump [u8], + strings_to_escape: &'bump [BunString], + jsobjs_len: u32, + param_heads: bool, +) -> LexPass<'bump> { + #[inline] + fn run<'bump, const ENCODING: StringEncoding>( + bump: &'bump Bump, + src: &'bump [u8], + strings_to_escape: &'bump [BunString], + jsobjs_len: u32, + param_heads: bool, + ) -> LexPass<'bump> { + let mut lexer = Lexer::::new(bump, src, strings_to_escape, jsobjs_len); + lexer.param_heads = param_heads; + let result = lexer.lex(); + LexPass { + can_be_text: lexer.forms_read == FormsRead::NoGuard, + result: result.map(|()| lexer.get_result()), + } + } + if is_all_ascii(src) { + run::<{ StringEncoding::Ascii }>(bump, src, strings_to_escape, jsobjs_len, param_heads) + } else { + run::<{ StringEncoding::Wtf8 }>(bump, src, strings_to_escape, jsobjs_len, param_heads) + } +} + +/// Gives the tokens of `src` to `parse`. When that fails for a pass that `can_be_text`, every `${` is text in a second pass. +#[inline] +fn with_tokens<'bump, T, E>( + bump: &'bump Bump, + src: &'bump [u8], + strings_to_escape: &'bump [BunString], + jsobjs_len: u32, + mut parse: impl FnMut(Result, LexerError>) -> Result, +) -> Result { + let pass = lex_pass(bump, src, strings_to_escape, jsobjs_len, true); + match parse(pass.result) { + Err(_) if pass.can_be_text => { + parse(lex_pass(bump, src, strings_to_escape, jsobjs_len, false).result) + } + parsed => parsed, + } +} + +/// Lexes and parses `src`. On `Err`, `out_lex_result` holds the errors of the lexer or `out_parser` those of the parser. +pub fn parse_script<'bump, E>( + bump: &'bump Bump, + src: &'bump [u8], + strings_to_escape: &'bump [BunString], + jsobjs_len: u32, + out_parser: &mut Option>, + out_lex_result: &mut Option>, +) -> Result, E> +where + E: From + From + From, +{ + with_tokens(bump, src, strings_to_escape, jsobjs_len, |lexed| { + (*out_parser, *out_lex_result) = (None, None); + let lex_result = lexed?; + if !lex_result.errors.is_empty() { + *out_lex_result = Some(lex_result); + return Err(ParseError::Lex.into()); + } + Ok(out_parser.insert(Parser::new(bump, lex_result)).parse()?) + }) +} + +/// The tokens that `parse_script` parses `src` from, or its last tokens when they do not parse. +pub fn lex_script<'bump>( + bump: &'bump Bump, + src: &'bump [u8], + strings_to_escape: &'bump [BunString], + jsobjs_len: u32, +) -> Result, LexerError> { + let parsed = with_tokens( + bump, + src, + strings_to_escape, + jsobjs_len, + |lexed| match lexed { + Ok(lex_result) + if lex_result.errors.is_empty() + && Parser::new(bump, lex_result).parse().is_ok() => + { + Ok(lex_result) + } + failed => Err(failed), + }, + ); + parsed.or_else(|failed| failed) +} + /// A special char used to denote the beginning of a special token /// used for substituting JS variables into the script string. /// @@ -2129,6 +2464,11 @@ pub const LEX_JS_OBJREF_PREFIX: &[u8] = b"\x08__bun_"; pub const LEX_JS_STRING_PREFIX: &[u8] = b"\x08__bunstr_"; pub const LEX_JS_REF_TERMINATOR: u8 = SPECIAL_JS_CHAR; +/// `bytes` start after the first byte of `prefix`: whether the rest of `prefix` is next. +fn continues_js_ref_prefix(bytes: &[u8], prefix: &[u8]) -> bool { + prefix.len() <= bytes.len() && bytes[..prefix.len() - 1] == prefix[1..] +} + #[derive(Clone, Copy, PartialEq, Eq, core::marker::ConstParamTy)] pub enum StringEncoding { Ascii, @@ -2176,7 +2516,7 @@ struct BacktrackSnapshot<'bump, const ENCODING: StringEncoding> { delimit_quote: bool, } -pub struct Lexer<'bump, const ENCODING: StringEncoding> { +pub(crate) struct Lexer<'bump, const ENCODING: StringEncoding> { pub(crate) chars: ShellCharIter<'bump, ENCODING>, /// Tell us the beginning of a "word", indexes into the string pool (`buf`) @@ -2206,10 +2546,24 @@ pub struct Lexer<'bump, const ENCODING: StringEncoding> { /// Number of JS object references expected (for bounds validation) pub(crate) jsobjs_len: u32, + + /// `${NAME}` and `${NAMEword}` are read. When off, a `${` is text. + param_heads: bool, + forms_read: FormsRead, +} + +/// The `${NAME}` and `${NAMEword}` forms that a lexer read. +#[derive(Clone, Copy, PartialEq, Eq)] +enum FormsRead { + None, + /// One or more, and no `${NAME:?word}` or `${NAME?word}`. + NoGuard, + /// One or more, with a `${NAME:?word}` or a `${NAME?word}`. + Guard, } impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { - pub fn new( + fn new( bump: &'bump Bump, src: &'bump [u8], strings_to_escape: &'bump [BunString], @@ -2228,10 +2582,12 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { subshell_depth: 0, string_refs: strings_to_escape, jsobjs_len, + param_heads: true, + forms_read: FormsRead::None, } } - pub fn get_result(self) -> LexResult<'bump> { + fn get_result(self) -> LexResult<'bump> { LexResult { tokens: self.tokens.into_bump_slice(), strpool: self.strpool.into_bump_slice(), @@ -2273,6 +2629,8 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { delimit_quote: false, string_refs: self.string_refs, jsobjs_len: self.jsobjs_len, + param_heads: self.param_heads, + forms_read: self.forms_read, }; sublexer.chars.state = CharState::Normal; sublexer @@ -2293,6 +2651,7 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { self.word_start = sublexer.word_start; self.j = sublexer.j; self.delimit_quote = sublexer.delimit_quote; + self.forms_read = sublexer.forms_read; } fn make_snapshot(&self) -> BacktrackSnapshot<'bump, ENCODING> { @@ -2320,7 +2679,7 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { Some(self.tokens[self.tokens.len() - 1].tag()) } - pub fn lex(&mut self) -> Result<(), LexerError> { + fn lex(&mut self) -> Result<(), LexerError> { loop { // Fast path: bulk-consume runs of non-special bytes in Normal state. // The guard-arm chain below does not compile to a jump table in @@ -2630,40 +2989,7 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { if self.chars.state == CharState::Single { break 'escaped; } - - let peeked = self.peek().unwrap_or(InputChar { - char: 0, - escaped: false, - }); - if !peeked.escaped && peeked.char == u32::from(b'(') { - self.break_word(AddDelimiter::No)?; - self.eat_subshell(SubShellKind::Dollar)?; - fell_through = true; - break 'escaped; - } - - // Handle variable - self.break_word(AddDelimiter::No)?; - let var_tok = self.eat_var()?; - - match var_tok.len() { - 0 => { - self.append_char_to_str_pool(u32::from(b'$'))?; - self.break_word(AddDelimiter::No)?; - } - 1 => 'blk: { - let c = self.strpool[var_tok.start as usize]; - if c >= b'0' && c <= b'9' { - self.tokens.push(Token::VarArgv(c - b'0')); - break 'blk; - } - self.tokens.push(Token::Var(var_tok)); - } - _ => { - self.tokens.push(Token::Var(var_tok)); - } - } - self.word_start = self.j; + self.eat_dollar()?; fell_through = true; } c if c == u32::from(b'(') => { @@ -2960,6 +3286,7 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { && match self.tokens[self.tokens.len() - 1].tag() { TokenTag::Var | TokenTag::VarArgv + | TokenTag::ParamEnd | TokenTag::Text | TokenTag::SingleQuotedText | TokenTag::DoubleQuotedText @@ -2979,6 +3306,8 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { | TokenTag::Newline | TokenTag::CmdSubstBegin | TokenTag::CmdSubstQuoted + | TokenTag::ParamBegin + | TokenTag::ParamSpace | TokenTag::OpenParen | TokenTag::CloseParen | TokenTag::JSObjRef @@ -3128,10 +3457,10 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { } let prev_quote_state = self.chars.state; let mut sublexer = self.make_sublexer(kind); - sublexer.lex()?; + let lexed = sublexer.lex(); self.continue_from_sublexer(&mut sublexer); self.chars.state = prev_quote_state; - Ok(()) + lexed } fn append_string_to_str_pool(&mut self, bunstr: &BunString) -> Result<(), LexerError> { @@ -3207,19 +3536,11 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { } fn looks_like_js_obj_ref(&mut self) -> bool { - let bytes = self.chars.src_bytes_at_cursor(); - if LEX_JS_OBJREF_PREFIX.len() > bytes.len() { - return false; - } - bytes[..LEX_JS_OBJREF_PREFIX.len() - 1] == LEX_JS_OBJREF_PREFIX[1..] + continues_js_ref_prefix(self.chars.src_bytes_at_cursor(), LEX_JS_OBJREF_PREFIX) } fn looks_like_js_string_ref(&mut self) -> bool { - let bytes = self.chars.src_bytes_at_cursor(); - if LEX_JS_STRING_PREFIX.len() > bytes.len() { - return false; - } - bytes[..LEX_JS_STRING_PREFIX.len() - 1] == LEX_JS_STRING_PREFIX[1..] + continues_js_ref_prefix(self.chars.src_bytes_at_cursor(), LEX_JS_STRING_PREFIX) } fn bump_cursor_ascii( @@ -3384,6 +3705,239 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { true } + /// After a `$` outside single quotes. `true`: the `$` is text and a `{` follows it. + #[inline(always)] // `lex` runs this for each `$`, and `lex_param_word` is a second caller. + fn eat_dollar(&mut self) -> Result { + let peeked = self.peek().unwrap_or(InputChar { + char: 0, + escaped: false, + }); + if !peeked.escaped && peeked.char == u32::from(b'(') { + self.break_word(AddDelimiter::No)?; + self.eat_subshell(SubShellKind::Dollar)?; + return Ok(false); + } + + // Handle variable + self.break_word(AddDelimiter::No)?; + let var_tok = self.eat_var()?; + + let mut is_text_before_brace = false; + match var_tok.len() { + 0 => { + let at_brace = self.at_braced_param(); + if !(at_brace && self.eat_braced_param()?) { + is_text_before_brace = at_brace; + self.append_char_to_str_pool(u32::from(b'$'))?; + self.break_word(AddDelimiter::No)?; + } + } + 1 => 'blk: { + let c = self.strpool[var_tok.start as usize]; + if c >= b'0' && c <= b'9' { + self.tokens.push(Token::VarArgv(c - b'0')); + break 'blk; + } + self.tokens.push(Token::Var(var_tok)); + } + _ => { + self.tokens.push(Token::Var(var_tok)); + } + } + self.word_start = self.j; + Ok(is_text_before_brace) + } + + /// After a `$` that no name follows: whether the source can start a `${...}` form. + #[inline] + fn at_braced_param(&self) -> bool { + self.param_heads && self.chars.src_bytes_at_cursor().first() == Some(&b'{') + } + + /// At the `{` after a `$`: lexes a form. `false`: the source is no form and nothing was read, so the `$` is text. + #[cold] + #[inline(never)] + fn eat_braced_param(&mut self) -> Result { + let src = self.chars.src_bytes_at_cursor(); + let name_len = match src.get(1) { + Some(b'0'..=b'9') => 1, + Some(b'a'..=b'z' | b'A'..=b'Z' | b'_') => src[1..] + .iter() + .take_while(|b| b.is_ascii_alphanumeric() || **b == b'_') + .count(), + _ => return Ok(false), + }; + let name = &src[1..=name_len]; + let after_name = &src[1 + name_len..]; + let (op, head_len) = if after_name.first() == Some(&b'}') { + (None, name_len + 2) + } else if let Some((op, op_len)) = ast::ParamOp::parse(after_name) { + (Some(op), 1 + name_len + op_len) + } else { + return Ok(false); + }; + // Set before the depth error below: `parse_script` reads it when this pass fails. + if op.is_some_and(|op| op.action == ast::ParamAction::Error) { + self.forms_read = FormsRead::Guard; + } else if self.forms_read == FormsRead::None { + self.forms_read = FormsRead::NoGuard; + } + if op.is_some() && self.subshell_depth >= MAX_SUBSHELL_DEPTH { + self.add_error(b"Subshell nesting depth exceeded"); + return Err(LexerError::SubshellDepthExceeded); + } + + if let Some(op) = op { + self.tokens.push(Token::ParamBegin(op)); + } + if name[0].is_ascii_digit() { + self.tokens.push(Token::VarArgv(name[0] - b'0')); + } else { + let start = self.j; + self.strpool.extend_from_slice(name); + self.j += name_len as u32; + self.tokens + .push(Token::Var(TextRange { start, end: self.j })); + } + let after_head = self.chars.cursor_pos() + head_len; + self.bump_cursor_ascii(after_head, Some(src[head_len - 2]), src[head_len - 1]); + + if op.is_some() { + self.word_start = self.j; + self.subshell_depth += 1; + self.lex_param_word(self.chars.state == CharState::Double)?; + self.subshell_depth -= 1; + } + Ok(true) + } + + /// Lexes the word of a `${NAMEword}` up to the `}` that ends the form. `quoted`: the form is in double quotes. + #[cold] + fn lex_param_word(&mut self, quoted: bool) -> Result<(), LexerError> { + let base_state = self.chars.state; + let mut in_inner_quotes = false; + // How many `${` in the word are text, with a `}` that is text too and does not end the form. + let mut text_braces: u32 = 0; + loop { + let Some(input) = self.eat() else { + self.add_error(b"Unclosed parameter expansion"); + return Ok(()); + }; + let char = input.char; + if char == u32::from(SPECIAL_JS_CHAR) { + if self.looks_like_js_string_ref() { + if let Some(bunstr) = self.eat_js_string_ref() { + self.break_word(AddDelimiter::No)?; + self.handle_js_string_ref(bunstr)?; + continue; + } + } else if self.looks_like_js_obj_ref() { + self.add_error(b"JS object reference not allowed in a parameter expansion"); + return Ok(()); + } + } else if input.escaped { + // A backslash before a newline continues the line. + if char == u32::from(b'\n') { + continue; + } + } else { + let state = self.chars.state; + let ends_form = state == base_state && !in_inner_quotes; + match u8::try_from(char).unwrap_or(0) { + b'}' if ends_form && text_braces > 0 => text_braces -= 1, + b'}' if ends_form => { + self.break_word(AddDelimiter::No)?; + self.tokens.push(Token::ParamEnd); + return Ok(()); + } + // A `}` between a pair of `"` in a quoted form does not end the form. + b'"' if quoted => { + in_inner_quotes = !in_inner_quotes; + continue; + } + b'"' if state != CharState::Single => { + self.break_word(AddDelimiter::No)?; + self.chars.state = if state == CharState::Double { + CharState::Normal + } else { + CharState::Double + }; + continue; + } + b'\'' if !quoted && state != CharState::Double => { + self.break_word(AddDelimiter::No)?; + self.chars.state = if state == CharState::Single { + CharState::Normal + } else { + CharState::Single + }; + continue; + } + // In double quotes a backslash does not escape a `}`, but in a word it keeps the `}` in the word. + b'\\' if state == CharState::Double => { + if self.peek().is_some_and(|next| next.char == u32::from(b'}')) { + let _ = self.eat(); + self.append_char_to_str_pool(u32::from(b'}'))?; + continue; + } + } + b'$' if state != CharState::Single => { + text_braces += u32::from(self.eat_dollar()? && ends_form); + continue; + } + b'`' if state != CharState::Single => { + // The `` `...` `` that holds this form ends here, inside the word. + if self.in_subshell == Some(SubShellKind::Backtick) { + self.add_error(b"Unclosed parameter expansion"); + return Ok(()); + } + self.break_word(AddDelimiter::No)?; + self.eat_subshell(SubShellKind::Backtick)?; + continue; + } + b' ' | b'\t' | b'\n' if state == CharState::Normal => { + self.eat_param_space(char)?; + continue; + } + b'*' if state == CharState::Normal => { + self.break_word(AddDelimiter::No)?; + if self + .peek() + .is_some_and(|next| !next.escaped && next.char == u32::from(b'*')) + { + let _ = self.eat(); + self.tokens.push(Token::DoubleAsterisk); + } else { + self.tokens.push(Token::Asterisk); + } + continue; + } + _ => {} + } + } + self.append_char_to_str_pool(char)?; + } + } + + /// One token for a run of unquoted whitespace in the word of a `${NAMEword}`. `first` was just eaten. + fn eat_param_space(&mut self, first: u32) -> Result<(), LexerError> { + self.break_word(AddDelimiter::No)?; + let start = self.j; + self.append_char_to_str_pool(first)?; + while let Some(next) = self.peek() { + if next.escaped || !matches!(u8::try_from(next.char), Ok(b' ' | b'\t' | b'\n')) { + break; + } + let _ = self.eat(); + self.append_char_to_str_pool(next.char)?; + } + self.tokens + .push(Token::ParamSpace(TextRange { start, end: self.j })); + self.word_start = self.j; + Ok(()) + } + + #[inline(always)] // `eat_dollar` has two callers: the inliner leaves a call to this in each. fn eat_var(&mut self) -> Result { let start = self.j; let mut i: usize = 0; @@ -3772,6 +4326,38 @@ impl<'a, const ENCODING: StringEncoding> ShellCharIter<'a, ENCODING> { // ───────────────────────────── var-name / eq helpers ───────────────────────────── +/// Whether text after `src` would name the parameter of a `$NAME` or a `${...}`, choose its operator or start its word. +#[inline] // The `$` tag runs this for each interpolated value, from another crate. +pub fn ends_in_param_head(src: &[u8]) -> bool { + let Some(last) = src + .iter() + .rposition(|b| !(b.is_ascii_alphanumeric() || *b == b'_')) + else { + return false; + }; + match src[last] { + b'$' => true, + b'{' => src[..last].ends_with(b"$"), + b':' | b'-' | b'+' | b'=' | b'?' if last + 1 == src.len() => ends_in_param_operator(src), + _ => false, + } +} + +/// `src` ends in `:` or in an operator byte: whether a `${NAME` is before the operator. +#[cold] +fn ends_in_param_operator(src: &[u8]) -> bool { + let before_operator = match src { + [head @ .., b':', b'-' | b'+' | b'=' | b'?'] | [head @ .., _] => head, + [] => return false, + }; + let name_len = before_operator + .iter() + .rev() + .take_while(|b| b.is_ascii_alphanumeric() || **b == b'_') + .count(); + name_len > 0 && before_operator[..before_operator.len() - name_len].ends_with(b"${") +} + /// Only these characters allowed: /// - a-zA-Z /// - _ diff --git a/test/cli/run/run-shell.test.ts b/test/cli/run/run-shell.test.ts index 4d2778abb7cd..214d7f5366c8 100644 --- a/test/cli/run/run-shell.test.ts +++ b/test/cli/run/run-shell.test.ts @@ -39,6 +39,288 @@ describe.concurrent("run-shell", () => { }); }); +// Why Bun's shell does not give what sh gives for a row of `paramRows`. +const EMPTY = "sh keeps an empty word that is in double quotes (#44904)"; +const STOPS = "sh stops the script at a ${NAME:?word} that fails (#20368)"; +const SPLITS = "sh splits the value of a variable at whitespace"; +const PREFIX = "sh reads an earlier assignment of the same command (#45371)"; +const QUOTE = "a quote in the word: Bun's shell gives what dash gives"; + +// A script, what bash 5.2 prints for it and whether it succeeds (default: it does). Then, where Bun's shell +// differs: what it prints, whether it succeeds, and why. `./args.sh` prints each of its arguments in `<>`. +type ParamRow = [script: string, stdout: string, ok?: boolean, bun?: [stdout: string, ok: boolean, why: string]]; +// prettier-ignore +const paramRows: ParamRow[] = [ + ["./args.sh ${X:-a b}", "\n"], + ["./args.sh \"${X:-a b}\"", "\n"], + ["./args.sh ${X:-\"a b\"}", "\n"], + ["./args.sh ${X:-'a b'}", "\n"], + ["./args.sh \"${X:-'a b'}\"", "<'a b'>\n"], + ["./args.sh \"${X:-\"a b\"}\"", "\n"], + ["./args.sh \"${X:-\"a b\"}\"", "\n"], + ["./args.sh ${X:-a\\ b}", "\n"], + ["./args.sh ${X:-a\\}b}", "\n"], + ["./args.sh \"${X:-a\\}b}\"", "\n"], + ["./args.sh \"${X:-a\\\\b}\"", "\n"], + ["./args.sh ${X:-}", "\n"], + ["./args.sh \"${X:-}\"", "<>\n", true, ["\n", true, EMPTY]], + ["./args.sh pre${X:-a b}post", "\n"], + ["./args.sh pre${X:- a }post", "
\n"],
+  ["./args.sh ${X:-  a   b  }", "\n"],
+  ["./args.sh ${X:-$Y}", "\n"],
+  ["./args.sh ${X:-$(echo c d)}", "\n"],
+  ["./args.sh \"${X:-$(echo c d)}\"", "\n"],
+  ["./args.sh ${X:-${Y:-z w}}", "\n"],
+  ["./args.sh ${X:-~}", "\n"],
+  ["./args.sh ${X:-~/foo}", "\n"],
+  ["./args.sh \"${X:-~/foo}\"", "<~/foo>\n"],
+  ["./args.sh ${X:-a;b}", "\n"],
+  ["./args.sh ${X:-a|b}", "\n"],
+  ["./args.sh ${X:->f}", "<>f>\n"],
+  ["./args.sh ${X:-{a,b}}", "<{a,b}>\n"],
+  ["./args.sh ${X:-a}b}", "\n"],
+  ["./args.sh ${X:-\"}\"}", "<}>\n"],
+  ["./args.sh \"${X:-\"}\"}\"", "<}>\n"],
+  ["./args.sh ${X:-'}'}", "<}>\n"],
+  ["./args.sh \"${X:-'}'}\"", "<'}'>\n", true, ["<''}>\n", true, QUOTE]],
+  ["./args.sh ${X:-\"\" b}", "<>\n"],
+  ["./args.sh ${X:-\"\"}", "<>\n"],
+  ["./args.sh ${X:-''}", "<>\n"],
+  ["./args.sh ${X:+a b}", "\n"],
+  ["./args.sh ${X+a}", "\n"],
+  ["./args.sh ${X-a}", "\n"],
+  ["./args.sh ${X:-#c}", "<#c>\n"],
+  ["./args.sh ${X:-a#c}", "\n"],
+  ["./args.sh ${X:-a\nb}", "\n"],
+  ["./args.sh \"${X:-a\nb}\"", "\n"],
+  ["./args.sh ${X:-a\\\nb}", "\n"],
+  ["./args.sh ${X:=a b}; ./args.sh \"$X\"", "\n\n", true, ["\n\n", true, SPLITS]],
+  ["./args.sh \"${X:=a b}\"; ./args.sh \"$X\"", "\n\n"],
+  ["./args.sh ${1:-pos}", "\n"],
+  ["./args.sh ${0:+zero}", "\n"],
+  ["./args.sh ${X:-ar*}", "\n"],
+  ["./args.sh \"${X:-*}\"", "<*>\n"],
+  ["./args.sh \"${X:-\"a\\}b\"}\"", "\n"],
+  ["./args.sh ${X:-\"a\\}b\"}", "\n", true, ["\n", true, QUOTE]],
+  ["./args.sh ${X:-'a\\}b'}", "\n"],
+  ["./args.sh \"${X:-a\\\"b}\"", "\n"],
+  ["./args.sh \"${X:-a\"b}c\"d}\"", "\n"],
+  ["./args.sh ${X:-a\"b}c\"d}", "\n"],
+  ["./args.sh \"${X:-$'x'}\"", "\n", true, ["<$'x'>\n", true, QUOTE]],
+  ["./args.sh ${X:-a\\\\b}", "\n"],
+  ["./args.sh ${X:-\\$Y}", "<$Y>\n"],
+  ["./args.sh \"${X:-\\$Y}\"", "<$Y>\n"],
+  ["./args.sh ${X:-\\\"}", "<\">\n"],
+  ["./args.sh \"${X:-`echo q r`}\"", "\n"],
+  ["./args.sh ${X:-`echo q r`}", "\n"],
+  ["./args.sh ${X:-a\"b c\"d e}", "\n"],
+  ["./args.sh pre${X:-}post", "\n"],
+  ["./args.sh pre\"${X:-a b}\"post", "\n"],
+  ["./args.sh ${X:-\"$Y\"}", "<>\n", true, ["\n", true, EMPTY]],
+  ["./args.sh ${X:-\"$Y\" z}", "<>\n", true, ["\n", true, EMPTY]],
+  ["./args.sh a${X:-~}", "\n"],
+  ["./args.sh ${X:-a~}", "\n"],
+  ["./args.sh ${X:-\ttab\tsep}", "\n"],
+  ["./args.sh ${X:-a}${X:-b}", "\n"],
+  ["./args.sh \"${X:-a}${X:-b}\"", "\n"],
+  ["./args.sh ${X:-a}\" \"${X:-b}", "\n"],
+  ["./args.sh ${X:-ar**}", "\n"],
+  ["./args.sh ${X?}", "", false],
+  ["./args.sh ${X:?}", "", false],
+  ["./args.sh ${X:?custom msg}", "", false],
+  ["./args.sh ${X?custom $HOME}", "", false],
+  ["./args.sh ${1:?}", "", false],
+  ["./args.sh ${X:=}; ./args.sh \"[$X]\"", "\n<[]>\n"],
+  ["./args.sh ${1:=v}", "", false],
+  ["X=; ./args.sh ${X=unset-only}; ./args.sh ${X:=now}; ./args.sh \"$X\"", "\n\n\n"],
+  ["X=; ./args.sh ${X+set}; ./args.sh ${X:+nonnull}; ./args.sh ${X-dflt}; ./args.sh ${X:-dflt}", "\n\n\n\n"],
+  ["X=v; ./args.sh ${X+set}; ./args.sh ${X:+nonnull}; ./args.sh ${X-dflt}; ./args.sh ${X:-dflt}; ./args.sh ${X?e}; ./args.sh ${X:=z}", "\n\n\n\n\n\n"],
+  ["./args.sh ${X:-$(echo a; echo b)}", "\n"],
+  ["./args.sh ${X:-a}; echo ${X:?stop}; echo after", "\n", false, ["\nafter\n", true, STOPS]],
+  ["echo ${X:?in pipe} | cat; echo after", "after\n"],
+  ["echo $(echo ${X:?in subst}); echo after", "\nafter\n"],
+  ["(echo ${X:?in subshell}); echo after", "after\n"],
+  ["Y=${X:?in assign}; echo after", "", false, ["after\n", true, STOPS]],
+  ["Y=${X:?in prefix} echo cmd; echo after", "", false, ["after\n", true, STOPS]],
+  ["echo a > ${X:?in redirect}; echo after", "", false, ["after\n", true, STOPS]],
+  ["if echo ${X:?in if}; then echo yes; else echo no; fi; echo after", "", false, ["no\nafter\n", true, STOPS]],
+  ["echo ${X:?a} || echo or; echo after", "", false, ["or\nafter\n", true, STOPS]],
+  ["echo ${X:?a} && echo and; echo after", "", false, ["after\n", true, STOPS]],
+  ["A=x B=${A:-y} sh -c 'echo \"[$B]\"'", "[x]\n", true, ["[y]\n", true, PREFIX]],
+  ["A=x B=${A:+alt} sh -c 'echo \"[$B]\"'", "[alt]\n", true, ["[]\n", true, PREFIX]],
+  ["TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERMX=${TERM_OVERRIDE:-fallback} sh -c 'echo \"[$TERMX]\"'", "[fallback]\n"],
+  ["TURBO_HASH=1; TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERMX=${TERM_OVERRIDE:-fallback} sh -c 'echo \"[$TERMX]\"'", "[dumb]\n", true, ["[fallback]\n", true, PREFIX]],
+  ["Y=${X:-a b}; ./args.sh \"$Y\"", "\n"],
+  ["Y=\"${X:-a b}\"; ./args.sh \"$Y\"", "\n"],
+  ["export Y=${X:-a b}; ./args.sh \"$Y\"", "\n"],
+  ["./args.sh ${X:-a}${X:-b c}d", "\n"],
+  ["./args.sh x${X:+y}z", "\n"],
+  ["X=v; ./args.sh x${X:+y}z", "\n"],
+  ["X=v; ./args.sh ${X:+--flag $X}", "<--flag>\n"],
+  ["X=\"a b\"; ./args.sh ${X:-d}", "\n", true, ["\n", true, SPLITS]],
+  ["X=\"a b\"; ./args.sh ${X:+$X}", "\n", true, ["\n", true, SPLITS]],
+  ["X=\"a b\"; ./args.sh \"${X:+$X}\"", "\n"],
+  ["./args.sh ${X:-$Y}end", "\n"],
+  ["./args.sh ${X:-\"a\"'b'c}", "\n"],
+  ["./args.sh ${X:-a\"b\"}", "\n"],
+  ["./args.sh ${X:-one two three}", "\n"],
+  ["./args.sh \"pre ${X:-mid dle} post\"", "
\n"],
+  ["./args.sh ${X:-${Y:-${Z:-deep}}}", "\n"],
+  ["./args.sh ${X:-${Y:+no}${Z:-yes}}", "\n"],
+  ["./args.sh ${X:=one} ${X:=two} $X", "\n"],
+  ["./args.sh ${X:=a b} \"$X\"", "\n", true, ["\n", true, SPLITS]],
+  ["./args.sh \"${X:=a b}\" \"$X\"", "\n"],
+  ["X=1; ./args.sh ${X:?no} ${X?no}", "<1><1>\n"],
+  ["./args.sh ${X:-a} > out1.txt; cat out1.txt", "\n"],
+  ["echo hi > ${X:-out2.txt}; cat out2.txt", "hi\n"],
+  ["[[ -n ${X:-v} ]] && echo nonempty", "nonempty\n"],
+  ["[[ -z ${X:-} ]] && echo empty", "empty\n"],
+  ["[[ ${X:-a} == a ]] && echo eq", "eq\n"],
+  ["./args.sh $(echo ${X:-in subst})", "\n"],
+  ["./args.sh \"$(echo ${X:-in subst})\"", "\n"],
+  ["./args.sh `echo ${X:-bt}`", "\n"],
+  ["(X=inner; ./args.sh ${X:-d}); ./args.sh ${X:-d}", "\n\n"],
+  ["(: ${X:=inner}); ./args.sh \"[$X]\"", "<[]>\n"],
+  ["echo ${X:=piped} | cat; ./args.sh \"[$X]\"", "piped\n<[]>\n"],
+  ["./args.sh ${X:-a} | cat", "\n"],
+  ["./args.sh ${HOME:+set} ${HOME:-unset}", "\n"],
+  ["./args.sh ${X:-'single quoted'} ${X:-\"double quoted\"}", "\n"],
+  ["./args.sh ${X:-a\\ b} ${X:-a\\\\b}", "\n"],
+  ["./args.sh ${X:-$}", "<$>\n"],
+  ["./args.sh ${X:-$ }", "<$>\n"],
+  ["./args.sh ${X:-a$}", "\n"],
+  ["./args.sh ${X:-}${X:-}", "\n"],
+  ["./args.sh \"${X:-}\"x", "\n"],
+  ["./args.sh ${X:+}", "\n"],
+  ["./args.sh \"a${X:-b}c\" 'a${X:-b}c' a\\${X:-b}c", "\n"],
+  ["./args.sh ${X:-a} ${Y:-b} ${Z:-c}", "\n"],
+  ["./args.sh ${X}", "\n"],
+  ["./args.sh \"${X}\"", "<>\n", true, ["\n", true, EMPTY]],
+  ["./args.sh ${X}y", "\n"],
+  ["X=1; ./args.sh ${X} \"${X}\" ${X}y $X${X} ${X}${X}", "<1><1><1y><11><11>\n"],
+  ["./args.sh ${0} | grep -c .", "1\n"],
+  ["./args.sh ${X:-\"$(echo q1  q2)\"}", "\n"],
+  ["./args.sh ${X:-$(echo q1  q2) tail}", "\n"],
+  ["./args.sh pre${X:-$(echo q1 q2)}post", "\n"],
+  ["./args.sh ${X:- $(echo q1) }post", "\n"],
+  ["./args.sh ${X:-=} ${X:-+} ${X:--} ${X:-?}", "<=><+><->\n"],
+  ["./args.sh ${X-=} ${X=+}", "<=><+>\n"],
+  ["./args.sh ${X:-a=b}", "\n"],
+  ["FOO=${X:-bar}; ./args.sh $FOO", "\n"],
+  ["FOO=a${X:-bar}b; ./args.sh $FOO", "\n"],
+  ["./args.sh ${X:-#notcomment} #comment", "<#notcomment>\n"],
+  ["./args.sh ${X:-{a,b}} {a,b}${X:-c}", "<{a,b}>\n"],
+  ["./args.sh ${X:-a,b}", "\n"],
+  ["./args.sh {${X:-a},b}", "\n"],
+  ["./args.sh ~/x${X:- } ~/x${X:- y}", "\n"],
+  ["./args.sh ${X:-~abc} ${X:-~$Y} ${X:-~ b} a${X:-~/b}", "<~abc><~><~>\n"],
+  ["./args.sh ${X:-a \"\" b} ${HOME:+name \"\"}", "<><>\n"],
+  ["./args.sh x${X:-$Y $(echo a)} ${X:-$(echo a) $Y}y", "\n"],
+  ["[[ ${X:-a b} == \"a b\" ]] && echo one", "one\n"],
+  ["[[ ${X:-a b} == ab ]] && echo glued", "", false],
+  ["true \"${X:?}\" && echo RAN", "", false],
+  ["X=1; true \"${X:?}\" && echo RAN", "RAN\n"],
+];
+
+// `--shell=system` runs bash when there is one. bash 3.2 (macOS) and dash give other results for a few rows.
+const bashVersion = await (async () => {
+  const bash = Bun.which("bash", { PATH: bunEnv.PATH });
+  if (!bash) return 0;
+  await using proc = Bun.spawn({ cmd: [bash, "-c", 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'], stdout: "pipe" });
+  return Number(await proc.stdout.text());
+})();
+
+// Bun's shell runs package.json scripts by default on Windows. The system
+// shell rows show that both shells give the same output.
+describe.each(isWindows ? ["bun"] : ["bun", "system"])("package script with --shell=%s", shell => {
+  const run = async (script: string, env: Record, ...args: string[]) => {
+    using dir = tempDir("run-shell-braced", {
+      "package.json": JSON.stringify({ name: "braced-fixture", version: "1.2.3", scripts: { braced: script } }),
+    });
+    await using proc = Bun.spawn({
+      cmd: [bunExe(), "run", `--shell=${shell}`, "braced", ...args],
+      cwd: String(dir),
+      env: { ...bunEnv, ...env },
+      stdout: "pipe",
+      stderr: "pipe",
+    });
+    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+    return { stdout, stderr, exitCode };
+  };
+
+  test("expands ${NAME}", async () => {
+    const { stdout, exitCode } = await run(
+      'echo ${BRACED_NAME} "${BRACED_VERSION}" ${npm_lifecycle_event}-x',
+      { BRACED_NAME: "braced-fixture", BRACED_VERSION: "1.2.3" },
+      "${BRACED_NAME}",
+    );
+    // The argument after the script name is data. It stays text.
+    expect(stdout).toBe("braced-fixture 1.2.3 braced-x ${BRACED_NAME}\n");
+    expect(exitCode).toBe(0);
+  });
+
+  test("expands ${NAME:-word} and ${NAME:+word}", async () => {
+    const script = "echo --port ${BRACED_PORT:-3002} ${BRACED_HOST:+--host $BRACED_HOST} ${BRACED_NONE:+--none}";
+    expect(await run(script, { BRACED_HOST: "localhost" })).toMatchObject({
+      stdout: "--port 3002 --host localhost\n",
+      exitCode: 0,
+    });
+    expect(await run(script, { BRACED_PORT: "8080" })).toMatchObject({ stdout: "--port 8080\n", exitCode: 0 });
+  });
+
+  // Each row runs in a subshell of one script, so that no row changes a variable of another row.
+  test.skipIf(isWindows || (shell === "system" && bashVersion < 5.2))(
+    "${NAMEword} gives what sh gives",
+    async () => {
+      const script = paramRows.map(([row]) => `echo @@row; (\n${row}\n) && echo @@ok || echo @@fail`).join("\n");
+      using dir = tempDir("run-shell-param-rows", {
+        "package.json": JSON.stringify({ name: "param-rows", scripts: { rows: script } }),
+        "args.sh": `#!/bin/sh\nfor a in "$@"; do printf '<%s>' "$a"; done; printf '\\n'\n`,
+      });
+      chmodSync(join(String(dir), "args.sh"), 0o755);
+      await using proc = Bun.spawn({
+        cmd: [bunExe(), "run", `--shell=${shell}`, "rows"],
+        cwd: String(dir),
+        env: { ...bunEnv, HOME: "/home/user" },
+        stdout: "pipe",
+        stderr: "ignore",
+      });
+      const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
+      const results = stdout
+        .split("@@row\n")
+        .slice(1)
+        .map(result => [result.replace(/@@(ok|fail)\n$/, ""), result.endsWith("@@ok\n")]);
+      expect(Object.fromEntries(paramRows.map(([row], index) => [row, results[index]]))).toEqual(
+        Object.fromEntries(
+          paramRows.map(([row, out, ok = true, bun]) => [row, shell === "bun" && bun ? [bun[0], bun[1]] : [out, ok]]),
+        ),
+      );
+      expect(exitCode).toBe(0);
+    },
+  );
+
+  test("a ${NAME:?} guard in an assignment stops the command", async () => {
+    const script = "ENV=${BRACED_DEPLOY_ENV:?} echo deploying";
+    const unset = await run(script, {});
+    expect(unset.stderr).toContain("BRACED_DEPLOY_ENV: parameter");
+    expect(unset.stdout).toBe("");
+    expect(unset.exitCode).not.toBe(0);
+    expect(await run(script, { BRACED_DEPLOY_ENV: "staging" })).toMatchObject({ stdout: "deploying\n", exitCode: 0 });
+  });
+
+  test("a ${NAME:?} guard stops the script when NAME is not set", async () => {
+    const script = 'true "${BRACED_DEPLOY_ENV:?}" && echo deploying to $BRACED_DEPLOY_ENV';
+    const unset = await run(script, {});
+    expect(unset.stderr).toContain("BRACED_DEPLOY_ENV: parameter");
+    expect(unset.stdout).toBe("");
+    expect(unset.exitCode).not.toBe(0);
+    expect(await run(script, { BRACED_DEPLOY_ENV: "staging" })).toMatchObject({
+      stdout: "deploying to staging\n",
+      exitCode: 0,
+    });
+  });
+});
+
 test.skipIf(isWindows)(
   "package script shell interpreter is resolved from the original PATH, not node_modules/.bin",
   async () => {
diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts
index f2831d30d204..8ce23945e6e8 100644
--- a/test/js/bun/shell/bunshell.test.ts
+++ b/test/js/bun/shell/bunshell.test.ts
@@ -64,6 +64,8 @@ afterAll(async () => {
 });
 
 const BUN = bunExe();
+// With `bun -e`: prints the arguments as JSON, so a test sees where each one ends.
+const printArgv = "console.log(JSON.stringify(process.argv.slice(1)))";
 
 describe("bunshell", () => {
   describe("exit codes", async () => {
@@ -278,6 +280,85 @@ describe("bunshell", () => {
       });
     });
 
+    // The first `${FOO}` of each script is written in the shell source and
+    // expands. The second one gets its name, or the end of its name, from an
+    // interpolated value and stays text.
+    describe("interpolated value after a literal ${ stays literal", () => {
+      const source = { raw: "${FOO}" };
+      const open = { raw: "${" };
+      const name = "FOO";
+      TestBuilder.command`FOO=bar && echo ${source} ${open}${name}}`.stdout("bar ${FOO}\n").runAsTest("no quotes");
+      TestBuilder.command`FOO=bar && echo "${source} ${open}${name}}"`
+        .stdout("bar ${FOO}\n")
+        .runAsTest("double quotes");
+      TestBuilder.command`FOO=bar && echo ${source} a${open}${name}}b`
+        .stdout("bar a${FOO}b\n")
+        .runAsTest("inside a word");
+      TestBuilder.command`FOO=bar && echo ${source} ${open}${1}}`.stdout("bar ${1}\n").runAsTest("a number");
+      TestBuilder.command`FOO=bar && echo ${source} ${open}${["FOO"]}}`.stdout("bar ${FOO}\n").runAsTest("an array");
+      TestBuilder.command`FOO=bar && echo ${source} ${open}${{ toString: () => "FOO" }}}`
+        .stdout("bar ${FOO}\n")
+        .runAsTest("an object with toString");
+      TestBuilder.command`FOO=bar && echo ${source} ${open}${Bun.file("FOO")}}`
+        .stdout("bar ${FOO}\n")
+        .runAsTest("a Bun.file path");
+      TestBuilder.command`FOOBAR=long && FOO=short && echo ${source} ${{ raw: "${FOO" }}${Bun.file("BAR")}}`
+        .stdout("short ${FOOBAR}\n")
+        .runAsTest("a Bun.file path that would complete a name");
+      TestBuilder.command`FOO=bar && echo ${source} ${{ raw: "${FOO" }}${":-other"}} ${{ raw: "${NONE:" }}${"-other"}}`
+        .stdout("bar ${FOO:-other} ${NONE:-other}\n")
+        .runAsTest("a value that would be the operator");
+      // A backslash-newline breaks the word today, so this prints `$ {FOO}`.
+      TestBuilder.command`FOO=bar && echo ${source} ${{ raw: "$\\\n{" }}${name}}`
+        .stdout(stdout => expect(stdout.replace("$ {", "${")).toBe("bar ${FOO}\n"))
+        .runAsTest("after a line continuation between $ and {");
+
+      test("does not complete a variable name", async () => {
+        const { stdout, stderr, exitCode } =
+          await $`FOOBAR=long && FOO=short && echo ${source} ${{ raw: "${FOO" }}${"BAR"}}`.env({ ...bunEnv });
+        expect(stderr.toString()).toBe("");
+        expect(stdout.toString()).toBe("short ${FOOBAR}\n");
+        expect(exitCode).toBe(0);
+      });
+
+      test("does not name a variable from the environment", async () => {
+        const { stdout, stderr, exitCode } = await $`echo ${source} ${open}${"SHELL_TEST_SECRET"}}`.env({
+          ...bunEnv,
+          FOO: "bar",
+          SHELL_TEST_SECRET: "hunter2",
+        });
+        expect(stderr.toString()).toBe("");
+        expect(stdout.toString()).toBe("bar ${SHELL_TEST_SECRET}\n");
+        expect(exitCode).toBe(0);
+      });
+
+      test("when $ is called with a strings array", async () => {
+        const { stdout, stderr, exitCode } = await $({ raw: ["FOO=bar && echo ${FOO} ${", "}"] } as any, name);
+        expect(stderr.toString()).toBe("");
+        expect(stdout.toString()).toBe("bar ${FOO}\n");
+        expect(exitCode).toBe(0);
+      });
+    });
+
+    // The template opens the form and its operator. The value is the word, and it is data there.
+    describe("interpolated value in the word of a literal ${NAME", () => {
+      const open = { raw: "${SHELL_TEST_UNSET:-" };
+      TestBuilder.command`echo ${open}${"a; echo INJECTED"}}`.stdout("a; echo INJECTED\n").runAsTest("a command");
+      TestBuilder.command`echo ${open}${"$(echo INJECTED) `echo INJECTED` $HOME"}}`
+        .stdout("$(echo INJECTED) `echo INJECTED` $HOME\n")
+        .runAsTest("a substitution or a variable");
+      TestBuilder.command`echo ${open}${"a}b"}}c ${open}${"x y"}}`.stdout("a}bc x y\n").runAsTest("a closing brace");
+      TestBuilder.command`echo ${open}${"~"}} ${open}${"*"}} ${open}${"${FOO:-d}"}}`
+        .stdout("~ * ${FOO:-d}\n")
+        .runAsTest("a tilde, a glob or another form");
+      TestBuilder.command`echo ${open}${"plain"}} ${{ raw: "${SHELL_TEST_UNSET:-a" }}${"b"}}`
+        .stdout("plain ab\n")
+        .runAsTest("plain text");
+      TestBuilder.command`true ${{ raw: "${SHELL_TEST_A:=" }}${"$(echo INJECTED)"}}; echo "$SHELL_TEST_A"`
+        .stdout("$(echo INJECTED)\n")
+        .runAsTest("the value of an assignment");
+    });
+
     test("can't escape a js string/obj ref", async () => {
       const shellvar = "$FOO";
       await TestBuilder.command`FOO=bar && echo \\${shellvar}`.stdout(`\\$FOO\n`).run();
@@ -1316,6 +1397,567 @@ booga"
 
       expect(procEnv).toEqual({ ...bunEnv, BUN_TEST_VAR: "1", FOO: "bar" });
     });
+
+    // `${` in a JS template literal starts an interpolation, so `{ raw }`
+    // carries the braced forms to the shell.
+    describe("${NAME}", () => {
+      const raw = (src: string) => TestBuilder.command`${{ raw: src }}`;
+
+      raw("FOO=bar; echo ${FOO}").stdout("bar\n").runAsTest("expands like $NAME");
+      raw('FOO=bar; echo "${FOO}"').stdout("bar\n").runAsTest("in double quotes");
+      raw('FOO="a  b"; echo "x ${FOO} y"').stdout("x a  b y\n").runAsTest("in double quotes beside text");
+      raw("FOO=bar; echo a${FOO}b").stdout("abarb\n").runAsTest("inside a word");
+      raw("FOO=bar; FOObar=no; echo ${FOO}bar").stdout("barbar\n").runAsTest("the braces end the name");
+      raw("FOO=a; BAR=b; echo ${FOO}${BAR}$FOO").stdout("aba\n").runAsTest("beside other variables");
+      raw("echo a${SHELL_TEST_UNSET}b").stdout("ab\n").runAsTest("unset is empty");
+      raw("echo '${FOO}' \\${FOO}").stdout("${FOO} ${FOO}\n").runAsTest("quoted or escaped stays text");
+
+      raw("echo ${FOO}; FOO=bar; echo ${FOO}").stdout("\nbar\n").runAsTest("reads the value when the command runs");
+      raw("FOO=a; FOO=b; echo ${FOO}").stdout("b\n").runAsTest("after a second assignment");
+      raw("FOO=a; (FOO=b; echo ${FOO}); echo ${FOO}").stdout("b\na\n").runAsTest("in a subshell");
+      raw("FOO=bar; echo $(echo ${FOO}) `echo ${FOO}`").stdout("bar bar\n").runAsTest("in a command substitution");
+      raw("FOO=bar; BAR=${FOO}; echo $BAR").stdout("bar\n").runAsTest("as an assignment value");
+      raw("FOO=echo; ${FOO} hi").stdout("hi\n").runAsTest("as the command name");
+      raw("FOO=bar; echo hi > out_${FOO}.txt")
+        .ensureTempDir()
+        .fileEquals("out_bar.txt", "hi\n")
+        .runAsTest("in a redirect target");
+      raw("FOO=bar; echo ${FOO}{a,b} {${FOO},c} {a,b}")
+        .stdout("bara barb bar c a b\n")
+        .runAsTest("beside a brace group");
+      raw("[[ -n ${SHELL_TEST_UNSET} ]]").exitCode(1).runAsTest("unset in [[ -n ]]");
+      raw("FOO=bar; [[ -n ${FOO} ]] && [[ ${FOO} == bar ]] && echo yes").stdout("yes\n").runAsTest("set in [[ ]]");
+      raw("mkdir sub && cd sub && echo ${PWD} && echo $PWD")
+        .ensureTempDir()
+        .stdout(stdout => {
+          const [braced, bare] = stdout.split("\n");
+          expect(braced).toEndWith("sub");
+          expect(braced).toBe(bare);
+        })
+        .runAsTest("${PWD} after cd");
+      raw("echo ${SHELL_TEST_X%a} ${#SHELL_TEST_X} ${SHELL_TEST_X/a/b} ${10}")
+        .stdout("${SHELL_TEST_X%a} ${#SHELL_TEST_X} ${SHELL_TEST_X/a/b} ${10}\n")
+        .runAsTest("the forms that are not read stay text");
+
+      test("reads an environment set with .env()", async () => {
+        const { stdout, stderr, exitCode } = await $`echo ${{ raw: "${FOO}" }}`.env({ ...bunEnv, FOO: "bar" });
+        expect(stderr.toString()).toBe("");
+        expect(stdout.toString()).toBe("bar\n");
+        expect(exitCode).toBe(0);
+      });
+
+      test("is one argument of a command", async () => {
+        const words = { raw: '"${FOO}" x${BAR}y ${BAR}' };
+        const { stdout, stderr, exitCode } = await $`FOO="a  b"; BAR=c; ${BUN} -e ${printArgv} ${words}`;
+        expect(stderr.toString()).toBe("");
+        expect(JSON.parse(stdout.toString())).toEqual(["a  b", "xcy", "c"]);
+        expect(exitCode).toBe(0);
+      });
+
+      // sh gives one empty argument for a double-quoted expansion that is
+      // empty. Bun's shell gives none, for "$NAME" too.
+      test('empty in double quotes is what "$NAME" is', async () => {
+        const braced = { raw: 'a "${SHELL_TEST_UNSET}" "${EMPTY}" b' };
+        const bare = { raw: 'a "$SHELL_TEST_UNSET" "$EMPTY" b' };
+        const run = (words: { raw: string }) => $`EMPTY=; ${BUN} -e ${printArgv} ${words}`.text();
+        expect(await run(braced)).toBe(await run(bare));
+      });
+
+      // sh gives one empty word. Bun's shell drops the word, for "$NAME" too. #44904 changes both.
+      raw('echo a "${SHELL_TEST_UNSET}" b')
+        .stdout("a  b\n")
+        .todo("needs #44904")
+        .runAsTest("unset in double quotes is one empty word");
+      raw('FOO=; echo a "${FOO}" b')
+        .stdout("a  b\n")
+        .todo("needs #44904")
+        .runAsTest("empty in double quotes is one empty word");
+      raw('echo a "${SHELL_TEST_UNSET:-}" b')
+        .stdout("a  b\n")
+        .todo("needs #44904")
+        .runAsTest("a form in double quotes that expands to nothing is one empty word");
+    });
+
+    describe("${NAMEword}", () => {
+      const raw = (src: string) => TestBuilder.command`${{ raw: src }}`;
+      // The arguments that the words of `src` give, after the assignments in `setup`.
+      const argvOf = async (src: string, setup = "") => {
+        const { stdout, stderr } = await $`${{ raw: setup }} ${BUN} -e ${printArgv} ${{ raw: src }}`;
+        expect(stderr.toString()).toBe("");
+        return JSON.parse(stdout.toString());
+      };
+      // The arguments that each of `words` gives, from one command. `E` is empty and HOME is /home/user.
+      const fieldsOf = async (words: string[], cwd = process.cwd()) => {
+        const { stdout, stderr } = await $`E=; ${BUN} -e ${printArgv} ${{ raw: words.join(" @@ ") }}`
+          .cwd(cwd)
+          .env({ ...bunEnv, HOME: "/home/user", USERPROFILE: "/home/user" })
+          .quiet();
+        expect(stderr.toString()).toBe("");
+        const fields: string[][] = [[]];
+        for (const arg of JSON.parse(stdout.toString())) arg === "@@" ? fields.push([]) : fields.at(-1)!.push(arg);
+        return fields;
+      };
+
+      // The result for a parameter that is not set, is empty, or is `v`. `!`: the command fails.
+      const results: Record = {
+        ":-": ["w", "w", "v"],
+        "-": ["w", "", "v"],
+        ":+": ["", "", "w"],
+        "+": ["", "w", "w"],
+        ":=": ["w", "w", "v"],
+        "=": ["w", "", "v"],
+        ":?": ["!", "!", "v"],
+        "?": ["!", "", "v"],
+      };
+      const cells = Object.entries(results).flatMap(([op, byState], row) =>
+        (["unset", "empty", "set"] as const).map((state, column) => {
+          const name = `SHELL_TEST_P${row}${column}`;
+          const result = byState[column];
+          const assigns = op.endsWith("=") && result === "w";
+          return {
+            op,
+            id: `${op} ${state}`,
+            name,
+            setup: state === "unset" ? "" : state === "empty" ? `${name}=; ` : `${name}=v; `,
+            form: "${" + name + op + "w}",
+            result,
+            after: assigns ? "w" : state === "set" ? "v" : "",
+            error: `bun: ${name}: w\n`,
+          };
+        }),
+      );
+
+      test.each([
+        ["an argument", (form: string) => `echo "$ID" .${form}.`],
+        ["an argument in double quotes", (form: string) => `echo "$ID" ".${form}."`],
+      ])("every operator as %s", async (_name, command) => {
+        const script = cells
+          .map(cell => `${cell.setup}ID="${cell.id}"; ${command(cell.form)} "[$${cell.name}]"`)
+          .join("\n");
+        const { stdout, stderr, exitCode } = await $`${{ raw: script }}`;
+        expect(stdout.toString()).toBe(
+          cells
+            .filter(cell => cell.result !== "!")
+            .map(cell => `${cell.id} .${cell.result}. [${cell.after}]\n`)
+            .join(""),
+        );
+        expect(stderr.toString()).toBe(
+          cells
+            .filter(cell => cell.result === "!")
+            .map(cell => cell.error)
+            .join(""),
+        );
+        expect(exitCode).toBe(0);
+      });
+
+      test.each([
+        ["an assignment", (form: string) => `VALUE=${form}; `, (result: string) => result],
+        ["an assignment in double quotes", (form: string) => `VALUE=".${form}."; `, (result: string) => `.${result}.`],
+        // An assignment before a command is for that command only.
+        ["an assignment before a command", (form: string) => `VALUE=${form} true; `, () => "none"],
+      ])("every operator in the value of %s", async (_name, assign, value) => {
+        const script = cells
+          .map(cell => `${cell.setup}VALUE=none; ${assign(cell.form)}echo "${cell.id} [$VALUE] [$${cell.name}]"`)
+          .join("\n");
+        const { stdout, stderr, exitCode } = await $`${{ raw: script }}`;
+        expect(stdout.toString()).toBe(
+          cells
+            .map(cell => `${cell.id} [${cell.result === "!" ? "none" : value(cell.result)}] [${cell.after}]\n`)
+            .join(""),
+        );
+        expect(stderr.toString()).toBe(
+          cells
+            .filter(cell => cell.result === "!")
+            .map(cell => cell.error)
+            .join(""),
+        );
+        expect(exitCode).toBe(0);
+      });
+
+      test("in a [[ ]] operand", async () => {
+        const script = cells
+          .map(cell => `${cell.setup}[[ .${cell.form}. == .${cell.result}. ]] && echo "${cell.id}"`)
+          .join("\n");
+        const { stdout, stderr } = await $`${{ raw: script }}`;
+        expect(stdout.toString()).toBe(
+          cells
+            .filter(cell => cell.result !== "!")
+            .map(cell => `${cell.id}\n`)
+            .join(""),
+        );
+        expect(stderr.toString()).toBe(
+          cells
+            .filter(cell => cell.result === "!")
+            .map(cell => cell.error)
+            .join(""),
+        );
+      });
+
+      // What changes "$NAME" (#44904) must change each of these with it.
+      test('a form that expands to nothing in double quotes is what "$NAME" is', async () => {
+        const spellings = [
+          ...['"${U}"', '"${E}"', '"${9}"', '"${U:-}"', '"${U-}"', '"${E:-}"', '"${E-x}"', '"${9:-}"'],
+          ...['"${U:+x}"', '"${U+x}"', '"${E:+x}"', '"${E+}"', '"${E=x}"', '"${E?x}"'],
+          ...['"${U:-$U}"', '"${U:-${U}}"', '"${U:-$(true)}"', '"${U:-""}"', '${U:-"$U"}', '${U:-"$(true)"}'],
+          ...['"${SHELL_TEST_A:=}"', '"${SHELL_TEST_B=}"', '~"${U:-}"'],
+        ];
+        const echo = (word: string) => `echo a ${word} b`;
+        const bare = (word: string) => (word.startsWith("~") ? '~"$U"' : word.includes("E") ? '"$E"' : '"$U"');
+        const run = (script: string) => $`E=; ${{ raw: script }}`.text();
+        expect(await run(spellings.map(echo).join("\n"))).toBe(await run(spellings.map(bare).map(echo).join("\n")));
+        // A `""` in a word that is not in double quotes is one empty argument.
+        expect(await argvOf('a ${U:-""} ${U:-\'\'} "${U:-}""" b')).toEqual(["a", "", "", "", "b"]);
+      });
+
+      // Whitespace in the word ends no argument there.
+      raw(
+        [
+          '[[ ${U:-a b} == "a b" ]] && echo 1',
+          "[[ ${U:-a b} != ab ]] && echo 2",
+          '[[ ${U:-x $(echo a b)} == "x a b" ]] && echo 3',
+          "[[ -n ${U:- } ]] && echo 4",
+          "touch myfile; [[ -f ${U:-my file} ]] || echo 5",
+          '[[ ${S:=a  b} == "a  b" ]] && echo 6',
+        ].join("\n"),
+      )
+        .ensureTempDir()
+        .stdout("1\n2\n3\n4\n5\n6\n")
+        .runAsTest("a [[ ]] operand is one string");
+
+      raw("echo hi > out_${SHELL_TEST_UNSET:-a}.txt; SET=b; echo hi > out_${SET:+c}.txt")
+        .ensureTempDir()
+        .fileEquals("out_a.txt", "hi\n")
+        .fileEquals("out_c.txt", "hi\n")
+        .runAsTest("in a redirect target");
+      raw("echo hi > ${U:-a b}")
+        .ensureTempDir()
+        .exitCode(1)
+        .stderr("bun: ambiguous redirect: at `echo`\n")
+        .doesNotExist("a")
+        .runAsTest("a redirect target of two words is ambiguous");
+
+      test("a positional parameter", async () => {
+        const { stdout } = await $`echo ${{ raw: "${0:+zero} ${0:-none} ${9:-none} ${9:+nine}." }}`;
+        expect(stdout.toString()).toBe(`zero ${process.argv[0]} none .\n`);
+      });
+
+      describe("the word", () => {
+        test("unquoted whitespace ends an argument", async () => {
+          expect(await argvOf("${U:-a b} ${U:-  a\tb  } pre${U:- a }post ${U:-} x${U:-}y")).toEqual([
+            ...["a", "b"],
+            ...["a", "b"],
+            ...["pre", "a", "post"],
+            "xy",
+          ]);
+          expect(await argvOf("${U:-a\nb}")).toEqual(["a", "b"]);
+        });
+
+        test("quotes and escapes keep whitespace", async () => {
+          expect(await argvOf(`"\${U:-a  b}" \${U:-'a b' "c d" e\\ f} \${U:-a"b c"d e} \${U:-""} \${U:-''}x`)).toEqual([
+            "a  b",
+            "a b",
+            "c d",
+            "e f",
+            "ab cd",
+            "e",
+            "",
+            "x",
+          ]);
+        });
+
+        raw('VALUE=${U:-a  b}; echo "[$VALUE]"; export EXPORTED=${U:- c }; echo "[$EXPORTED]"')
+          .stdout("[a  b]\n[ c ]\n")
+          .runAsTest("whitespace is text in the value of an assignment");
+
+        test("the value of a variable is one argument", async () => {
+          expect(await argvOf("${S:-d} ${S:+$S} ${U:-$S} ${U:=a b} $U", 'S="a b";')).toEqual([
+            "a b",
+            "a b",
+            "a b",
+            "a b",
+            "a b",
+          ]);
+        });
+
+        test("a $(...)", async () => {
+          expect(
+            await argvOf(
+              '${U:-$(echo c d)} "${U:-$(echo "c  d")}" ${U:-`echo e`} pre${U:-$(echo q r)}post ${U:- $(echo s) }post',
+            ),
+          ).toEqual(["c", "d", "c  d", "e", "preq", "rpost", "s", "post"]);
+        });
+
+        test("a nested form", async () => {
+          expect(await argvOf("${U:-${V:-z w}} ${U:-${V:-${W:-deep}}} ${U:-${S:+no}${V:-yes}} ${U:-${S%x}}")).toEqual([
+            "z",
+            "w",
+            "deep",
+            "yes",
+            "${S%x}",
+          ]);
+        });
+
+        raw("S=1; echo ${S:-$(touch unused1.txt)} ${U:+$(touch unused2.txt)} ${U:-$(touch used.txt)}.")
+          .ensureTempDir()
+          .stdout("1 .\n")
+          .doesNotExist("unused1.txt")
+          .doesNotExist("unused2.txt")
+          .fileEquals("used.txt", "")
+          .runAsTest("a $(...) runs only when the word is the result");
+
+        raw('echo ${U:-a;b|c&d>ee {
+          expect(
+            await fieldsOf([
+              "~/.cache${HOME:+ force}",
+              "~/x${U:- }",
+              "~/x${U:- ~/y}",
+              '~/a$(echo "b c")',
+              '${HOME:+name ""}',
+              '${U:-"" b}',
+              '${U:-a "" b}',
+              '${U:-"" ""}',
+              '${U:-"" }',
+              '${U:-"" }{a,b}',
+            ]),
+          ).toEqual([
+            ["/home/user/.cache", "force"],
+            ["/home/user/x"],
+            ["/home/user/x", "~/y"],
+            ["/home/user/ab", "c"],
+            ["name", ""],
+            ["", "b"],
+            ["a", "", "b"],
+            ["", ""],
+            [""],
+            ["", "a", "b"],
+          ]);
+        });
+
+        // The word expands in the same way with a `$(...)` in it.
+        test("a $(...) in the word changes no other part of it", async () => {
+          using dir = tempDir("shell-word-subst", { "a.txt": "", "src/x.ts": "" });
+          const pairs: [string, string, string[]][] = [
+            ["pre${U:- a $E}post", "pre${U:- a $(true)}post", ["pre", "a", "post"]],
+            ["a${U:-$E b}", "a${U:-$(true) b}", ["a", "b"]],
+            ["x${U:-$E a}", "x${U:-$E $(echo a)}", ["x", "a"]],
+            ["${U:-a $E}y", "${U:-$(echo a) $E}y", ["a", "y"]],
+            ["x=${U:-$E y}", "x=${U:-$(true) y}", ["x=", "y"]],
+            ["a${U:-${V:- b}}", "a${U:-${V:- b}$(true)}", ["a", "b"]],
+            ["${U:-${V:-b }}c", "${U:-$(true)${V:-b }}c", ["b", "c"]],
+            ["${U:-~/bin /opt/bin}", "${U:-~/bin $(echo /opt/bin)}", ["/home/user/bin", "/opt/bin"]],
+            ["${U:-~abc}", "${U:-~abc$(echo)}", ["~abc"]],
+            ["${U:-~}", "${U:-~}$(true)", ["/home/user"]],
+            ["${U:-src/*.ts}", "${U:-$(echo src)/*.ts}", ["src/x.ts"]],
+            ["${U:-a.*}", "${U:-$(echo a).*}", ["a.txt"]],
+            ["${U:-*.txt}$E", "${U:-*.txt$(true)}", ["a.txt"]],
+          ];
+          const expected = pairs.map(([, , fields]) => fields);
+          expect(
+            await fieldsOf(
+              pairs.map(([plain]) => plain),
+              String(dir),
+            ),
+          ).toEqual(expected);
+          expect(
+            await fieldsOf(
+              pairs.map(([, withSubst]) => withSubst),
+              String(dir),
+            ),
+          ).toEqual(expected);
+        });
+
+        raw('echo ${U:-*.txt} "${U:-*.txt}" ${U:-a.*}')
+          .ensureTempDir()
+          .file("a.txt", "")
+          .file("b.txt", "")
+          .stdout(stdout =>
+            expect(stdout.split(/\s+/).filter(Boolean).sort()).toEqual(["*.txt", "a.txt", "a.txt", "b.txt"]),
+          )
+          .runAsTest("an unquoted * is a glob");
+
+        raw("S=x; echo ${U:-*.none}; echo ${S:-*.none} ${U:+*.none} ${U:-a }")
+          .ensureTempDir()
+          .stdout("x a\n")
+          .stderr("bun: no matches found: *.none\n")
+          .runAsTest("a * is a glob only in the word that is the result");
+
+        test("whitespace at the end of a word adds no empty argument", async () => {
+          expect(await argvOf("${U:-a}${U:-b c}d ${U:-a }${U:-} ${U:- } x${U:-b } ${U:-$(echo c) }")).toEqual([
+            "ab",
+            "cd",
+            "a",
+            "xb",
+            "c",
+          ]);
+        });
+
+        // A `{a,b}` or a `*` expands only in the last field of an argument. sh expands it in each field.
+        test("a brace group or a glob in a field that whitespace of a word ends is text", async () => {
+          expect(await argvOf("{a,b}${U:-c } *.none${U:-d } *.none${U:- e}")).toEqual([
+            "{a,b}c",
+            "*.noned",
+            "*.none",
+            "e",
+          ]);
+        });
+        test.failing("a brace group in a field that whitespace of a word ends", async () => {
+          expect(await argvOf("{a,b}${U:-c }")).toEqual(["ac", "bc"]);
+        });
+        test.failing("a glob in a field that whitespace of a word ends", async () => {
+          using dir = tempDir("shell-word-glob", { "a.txt": "" });
+          const { stdout } = await $`${BUN} -e ${printArgv} ${{ raw: "*.txt${U:- }" }}`.cwd(String(dir));
+          expect(JSON.parse(stdout.toString())).toEqual(["a.txt"]);
+        });
+
+        raw('S=é; echo ${S:-ü} ${U:-ü ö} "${U:-ü ö}" é${U:+ü}é')
+          .stdout("é ü ö ü ö éé\n")
+          .runAsTest("text that is not ASCII");
+      });
+
+      describe("= and :=", () => {
+        raw('echo ${A:=one} ${A:=two} $A; B=; echo "[${B=no}]" "[${B:=yes}]" $B')
+          .stdout("one one one\n[] [yes] yes\n")
+          .runAsTest("assign once");
+        raw('S=1; true ${A:=$(echo a) b$S ${S:+c}}; echo "$A"')
+          .stdout("a b1 c\n")
+          .runAsTest("the value is the expanded word");
+        raw('(echo ${A:=sub}); echo $(echo ${B:=subst}) "[$A$B]"; echo ${C:=pipe} | cat; echo "[$C]"')
+          .stdout("sub\nsubst []\npipe\n[]\n")
+          .runAsTest("not outside a subshell, a $(...) or a pipeline");
+        raw('true ${A:=v}; echo "[$A]"').stdout("[v]\n").runAsTest("in the argument of a command that ignores it");
+        test("a quote or a * in the word is not syntax of the argument", async () => {
+          using dir = tempDir("shell-assign-word", { "a.txt": "" });
+          const { stdout } = await $`${BUN} -e ${printArgv} ${{ raw: "${A:=\"\"} ${B=''} ${C:=*.txt} x" }}`.cwd(
+            String(dir),
+          );
+          expect(JSON.parse(stdout.toString())).toEqual(["*.txt", "x"]);
+        });
+        raw("echo ${9:=v}; echo after")
+          .stdout("after\n")
+          .stderr("bun: $9: cannot assign in this way\n")
+          .runAsTest("not to a positional parameter");
+      });
+
+      describe("? and :?", () => {
+        raw('true "${U:?}" && echo RAN')
+          .exitCode(1)
+          .stderr("bun: U: parameter null or not set\n")
+          .runAsTest("the command does not run and what follows && does not");
+        raw("echo ${U?}; E=; echo ${E:?}; echo ${9:?}; echo ${U:?need $E U  here}")
+          .exitCode(1)
+          .stderr(
+            "bun: U: parameter not set\nbun: E: parameter null or not set\nbun: 9: parameter null or not set\nbun: U: need  U  here\n",
+          )
+          .runAsTest("the message");
+        raw('echo "${U:?need  $(echo that) U}"')
+          .exitCode(1)
+          .stderr("bun: U: need  that U\n")
+          .runAsTest("the message from a word in double quotes");
+        raw("echo a; echo ${U:?boom}; echo b; echo ${U:?boom} || echo c")
+          .stdout("a\nb\nc\n")
+          .stderr("bun: U: boom\nbun: U: boom\n")
+          .runAsTest("only that command fails");
+        raw("VALUE=${U:?one}; VALUE=${U:?two} echo cmd; echo after")
+          .stdout("after\n")
+          .stderr("bun: U: one\nbun: U: two\n")
+          .runAsTest("in the value of an assignment");
+        raw("VALUE=${U:?one}").exitCode(1).stderr("bun: U: one\n").runAsTest("an assignment alone has exit code 1");
+        raw("VALUE=${U:?two} echo cmd")
+          .exitCode(1)
+          .stderr("bun: U: two\n")
+          .runAsTest("an assignment before a command has exit code 1 and the command does not run");
+        raw("VALUE=${U:?one}; VALUE=${U:?two} echo cmd; echo after")
+          .quiet()
+          .stdout("after\n")
+          .stderr("bun: U: one\nbun: U: two\n")
+          .runAsTest("in the value of an assignment, with .quiet()");
+        raw("A=1 B=${U:?stop} C=2; echo $A-$C; VALUE=${U:?one} || echo or")
+          .stdout("1-\nor\n")
+          .stderr("bun: U: stop\nbun: U: one\n")
+          .runAsTest("the assignments before it are made and the ones after it are not");
+        // 16 alternatives in each of 5 groups are 1048576 words, over the limit of 65536.
+        raw("VALUE=" + Buffer.alloc(33 * 5, "{a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p}").toString() + "; echo after")
+          .stdout("after\n")
+          .stderr("bun: too many brace expansions (1048576 > 65536)\n")
+          .runAsTest("another error in the value of an assignment is written too");
+        raw("echo hi > ${U:?no file}.txt; echo ${U:-$(echo ${U:?inner})} after")
+          .ensureTempDir()
+          .stdout("after\n")
+          .stderr("bun: U: no file\nbun: U: inner\n")
+          .doesNotExist(".txt")
+          .runAsTest("in a redirect target and in a $(...)");
+        raw("echo ${U:-x${U:?nested}} after; echo ${U:-$(echo y)${U:?beside a substitution}} after")
+          .exitCode(1)
+          .stderr("bun: U: nested\nbun: U: beside a substitution\n")
+          .runAsTest("in the word of another form");
+        raw("S=1; echo ${S:?no} ${S?no} ${U:-${S:?no}} ${S:+${U:-ok}}")
+          .stdout("1 1 1 ok\n")
+          .runAsTest("set is the value");
+      });
+
+      // sh reads the `A` of the same command. Bun's shell does not, for $A too.
+      test("an assignment before a command is not read by a later one, like $NAME", async () => {
+        const printB = "console.log(process.env.B)";
+        const run = (value: string) => $`A=x ${{ raw: "B=[" + value + "]" }} ${BUN} -e ${printB}`.text();
+        expect([await run("$A"), await run("${A}"), await run("${A:-none}")]).toEqual(["[]\n", "[]\n", "[none]\n"]);
+      });
+
+      // `$` reads the raw template, so an escaped `${` is text. A `{ raw }` value carries a form.
+      test("in a template literal", async () => {
+        const { stdout, stderr, exitCode } =
+          await $`FOO=bar; echo \${FOO} \${FOO:-d} "\${NONE:?}" ${{ raw: "${FOO:-d} ${NONE:-d}" }}`.quiet();
+        expect(stderr.toString()).toBe("");
+        expect(stdout.toString()).toBe("${FOO} ${FOO:-d} ${NONE:?} bar d\n");
+        expect(exitCode).toBe(0);
+      });
+
+      test("reads an environment set with .env()", async () => {
+        const words = { raw: "${FOO:-none} ${BAR:-none} ${FOO:+set}" };
+        const { stdout, stderr, exitCode } = await $`echo ${words}`.env({ ...bunEnv, FOO: "bar" });
+        expect(stderr.toString()).toBe("");
+        expect(stdout.toString()).toBe("bar none set\n");
+        expect(exitCode).toBe(0);
+      });
+
+      // A script with a form that does not end runs with every `${` as text.
+      raw("echo ${U:-a} $(echo ${U:-b) c}; echo next")
+        .stdout("${U:-a} ${U:-b c}\nnext\n")
+        .runAsTest("a form that does not end makes every form text");
+      // As text, a guard stops nothing. So a script that has one does not run.
+      raw('true "${U:?}" && echo RAN; echo ${V:-z')
+        .error("Unclosed parameter expansion")
+        .runAsTest("a form that does not end is an error in a script with a ? form");
+      test("forms nest to the depth of $(...)", async () => {
+        const repeat = (text: string, count: number) => Buffer.alloc(text.length * count, text).toString();
+        const nested = (depth: number) => ({ raw: repeat("${A:-", depth) + "x" + repeat("}", depth) });
+        expect(await $`echo ${nested(128)}`.text()).toBe("x\n");
+        expect(await $`echo ${nested(129)}`.text()).toBe(nested(129).raw + "\n");
+        // A form counts as one level of $(...).
+        const inSubst = (depth: number) => ({ raw: repeat("$(echo ", depth) + "${A:-x}" + repeat(")", depth) });
+        expect(await $`echo ${inSubst(127)}`.text()).toBe("x\n");
+        expect(await $`echo ${inSubst(128)}`.text()).toBe("${A:-x}\n");
+      });
+    });
   });
 
   describe("cd & pwd", () => {
diff --git a/test/js/bun/shell/env.positionals.test.ts b/test/js/bun/shell/env.positionals.test.ts
index 66273eec7a17..b34c9d92f20b 100644
--- a/test/js/bun/shell/env.positionals.test.ts
+++ b/test/js/bun/shell/env.positionals.test.ts
@@ -14,6 +14,12 @@ describe("$ argv", async () => {
       .stdout(process.argv[i] + "\n")
       .runAsTest(`$${i} should equal process.argv[${i}]`);
   }
+  for (let i = 0; i < Math.min(process.argv.length, 10); i++) {
+    TestBuilder.command`echo ${{ raw: "${" + i + "}" }}`
+      .exitCode(0)
+      .stdout(process.argv[i] + "\n")
+      .runAsTest(`\${${i}} should equal process.argv[${i}]`);
+  }
 });
 
 test("$ argv: standalone", async () => {
@@ -73,6 +79,22 @@ test("$ argv: standalone: only 10", async () => {
   expect(out.split("\n")).toEqual([script, "a", "bb", "c", "d", "e", "f", "g", "h", "i", "a0", ""]);
 });
 
+test("$ argv: standalone: ${N}", async () => {
+  const script = path.join(import.meta.dir, "fixtures", "positionals3.bun.sh");
+  await using proc = spawn({
+    cmd: [bunExe(), "run", script, "a", "b", "c  d"],
+    stdout: "pipe",
+    stdin: "ignore",
+    stderr: "pipe",
+    env: bunEnv,
+  });
+  const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+
+  expect(err).toBeEmpty();
+  expect(out.split("\n")).toEqual([script, "a", "bb", "a0", "c  d", "none one", ""]);
+  expect(exitCode).toBe(0);
+});
+
 test("$ argv: standalone: non-ascii", async () => {
   const script = path.join(import.meta.dir, "fixtures", "positionals2.bun.sh");
   const { stdout, stderr, exited } = spawn({
diff --git a/test/js/bun/shell/exec.test.ts b/test/js/bun/shell/exec.test.ts
index b7c272197124..b94cda05656a 100644
--- a/test/js/bun/shell/exec.test.ts
+++ b/test/js/bun/shell/exec.test.ts
@@ -27,6 +27,41 @@ describe("bun exec", () => {
     .stdout('hi "there bud"\n')
     .runAsTest("it works2");
 
+  TestBuilder.command`${BUN} exec ${'FOO=bar; echo ${FOO} "${FOO}" ${FOO}baz'}`
+    .env(bunEnv)
+    .stdout("bar bar barbaz\n")
+    .runAsTest("expands ${NAME}");
+
+  TestBuilder.command`${BUN} exec ${'FOO=bar; echo ${FOO:-none} ${NONE:-"a  b"} ${FOO:+set}; echo ${NONE:=made} $NONE'}`
+    .env(bunEnv)
+    .stdout("bar a  b set\nmade made\n")
+    .runAsTest("expands ${NAMEword}");
+
+  TestBuilder.command`${BUN} exec ${'true "${NONE:?need NONE}" && echo RAN'}`
+    .env(bunEnv)
+    .stderr("bun: NONE: need NONE\n")
+    .exitCode(1)
+    .runAsTest("${NAME:?word} fails the command when NAME is not set");
+
+  TestBuilder.command`${BUN} exec ${"ENV=${NONE:?} echo cmd"}`
+    .env(bunEnv)
+    .stderr("bun: NONE: parameter null or not set\n")
+    .exitCode(1)
+    .runAsTest("${NAME:?word} in an assignment before a command");
+
+  TestBuilder.command`${BUN} script.sh`
+    .file("script.sh", "ENV=${NONE:?need NONE}\n")
+    .env(bunEnv)
+    .stderr("bun: NONE: need NONE\n")
+    .exitCode(1)
+    .runAsTest("${NAME:?word} in an assignment in a .sh file");
+
+  TestBuilder.command`${BUN} script.sh`
+    .file("script.sh", 'FOO=bar\necho ${FOO} "${FOO}" ${FOO}baz ${NONE:-é ${FOO:+ü}}\n')
+    .env(bunEnv)
+    .stdout("bar bar barbaz é ü\n")
+    .runAsTest("expands ${NAME} and ${NAMEword} in a .sh file");
+
   TestBuilder.command`${BUN} exec ${"cat filename"}`
     .file(
       "filename",
diff --git a/test/js/bun/shell/fixtures/positionals3.bun.sh b/test/js/bun/shell/fixtures/positionals3.bun.sh
new file mode 100644
index 000000000000..b64bad288941
--- /dev/null
+++ b/test/js/bun/shell/fixtures/positionals3.bun.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+echo ${0}
+echo ${1}
+echo ${2}${2}
+echo ${1}0
+echo "${3}"
+echo ${4:-none} ${1:+one}
diff --git a/test/js/bun/shell/lex.test.ts b/test/js/bun/shell/lex.test.ts
index aebe12bc91a2..e0673210f68e 100644
--- a/test/js/bun/shell/lex.test.ts
+++ b/test/js/bun/shell/lex.test.ts
@@ -821,6 +821,464 @@ describe("lex shell", () => {
     expect(JSON.parse(lex({ raw: [source] }))).toEqual(expected);
   });
 
+  // `${` in a JS template literal starts an interpolation, so the sources
+  // here go to the lexer as a raw array.
+  describe("${...}", () => {
+    const tokens = (source: string) => JSON.parse(lex({ raw: [source] }));
+    const param = (op: string, target: string | number, word: object[]) => [
+      { ParamBegin: op },
+      typeof target === "number" ? { VarArgv: target } : { Var: target },
+      ...word,
+      { ParamEnd: {} },
+    ];
+    const operators = [":-", "-", ":+", "+", ":=", "=", ":?", "?"];
+
+    test.each([
+      ["a whole word", "echo ${PORT}", "echo $PORT"],
+      ["in double quotes", 'echo "${X}"', 'echo "$X"'],
+      ["in double quotes beside text", 'echo "a ${X} b"', 'echo "a $X b"'],
+      ["two in one word", "${A}${B}", "$A$B"],
+      ["a positional parameter", "echo ${1}", "echo $1"],
+      ["before a brace group", "${X}{a,b}", "$X{a,b}"],
+      ["in a brace group", "{${X},b}", "{$X,b}"],
+      ["in a script that is not ASCII", "é${X}", "é$X"],
+      ["before a character that is not ASCII", "${X}é", "$Xé"],
+      ["in $(...)", "echo $(echo ${X})", "echo $(echo $X)"],
+      ["in $(...) in a script that is not ASCII", "é $(echo ${X})", "é $(echo $X)"],
+      ["in backticks", "echo `echo ${X}`", "echo `echo $X`"],
+      ["in a subshell", "(echo ${X})", "(echo $X)"],
+      ["before a space", "${FOO} bar", "$FOO bar"],
+      ["before a newline", "echo ${FOO}\nls", "echo $FOO\nls"],
+      ["before a semicolon", "${FOO};b", "$FOO;b"],
+      ["before an operator", "${FOO}|b", "$FOO|b"],
+      ["before a #", "${X}#c", "$X#c"],
+      ["after a literal $", "$${X}", "$$X"],
+      ["in the word of another form", "${X:-${Y}}", "${X:-$Y}"],
+      ["in the word of another form, in double quotes", '"${X:-${Y}}"', '"${X:-$Y}"'],
+    ])("${NAME} gives the tokens of $NAME: %s", (_name, braced, bare) => {
+      expect(tokens(braced)).toEqual(tokens(bare));
+    });
+
+    test.each([
+      [
+        "text after the brace",
+        "echo ${FOO}bar",
+        [{ Text: "echo" }, { Delimit: {} }, { Var: "FOO" }, { Text: "bar" }, { Delimit: {} }, { Eof: {} }],
+      ],
+      ["text on both sides", "a${X}b", [{ Text: "a" }, { Var: "X" }, { Text: "b" }, { Delimit: {} }, { Eof: {} }]],
+      [
+        "text on both sides in a script that is not ASCII",
+        "é${X}b",
+        [{ Text: "é" }, { Var: "X" }, { Text: "b" }, { Delimit: {} }, { Eof: {} }],
+      ],
+      ["name characters after the brace", "${X}_1", [{ Var: "X" }, { Text: "_1" }, { Delimit: {} }, { Eof: {} }]],
+      ["a digit after a positional parameter", "${1}0", [{ VarArgv: 1 }, { Text: "0" }, { Delimit: {} }, { Eof: {} }]],
+      ["an underscore and digits in the name", "${_X_1}", [{ Var: "_X_1" }, { Eof: {} }]],
+    ])("the braces end the name: %s", (_name, source, expected) => {
+      expect(tokens(source)).toEqual(expected);
+    });
+
+    test.each(operators)("${NAME%sword}", op => {
+      expect(tokens("${X" + op + "w}")).toEqual([...param(op, "X", [{ Text: "w" }]), { Eof: {} }]);
+      expect(tokens('"${X' + op + 'w}"')).toEqual([...param(op, "X", [{ DoubleQuotedText: "w" }]), { Eof: {} }]);
+      expect(tokens("${1" + op + "}")).toEqual([...param(op, 1, []), { Eof: {} }]);
+    });
+
+    test.each([
+      [
+        "unquoted whitespace",
+        "${X:-  a\tb\nc }",
+        [
+          { ParamSpace: "  " },
+          { Text: "a" },
+          { ParamSpace: "\t" },
+          { Text: "b" },
+          { ParamSpace: "\n" },
+          { Text: "c" },
+          { ParamSpace: " " },
+        ],
+      ],
+      [
+        "quotes",
+        "${X:-'a b' \"c d\"}",
+        [{ SingleQuotedText: "a b" }, { ParamSpace: " " }, { DoubleQuotedText: "c d" }],
+      ],
+      ["empty quotes", "${X:-''\"\"}", [{ SingleQuotedText: "" }, { DoubleQuotedText: "" }]],
+      [
+        "a } in quotes",
+        "${X:-a'b}c'\"d}e\"}",
+        [{ Text: "a" }, { SingleQuotedText: "b}c" }, { DoubleQuotedText: "d}e" }],
+      ],
+      ["escapes", "${X:-a\\}b\\ c\\$d}", [{ Text: "a}b c$d" }]],
+      ["a line continuation", "${X:-a\\\nb}", [{ Text: "ab" }]],
+      ["variables", "${X:-$Y$1 z}", [{ Var: "Y" }, { VarArgv: 1 }, { ParamSpace: " " }, { Text: "z" }]],
+      ["a variable in double quotes", '${X:-"$Y"}', [{ Var: "Y" }]],
+      ["a $ with no name", "${X:-$ $}", [{ Text: "$" }, { ParamSpace: " " }, { Text: "$" }]],
+      ["a glob", "${X:-*.js **}", [{ Asterisk: {} }, { Text: ".js" }, { ParamSpace: " " }, { DoubleAsterisk: {} }]],
+      [
+        "operators, braces and a # are text",
+        "${X:-a;b|c&df(g)#h{i,j[[k]]}",
+        [{ Text: "a;b|c&df(g)#h{i,j[[k]]" }],
+      ],
+      ["a tilde is text for the parser", "${X:-~/a}", [{ Text: "~/a" }]],
+      [
+        "$(...)",
+        "${X:-$(echo a) b}",
+        [
+          { CmdSubstBegin: {} },
+          { Text: "echo" },
+          { Delimit: {} },
+          { Text: "a" },
+          { Delimit: {} },
+          { CmdSubstEnd: {} },
+          { ParamSpace: " " },
+          { Text: "b" },
+        ],
+      ],
+      ["backticks", "${X:-`echo`}", [{ CmdSubstBegin: {} }, { Text: "echo" }, { Delimit: {} }, { CmdSubstEnd: {} }]],
+      [
+        "a nested form",
+        "${X:-a${Y:+b ${Z}}c}",
+        [{ Text: "a" }, ...param(":+", "Y", [{ Text: "b" }, { ParamSpace: " " }, { Var: "Z" }]), { Text: "c" }],
+      ],
+      ["a nested form that is not read keeps its }", "${X:-${Y%z}}", [{ Text: "$" }, { Text: "{Y%z}" }]],
+      ["an escaped nested form does not", "${X:-\\${Y}", [{ Text: "${Y" }]],
+    ])("the word: %s", (_name, source, word) => {
+      expect(tokens(source)).toEqual([...param(":-", "X", word), { Eof: {} }]);
+    });
+
+    // `$?` and the like are not read yet. What reads them must read them in a word too.
+    test.each(["$?", "$$", "$#", "$@", "$!", "$-", "$*", "$=", "$", "$NAME", "$1", "$9x", "${Y}", "${Y:-z}", "$(a)"])(
+      "the word: %s gives the tokens that it gives outside of a form",
+      dollar => {
+        const outside = tokens(dollar);
+        while ("Delimit" in outside.at(-1) || "Eof" in outside.at(-1)) outside.pop();
+        expect(tokens("${X:-" + dollar + "}")).toEqual([...param(":-", "X", outside), { Eof: {} }]);
+      },
+    );
+
+    test.each([
+      ["text and whitespace", '"${X:-a  b}"', [{ DoubleQuotedText: "a  b" }]],
+      ["single quotes are text", '"${X:-\'a}"', [{ DoubleQuotedText: "'a" }]],
+      ["inner double quotes keep a }", '"${X:-a"b}c"d}"', [{ DoubleQuotedText: "ab}cd" }]],
+      ["a backslash keeps a }", '"${X:-a\\}b}"', [{ DoubleQuotedText: "a}b" }]],
+      ["a glob is text", '"${X:-*}"', [{ DoubleQuotedText: "*" }]],
+      [
+        "$(...)",
+        '"${X:-$(echo)}"',
+        [{ CmdSubstBegin: {} }, { CmdSubstQuoted: {} }, { Text: "echo" }, { Delimit: {} }, { CmdSubstEnd: {} }],
+      ],
+    ])("the word in double quotes: %s", (_name, source, word) => {
+      expect(tokens(source)).toEqual([...param(":-", "X", word), { Eof: {} }]);
+    });
+
+    test.each([
+      [
+        "inside a word",
+        "pre${X:-d}post",
+        [{ Text: "pre" }, ...param(":-", "X", [{ Text: "d" }]), { Text: "post" }, { Delimit: {} }, { Eof: {} }],
+      ],
+      [
+        "before a space",
+        "${X:-a} b",
+        [...param(":-", "X", [{ Text: "a" }]), { Delimit: {} }, { Text: "b" }, { Delimit: {} }, { Eof: {} }],
+      ],
+      [
+        "before a semicolon",
+        "${X:-a};b",
+        [...param(":-", "X", [{ Text: "a" }]), { Semicolon: {} }, { Text: "b" }, { Delimit: {} }, { Eof: {} }],
+      ],
+      [
+        "before a pipe",
+        "${X:-a}|b",
+        [
+          ...param(":-", "X", [{ Text: "a" }]),
+          { Delimit: {} },
+          { Pipe: {} },
+          { Text: "b" },
+          { Delimit: {} },
+          { Eof: {} },
+        ],
+      ],
+      [
+        "two forms",
+        "${X:-a}${Y:-b}",
+        [...param(":-", "X", [{ Text: "a" }]), ...param(":-", "Y", [{ Text: "b" }]), { Eof: {} }],
+      ],
+      [
+        "in $(...)",
+        "$(echo ${X:-a})",
+        [
+          { CmdSubstBegin: {} },
+          { Text: "echo" },
+          { Delimit: {} },
+          ...param(":-", "X", [{ Text: "a" }]),
+          { Delimit: {} },
+          { CmdSubstEnd: {} },
+          { Eof: {} },
+        ],
+      ],
+      [
+        "in a script that is not ASCII",
+        "é${X:-ü ö}é",
+        [
+          { Text: "é" },
+          ...param(":-", "X", [{ Text: "ü" }, { ParamSpace: " " }, { Text: "ö" }]),
+          { Text: "é" },
+          { Delimit: {} },
+          { Eof: {} },
+        ],
+      ],
+    ])("a form beside other tokens: %s", (_name, source, expected) => {
+      expect(tokens(source)).toEqual(expected);
+    });
+
+    const literal = (...inside: object[]) => [
+      { Text: "$" },
+      { BraceBegin: {} },
+      ...inside,
+      { BraceEnd: {} },
+      { Eof: {} },
+    ];
+    test.each([
+      ["an escaped $", "\\${X}", literal({ Text: "X" })],
+      ["an escaped {", "$\\{X}", [{ Text: "$" }, { Text: "{X" }, { BraceEnd: {} }, { Eof: {} }]],
+      ["single quotes", "'${X}'", [{ SingleQuotedText: "${X}" }, { Eof: {} }]],
+      ["an escaped $ in double quotes", '"\\${X}"', [{ DoubleQuotedText: "${X}" }, { Eof: {} }]],
+      ["an escaped $ before an operator", "\\${X:-d}", literal({ Text: "X:-d" })],
+      [
+        "an escaped { before an operator",
+        "$\\{X:-d}",
+        [{ Text: "$" }, { Text: "{X:-d" }, { BraceEnd: {} }, { Eof: {} }],
+      ],
+      ["an operator in single quotes", "'${X:-d}'", [{ SingleQuotedText: "${X:-d}" }, { Eof: {} }]],
+    ])("stays text, as in sh: %s", (_name, source, expected) => {
+      expect(tokens(source)).toEqual(expected);
+    });
+
+    // sh expands or rejects each of these. Bun's shell does not read them:
+    // the `$` is text and the rest is lexed as ordinary shell syntax.
+    test.each([
+      ["a suffix pattern", "${X%a}", literal({ Text: "X%a" })],
+      ["a substitution", "${X/a/b}", literal({ Text: "X/a/b" })],
+      ["a substring", "${X:1:2}", literal({ Text: "X:1:2" })],
+      ["a : with no operator", "${X:}", literal({ Text: "X:" })],
+      ["a length", "${#X}", literal({ Text: "#X" })],
+      ["an indirection", "${!X}", literal({ Text: "!X" })],
+      ["two digits", "${10}", literal({ Text: "10" })],
+      ["two digits before an operator", "${10:-a}", literal({ Text: "10:-a" })],
+      ["no name", "${}", literal()],
+      ["no name before an operator", "${:-a}", literal({ Text: ":-a" })],
+      ["a digit then a letter", "${1a}", literal({ Text: "1a" })],
+      ["a name that is not ASCII", "${é}", literal({ Text: "é" })],
+      ["spaces around the name", "${ X }", literal({ Delimit: {} }, { Text: "X" }, { Delimit: {} })],
+      ["a backslash in the name", "${F\\OO}", literal({ Text: "FOO" })],
+      ["a brace group after $", "${a,b}", literal({ Text: "a" }, { Comma: {} }, { Text: "b" })],
+      ["$?", "$?", [{ Text: "$" }, { Text: "?" }, { Delimit: {} }, { Eof: {} }]],
+      ["$$", "$$", [{ Text: "$" }, { Text: "$" }, { Eof: {} }]],
+    ])("not read, tokens unchanged: %s", (_name, source, expected) => {
+      expect(tokens(source)).toEqual(expected);
+    });
+
+    // A script with a form that does not end is lexed with every `${` as text.
+    test.each([
+      ["no closing brace", "${X", [{ Text: "$" }, { BraceBegin: {} }, { Text: "X" }, { Delimit: {} }, { Eof: {} }]],
+      [
+        "no closing brace after a word",
+        "${X:-a",
+        [{ Text: "$" }, { BraceBegin: {} }, { Text: "X:-a" }, { Delimit: {} }, { Eof: {} }],
+      ],
+      [
+        "no closing brace in double quotes",
+        '"${X:-a',
+        [{ DoubleQuotedText: "$" }, { DoubleQuotedText: "{X:-a" }, { Delimit: {} }, { Eof: {} }],
+      ],
+      [
+        "the closing brace is in quotes",
+        "${X:-'a}",
+        [{ Text: "$" }, { BraceBegin: {} }, { Text: "X:-" }, { SingleQuotedText: "a}" }, { Delimit: {} }, { Eof: {} }],
+      ],
+      [
+        "a form that ends, before one that does not",
+        "${A:-b} ${X:-a",
+        [
+          { Text: "$" },
+          { BraceBegin: {} },
+          { Text: "A:-b" },
+          { BraceEnd: {} },
+          { Delimit: {} },
+          { Text: "$" },
+          { BraceBegin: {} },
+          { Text: "X:-a" },
+          { Delimit: {} },
+          { Eof: {} },
+        ],
+      ],
+      [
+        "no closing brace in $(...)",
+        "$(echo ${X:-a)",
+        [
+          { CmdSubstBegin: {} },
+          { Text: "echo" },
+          { Delimit: {} },
+          { Text: "$" },
+          { BraceBegin: {} },
+          { Text: "X:-a" },
+          { Delimit: {} },
+          { CmdSubstEnd: {} },
+          { Eof: {} },
+        ],
+      ],
+      [
+        "no closing brace in backticks",
+        "`echo ${X:-a`",
+        [
+          { CmdSubstBegin: {} },
+          { Text: "echo" },
+          { Delimit: {} },
+          { Text: "$" },
+          { BraceBegin: {} },
+          { Text: "X:-a" },
+          { Delimit: {} },
+          { CmdSubstEnd: {} },
+          { Eof: {} },
+        ],
+      ],
+      [
+        "a backtick in the word ends the backticks around the form",
+        "`echo ${X:-a`true`echo b}`",
+        [
+          { CmdSubstBegin: {} },
+          { Text: "echo" },
+          { Delimit: {} },
+          { Text: "$" },
+          { BraceBegin: {} },
+          { Text: "X:-a" },
+          { Delimit: {} },
+          { CmdSubstEnd: {} },
+          { CmdSubstBegin: {} },
+          { Text: "trueecho" },
+          { Delimit: {} },
+          { Text: "b" },
+          { BraceEnd: {} },
+          { Delimit: {} },
+          { CmdSubstEnd: {} },
+          { Eof: {} },
+        ],
+      ],
+      [
+        "no closing brace for ${NAME} in $(...)",
+        "$(echo ${X)",
+        [
+          { CmdSubstBegin: {} },
+          { Text: "echo" },
+          { Delimit: {} },
+          { Text: "$" },
+          { BraceBegin: {} },
+          { Text: "X" },
+          { Delimit: {} },
+          { CmdSubstEnd: {} },
+          { Eof: {} },
+        ],
+      ],
+    ])("a form that does not end is text: %s", (_name, source, expected) => {
+      expect(tokens(source)).toEqual(expected);
+    });
+
+    test("a script with a form that does not end has the error it had as text", () => {
+      expect(() => lex({ raw: ["${X:-$(echo}"] })).toThrow("Unclosed command substitution");
+      expect(() => lex({ raw: ["${X:-a} |& b"] })).toThrow("Piping stdout and stderr (`|&`) is not supported yet.");
+    });
+
+    // As text, a `${NAME:?}` guard stops nothing. So a script with a guard is not lexed again.
+    test.each([
+      ["the guard before a form that does not end", 'true "${X:?}"; echo ${Y:-z'],
+      ["the guard after a form that does not end", "echo ${Y:-z; echo ${X?need X}"],
+      ["the guard that does not end", "echo ${X:?need X"],
+      ["the guard in the form that does not end", "echo ${Y:-${X?}"],
+      ["a backtick in a word in backticks", "${X:?}; `echo ${Y:-a`"],
+    ])("a form that does not end is an error in a script with a ? form: %s", (_name, source) => {
+      expect(() => lex({ raw: [source] })).toThrow("Unclosed parameter expansion");
+    });
+
+    test("the errors of a script with a ? form", () => {
+      expect(() => lex({ raw: ["${X:?}", ""] }, new Uint8Array(1))).not.toThrow();
+      expect(() => lex({ raw: ["${X:?} ${Y:-", "}"] }, new Uint8Array(1))).toThrow(
+        "JS object reference not allowed in a parameter expansion",
+      );
+      const repeat = (text: string, count: number) => Buffer.alloc(text.length * count, text).toString();
+      expect(() => lex({ raw: [repeat("${A?", 129) + repeat("}", 129)] })).toThrow("SubshellDepthExceeded");
+    });
+
+    test("forms nest to the depth of $(...), and deeper forms are text", () => {
+      const forms = (source: string) => tokens(source).filter((token: object) => "ParamBegin" in token);
+      const repeat = (text: string, count: number) => Buffer.alloc(text.length * count, text).toString();
+      const nested = (depth: number) => repeat("${A:-", depth) + "x" + repeat("}", depth);
+      expect(forms(nested(128))).toHaveLength(128);
+      expect(forms(nested(129))).toHaveLength(0);
+      // A form counts as one level of $(...).
+      const inSubst = (depth: number) => repeat("$(echo ", depth) + "${A:-x}" + repeat(")", depth);
+      expect(forms(inSubst(127))).toHaveLength(1);
+      expect(forms(inSubst(128))).toHaveLength(0);
+    });
+
+    // An interpolated value is data. It never supplies the name, the operator
+    // or the closing brace of a form that the template opens.
+    test.each([
+      ["the whole name", ["echo ${", "}"], "X", literal({ Text: "X" })],
+      ["the end of the name", ["echo ${FOO", "}"], "BAR", literal({ Text: "FOO" }, { Text: "BAR" })],
+      ["a digit", ["echo ${", "}"], 1, literal({ Text: "1" })],
+      ["the name, after an escaped $", ["echo \\${", "}"], "X", literal({ Text: "X" })],
+      ["the operator", ["echo ${FOO", "}"], ":-d", literal({ Text: "FOO" }, { Text: ":-d" })],
+      ["the end of the operator", ["echo ${FOO:", "}"], "-d", literal({ Text: "FOO:" }, { Text: "-d" })],
+      ["the word", ["echo ${FOO:-", "}"], "d", [...param(":-", "FOO", [{ Text: "d" }]), { Eof: {} }]],
+      [
+        "a word that looks like a tilde",
+        ["echo ${FOO:-", "}"],
+        "~",
+        [...param(":-", "FOO", [{ DoubleQuotedText: "~" }]), { Eof: {} }],
+      ],
+      [
+        "a word with a closing brace",
+        ["echo ${FOO:-", "}"],
+        "a}b",
+        [...param(":-", "FOO", [{ Text: "a}b" }]), { Eof: {} }],
+      ],
+      [
+        "a word with syntax",
+        ["echo ${FOO:-", "}"],
+        "$(x) `y` $Z *",
+        [...param(":-", "FOO", [{ Text: "$(x) `y` $Z *" }]), { Eof: {} }],
+      ],
+    ])("an interpolated value is not syntax of a form: %s", (_name, raw, value, word) => {
+      expect(JSON.parse(lex({ raw }, value))).toEqual([{ Text: "echo" }, { Delimit: {} }, ...word]);
+    });
+
+    // A value with no special character goes into the script as text. A value
+    // with one goes in as a reference. After any start of a `$` form, both
+    // must give the same tokens apart from text.
+    test("a value that is text and a value that is a reference give the same tokens after a $", () => {
+      const syntax = (source: string[], value: string) => {
+        try {
+          return JSON.parse(lex({ raw: source }, value))
+            .filter((token: object) => !Object.keys(token)[0].endsWith("Text"))
+            .map((token: object) => ("Var" in token || "ParamBegin" in token ? token : Object.keys(token)[0]));
+        } catch (error) {
+          return String(error);
+        }
+      };
+      // A byte that the lexer learns to read after `${NAME` must be here, and in `ends_in_param_head`.
+      const alphabet = ["{", "}", "?", ":", "-", "+", "=", "a", "1", "#", "!", "@", '"', "%", "/", "^"];
+      const short = ["", ...alphabet, ...alphabet.flatMap(first => alphabet.map(second => first + second))];
+      const tails = ["", "{a", "{1"].flatMap(head => short.map(tail => head + tail));
+      const differences = tails.filter(tail => {
+        const source = ["echo $" + tail, "}"];
+        return JSON.stringify(syntax(source, "bc")) !== JSON.stringify(syntax(source, "b c"));
+      });
+      expect(differences).toEqual([]);
+    });
+  });
+
   describe("errors", async () => {
     // This is disallowed because the js object references get turned into special vars: $__bun_0, $__bun_1, etc.
     // this will break things inside of a quote.
diff --git a/test/js/bun/shell/parse.test.ts b/test/js/bun/shell/parse.test.ts
index ac77b69d54b6..a5fc4551519f 100644
--- a/test/js/bun/shell/parse.test.ts
+++ b/test/js/bun/shell/parse.test.ts
@@ -511,6 +511,102 @@ describe("parse shell", () => {
     expect(result).toEqual(expected);
   });
 
+  // `${` in a JS template literal starts an interpolation, so the sources
+  // here go to the parser as a raw array.
+  describe("${...}", () => {
+    const cmd = (source: string) => JSON.parse(parse({ raw: [source] })).stmts[0].exprs[0].cmd;
+    const args = (source: string) => cmd("echo " + source).name_and_args.slice(1);
+    const compound = (atoms: object[], glob_hint = false) => ({
+      compound: { atoms, brace_expansion_hint: false, glob_hint },
+    });
+    const param = (name: string, op: string, word: object[]) => ({ param: { name, op, word } });
+
+    test("${NAME} is the atom of $NAME", () => {
+      expect(args("${X} ${1} a${X}b")).toEqual([
+        { simple: { Var: "X" } },
+        { simple: { VarArgv: 1 } },
+        compound([{ Text: "a" }, { Var: "X" }, { Text: "b" }]),
+      ]);
+    });
+
+    test.each([":-", "-", ":+", "+", ":=", "=", ":?", "?"])("${NAME%sword}", op => {
+      expect(args("${X" + op + "a}")).toEqual([{ simple: param("X", op, [{ Text: "a" }]) }]);
+    });
+
+    test("the parameter, the quotes and the parts of the word", () => {
+      expect(args('"${1:?no $Y}" a${X:-}b ${X:-\'\'"q"$(echo) c}')).toEqual([
+        { simple: { param: { argv: 1, op: ":?", word: [{ Text: "no " }, { Var: "Y" }] } } },
+        compound([{ Text: "a" }, param("X", ":-", []), { Text: "b" }]),
+        {
+          simple: param("X", ":-", [
+            { quoted_empty: {} },
+            { Text: "q" },
+            { cmd_subst: { script: { stmts: [{ exprs: [{ cmd: cmd("echo") }] }] }, quoted: false } },
+            { param_space: " " },
+            { Text: "c" },
+          ]),
+        },
+      ]);
+    });
+
+    test("a * in the word gives the atom the glob hint", () => {
+      expect(args("${X:-*} x${Y:+${X:-a **}} ${X:-a b}")).toEqual([
+        compound([param("X", ":-", [{ asterisk: {} }])], true),
+        compound(
+          [
+            { Text: "x" },
+            param("Y", ":+", [param("X", ":-", [{ Text: "a" }, { param_space: " " }, { double_asterisk: {} }])]),
+          ],
+          true,
+        ),
+        { simple: param("X", ":-", [{ Text: "a" }, { param_space: " " }, { Text: "b" }]) },
+      ]);
+      expect(args('"${X:-a b}" "${X:-*}"')).toEqual([
+        { simple: param("X", ":-", [{ Text: "a b" }]) },
+        { simple: param("X", ":-", [{ Text: "*" }]) },
+      ]);
+    });
+
+    // As in sh: the `~` is `$HOME` when it is the whole word or a `/` follows it.
+    test("a tilde starts the word", () => {
+      expect(args("${X:-~} ${X:-~/a~} ${X:=~}")).toEqual([
+        { simple: param("X", ":-", [{ tilde: {} }]) },
+        { simple: param("X", ":-", [{ tilde: {} }, { Text: "/a~" }]) },
+        { simple: param("X", ":=", [{ tilde: {} }]) },
+      ]);
+      expect(args('${X:-a~} ${X:-~a} ${X:-~$Y} ${X:-~ a} ${X:-~""} ${X:-~"/a"} "${X:-~}"')).toEqual([
+        { simple: param("X", ":-", [{ Text: "a~" }]) },
+        { simple: param("X", ":-", [{ Text: "~a" }]) },
+        { simple: param("X", ":-", [{ Text: "~" }, { Var: "Y" }]) },
+        { simple: param("X", ":-", [{ Text: "~" }, { param_space: " " }, { Text: "a" }]) },
+        { simple: param("X", ":-", [{ Text: "~" }, { quoted_empty: {} }]) },
+        { simple: param("X", ":-", [{ Text: "~" }, { Text: "/a" }]) },
+        { simple: param("X", ":-", [{ Text: "~" }]) },
+      ]);
+    });
+
+    test("in an assignment value and a redirect target", () => {
+      const parsed = cmd("A=${X:-a} B=b${X:+c} echo > ${Y:-f}");
+      expect(parsed.assigns).toEqual([
+        { label: "A", value: { simple: param("X", ":-", [{ Text: "a" }]) } },
+        { label: "B", value: compound([{ Text: "b" }, param("X", ":+", [{ Text: "c" }])]) },
+      ]);
+      expect(parsed.redirect_file).toEqual({ atom: { simple: param("Y", ":-", [{ Text: "f" }]) } });
+    });
+
+    // A form whose word does not parse makes the parser read the script with every `${` as text.
+    test("a script that does not parse with a form is parsed as text", () => {
+      const source = "A=${X:-a; echo b} > f";
+      expect(JSON.parse(parse({ raw: [source] })).stmts[0].exprs[0].assign).toEqual([
+        { label: "A", value: compound([{ Text: "$" }, { brace_begin: {} }, { Text: "X:-a" }]) },
+      ]);
+      // With a `?` form in the script, the error of the pass that read the forms is the result.
+      expect(() => parse({ raw: ["echo ${G?}; " + source] })).toThrow(
+        'expected a command or assignment but got: "Redirect"',
+      );
+    });
+  });
+
   describe("if_clause", () => {
     test("basic", () => {
       const expected = {
diff --git a/test/js/bun/shell/yield.test.ts b/test/js/bun/shell/yield.test.ts
index 29d96e378199..39457b5c8ef9 100644
--- a/test/js/bun/shell/yield.test.ts
+++ b/test/js/bun/shell/yield.test.ts
@@ -178,6 +178,14 @@ describe("yield", async () => {
       );
     });
 
+    // The `$(...)` is in the word of a `${NAMEword}`, which expands only because U is not set.
+    test.concurrent("in a $(...) in the word of a ${NAME:-word}", async () => {
+      await expectRejection(
+        '$`echo ${{ raw: "${U:-a $(echo x > " }}${new Blob(["x"])}${{ raw: "; echo y) b}" }}; echo no`.quiet().nothrow()',
+        builtin,
+      );
+    });
+
     // This one always rejected (the error comes straight out of `.run()`), but
     // the interpreter was never finished and kept the event loop alive forever.
     test.concurrent("first command, and the process still exits", async () => {
@@ -207,6 +215,31 @@ describe("yield", async () => {
       });
     });
 
+    // The message of a failed assignment value is not a thrown error: the exit code is 1, as with a stderr
+    // that takes the write, and the script continues.
+    test.concurrent.if(isLinux)("a failed write of the error of an assignment value is exit code 1", async () => {
+      await using proc = Bun.spawn({
+        cmd: child(`
+          const run = script => settle($\`\${{ raw: script }}\`.nothrow());
+          console.log(JSON.stringify([
+            await run("A=\${U:?x}"),
+            await run("A=\${U:?x} true"),
+            await run("A=\${U:?x}; A=\${U:?x} true; true"),
+          ]));
+        `),
+        env: bunEnv,
+        stdout: "pipe",
+        stderr: Bun.file("/dev/full"),
+      });
+
+      const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
+
+      expect({ result: stdout.trim(), exitCode }).toEqual({
+        result: JSON.stringify(["resolved 1", "resolved 1", "resolved 0"]),
+        exitCode: 0,
+      });
+    });
+
     // Once every member of a failed script has exited, the interpreter must
     // become collectable, not stay pinned until the process exits.
     test.concurrent("failed scripts are collected", async () => {

From 9c2022a2a1dd666552fed44696bd7900d328eb4e Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 06:42:02 +0000
Subject: [PATCH 02/10] shell: read the forms after a form past the nesting
 limit, lex again only after a whole pass

A `${NAMEword}` past the nesting limit of `$(...)` is text, and the
lexer reads on. A `${NAME:?word}` or a `${NAME?word}` there is the
error. The script is lexed again with every `${` as text only when the
first pass read the whole script, so a guard after the place where the
lexer stopped cannot become text.

`walk_words` does not start a `$(...)` whose script has no statement.
`Script::start` ends such a script before it returns, and `child_done`
would continue the walk inside that call. The parser gives no such
script today.

The glob of `src/*.ts` gives `src\x.ts` on Windows: the test expects
the separator of the platform.
---
 src/runtime/shell/states/Expansion.rs |  5 ++++-
 src/shell_parser/parse.rs             | 20 ++++++++++++--------
 test/js/bun/shell/bunshell.test.ts    | 17 +++++++++++++++--
 test/js/bun/shell/lex.test.ts         | 12 +++++++++++-
 4 files changed, 42 insertions(+), 12 deletions(-)

diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs
index 7e83da0cb06b..6a3b9cfa7ba6 100644
--- a/src/runtime/shell/states/Expansion.rs
+++ b/src/runtime/shell/states/Expansion.rs
@@ -715,7 +715,10 @@ impl Expansion {
                     }
                 }
                 ast::SimpleAtom::CmdSubst(sub) => {
-                    return Self::start_cmd_subst(interp, this, sub, Some(Box::new(words)));
+                    // A script with no statement ends inside `Script::start`. `child_done` would then continue this walk inside this call.
+                    if !sub.script.stmts.is_empty() {
+                        return Self::start_cmd_subst(interp, this, sub, Some(Box::new(words)));
+                    }
                 }
                 _ => {
                     let shell = me.base.shell();
diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs
index 97bd6b40c9f4..d9a479fbe6be 100644
--- a/src/shell_parser/parse.rs
+++ b/src/shell_parser/parse.rs
@@ -2352,7 +2352,7 @@ pub struct LexError {
 struct LexPass<'bump> {
     /// `Err`: the lexer stopped before the end of the script.
     result: Result, LexerError>,
-    /// The pass read a `${NAME}` or a `${NAMEword}`, and no `${NAME:?word}` or `${NAME?word}`.
+    /// The pass read the whole script, with a `${NAME}` or a `${NAMEword}` and no `${NAME:?word}` or `${NAME?word}`.
     can_be_text: bool,
 }
 
@@ -2376,7 +2376,7 @@ fn lex_pass<'bump>(
         lexer.param_heads = param_heads;
         let result = lexer.lex();
         LexPass {
-            can_be_text: lexer.forms_read == FormsRead::NoGuard,
+            can_be_text: result.is_ok() && lexer.forms_read == FormsRead::NoGuard,
             result: result.map(|()| lexer.get_result()),
         }
     }
@@ -3776,16 +3776,20 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
         } else {
             return Ok(false);
         };
-        // Set before the depth error below: `parse_script` reads it when this pass fails.
-        if op.is_some_and(|op| op.action == ast::ParamAction::Error) {
-            self.forms_read = FormsRead::Guard;
-        } else if self.forms_read == FormsRead::None {
-            self.forms_read = FormsRead::NoGuard;
-        }
+        let is_guard = op.is_some_and(|op| op.action == ast::ParamAction::Error);
         if op.is_some() && self.subshell_depth >= MAX_SUBSHELL_DEPTH {
+            // Past the nesting limit of `$(...)` a form is text. A guard is not: as text it stops nothing.
+            if !is_guard {
+                return Ok(false);
+            }
             self.add_error(b"Subshell nesting depth exceeded");
             return Err(LexerError::SubshellDepthExceeded);
         }
+        if is_guard {
+            self.forms_read = FormsRead::Guard;
+        } else if self.forms_read == FormsRead::None {
+            self.forms_read = FormsRead::NoGuard;
+        }
 
         if let Some(op) = op {
             self.tokens.push(Token::ParamBegin(op));
diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts
index 8ce23945e6e8..e2db067e5329 100644
--- a/test/js/bun/shell/bunshell.test.ts
+++ b/test/js/bun/shell/bunshell.test.ts
@@ -1738,6 +1738,9 @@ booga"
               '${U:-"" ""}',
               '${U:-"" }',
               '${U:-"" }{a,b}',
+              // A `~` before an expansion that gives nothing is no argument.
+              "~$U",
+              "~${HOME:+ x}",
             ]),
           ).toEqual([
             ["/home/user/.cache", "force"],
@@ -1750,6 +1753,8 @@ booga"
             ["", ""],
             [""],
             ["", "a", "b"],
+            [],
+            ["x"],
           ]);
         });
 
@@ -1767,7 +1772,7 @@ booga"
             ["${U:-~/bin /opt/bin}", "${U:-~/bin $(echo /opt/bin)}", ["/home/user/bin", "/opt/bin"]],
             ["${U:-~abc}", "${U:-~abc$(echo)}", ["~abc"]],
             ["${U:-~}", "${U:-~}$(true)", ["/home/user"]],
-            ["${U:-src/*.ts}", "${U:-$(echo src)/*.ts}", ["src/x.ts"]],
+            ["${U:-src/*.ts}", "${U:-$(echo src)/*.ts}", [join("src", "x.ts")]],
             ["${U:-a.*}", "${U:-$(echo a).*}", ["a.txt"]],
             ["${U:-*.txt}$E", "${U:-*.txt$(true)}", ["a.txt"]],
           ];
@@ -1915,6 +1920,7 @@ booga"
           .runAsTest("set is the value");
       });
 
+
       // sh reads the `A` of the same command. Bun's shell does not, for $A too.
       test("an assignment before a command is not read by a later one, like $NAME", async () => {
         const printB = "console.log(process.env.B)";
@@ -1951,11 +1957,18 @@ booga"
         const repeat = (text: string, count: number) => Buffer.alloc(text.length * count, text).toString();
         const nested = (depth: number) => ({ raw: repeat("${A:-", depth) + "x" + repeat("}", depth) });
         expect(await $`echo ${nested(128)}`.text()).toBe("x\n");
-        expect(await $`echo ${nested(129)}`.text()).toBe(nested(129).raw + "\n");
+        // A form past the limit is text.
+        expect(await $`echo ${nested(129)}`.text()).toBe("${A:-x}\n");
         // A form counts as one level of $(...).
         const inSubst = (depth: number) => ({ raw: repeat("$(echo ", depth) + "${A:-x}" + repeat(")", depth) });
         expect(await $`echo ${inSubst(127)}`.text()).toBe("x\n");
         expect(await $`echo ${inSubst(128)}`.text()).toBe("${A:-x}\n");
+        // The forms after it are read: a guard still stops its command.
+        const guard = { raw: 'true "${SHELL_TEST_UNSET:?}" && echo RAN' };
+        const { stdout, stderr, exitCode } = await $`echo ${nested(129)}; ${guard}`.nothrow().quiet();
+        expect(stderr.toString()).toBe("bun: SHELL_TEST_UNSET: parameter null or not set\n");
+        expect(stdout.toString()).toBe("${A:-x}\n");
+        expect(exitCode).toBe(1);
       });
     });
   });
diff --git a/test/js/bun/shell/lex.test.ts b/test/js/bun/shell/lex.test.ts
index e0673210f68e..2cba6978c454 100644
--- a/test/js/bun/shell/lex.test.ts
+++ b/test/js/bun/shell/lex.test.ts
@@ -1215,11 +1215,21 @@ describe("lex shell", () => {
       const repeat = (text: string, count: number) => Buffer.alloc(text.length * count, text).toString();
       const nested = (depth: number) => repeat("${A:-", depth) + "x" + repeat("}", depth);
       expect(forms(nested(128))).toHaveLength(128);
-      expect(forms(nested(129))).toHaveLength(0);
+      expect(forms(nested(129))).toHaveLength(128);
       // A form counts as one level of $(...).
       const inSubst = (depth: number) => repeat("$(echo ", depth) + "${A:-x}" + repeat(")", depth);
       expect(forms(inSubst(127))).toHaveLength(1);
       expect(forms(inSubst(128))).toHaveLength(0);
+      // The forms after a form that is text are read.
+      expect(forms(nested(129) + ' "${X:?}"')).toHaveLength(129);
+    });
+
+    // The lexer stopped before the end, so a guard can follow. The script is not lexed again with every `${` as text.
+    test("a $(...) past the nesting limit in the word of a form is an error", () => {
+      const repeat = (text: string, count: number) => Buffer.alloc(text.length * count, text).toString();
+      const tooDeep = repeat("${A:-", 128) + "$(echo x)" + repeat("}", 128);
+      expect(() => lex({ raw: [tooDeep] })).toThrow("SubshellDepthExceeded");
+      expect(() => lex({ raw: [tooDeep + '; true "${X:?}" && echo RAN'] })).toThrow("SubshellDepthExceeded");
     });
 
     // An interpolated value is data. It never supplies the name, the operator

From b5f7f4f920edc5978dbe4fad6b0411e5c14c01b9 Mon Sep 17 00:00:00 2001
From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com>
Date: Sat, 24 Oct 2026 09:12:29 +0000
Subject: [PATCH 03/10] [autofix.ci] apply automated fixes

---
 test/js/bun/shell/bunshell.test.ts | 1 -
 1 file changed, 1 deletion(-)

diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts
index e2db067e5329..3386518b817b 100644
--- a/test/js/bun/shell/bunshell.test.ts
+++ b/test/js/bun/shell/bunshell.test.ts
@@ -1920,7 +1920,6 @@ booga"
           .runAsTest("set is the value");
       });
 
-
       // sh reads the `A` of the same command. Bun's shell does not, for $A too.
       test("an assignment before a command is not read by a later one, like $NAME", async () => {
         const printB = "console.log(process.env.B)";

From ec45e73ebaeb61dcdd0b41d82697b6e7ae58139c Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 09:31:36 +0000
Subject: [PATCH 04/10] shell: compare a `{` in the word of a form with sh

The first `}` that is not quoted ends a `${NAMEword}`, also after a
`{` in the word. bash 5.2 and dash do the same: `X=v; echo ${X:-{}}`
prints `v}`. Two rows of the sh comparison table hold that.
---
 test/cli/run/run-shell.test.ts | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/test/cli/run/run-shell.test.ts b/test/cli/run/run-shell.test.ts
index 214d7f5366c8..31aa39efa4b5 100644
--- a/test/cli/run/run-shell.test.ts
+++ b/test/cli/run/run-shell.test.ts
@@ -220,6 +220,8 @@ const paramRows: ParamRow[] = [
   ["[[ ${X:-a b} == ab ]] && echo glued", "", false],
   ["true \"${X:?}\" && echo RAN", "", false],
   ["X=1; true \"${X:?}\" && echo RAN", "RAN\n"],
+  ["X=v; ./args.sh ${X:-{}} \"${X:-{}}\" ${X:+{m}}", "<{m}>\n"],
+  ["./args.sh ${X:={}}; ./args.sh \"$X\"", "<{}>\n<{>\n"],
 ];
 
 // `--shell=system` runs bash when there is one. bash 3.2 (macOS) and dash give other results for a few rows.

From 1bdcd1c0b57167c460fe794509dcde3e3eb920c6 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 11:07:11 +0000
Subject: [PATCH 05/10] ci: retrigger


From 3e9e0283e4fe22f67307eaf0cd39d7c4dcdc069b Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 11:16:02 +0000
Subject: [PATCH 06/10] shell: run the sh comparison rows with the names they
 read not set

A row of the table reads `X`, `Y`, `A`, `TURBO_HASH` and other names as
not set. The test now removes each name that a row reads from the
environment of the script, so a value from outside cannot change a row.
---
 test/cli/run/run-shell.test.ts | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/test/cli/run/run-shell.test.ts b/test/cli/run/run-shell.test.ts
index 31aa39efa4b5..5c49cbdc328b 100644
--- a/test/cli/run/run-shell.test.ts
+++ b/test/cli/run/run-shell.test.ts
@@ -280,10 +280,13 @@ describe.each(isWindows ? ["bun"] : ["bun", "system"])("package script with --sh
         "args.sh": `#!/bin/sh\nfor a in "$@"; do printf '<%s>' "$a"; done; printf '\\n'\n`,
       });
       chmodSync(join(String(dir), "args.sh"), 0o755);
+      // A row reads a name as not set until the row sets it.
+      const names = new Set(Array.from(script.matchAll(/\$\{?([A-Za-z_]\w*)/g), match => match[1]));
+      const notSet = Object.fromEntries(Array.from(names, name => [name, undefined]));
       await using proc = Bun.spawn({
         cmd: [bunExe(), "run", `--shell=${shell}`, "rows"],
         cwd: String(dir),
-        env: { ...bunEnv, HOME: "/home/user" },
+        env: { ...bunEnv, ...notSet, HOME: "/home/user" },
         stdout: "pipe",
         stderr: "ignore",
       });

From a3378e23a1323d1332a5e4c204daf3496ebfecc2 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sun, 1 Nov 2026 05:59:52 +0000
Subject: [PATCH 07/10] shell: no second pass after the parser runs out of
 stack, and tests that do not read the environment of the caller

`with_tokens` lexes a script again with every `${` as text only after a
failure that the text can repair. A `NestingTooDeep` from the stack
check of the parser is final: as text the script can parse, with
another meaning.

The tests of `${NAME}` and `${NAMEword}` remove the names that they
read as not set from the environment of the script, so a value from
the caller cannot change a result. The sh comparison reads the bash
version as two integers.
---
 src/shell_parser/parse.rs          | 60 +++++++++++++++++++-----------
 test/cli/run/run-shell.test.ts     | 18 +++++----
 test/js/bun/shell/bunshell.test.ts |  6 +++
 test/js/bun/shell/exec.test.ts     | 15 +++++---
 test/js/bun/shell/yield.test.ts    |  4 +-
 5 files changed, 65 insertions(+), 38 deletions(-)

diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs
index d9a479fbe6be..35cf50aa1a8e 100644
--- a/src/shell_parser/parse.rs
+++ b/src/shell_parser/parse.rs
@@ -2387,6 +2387,23 @@ fn lex_pass<'bump>(
     }
 }
 
+/// Whether a second pass, with every `${` as text, can give another result than the pass that failed.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Retry {
+    AsText,
+    /// The parser ran out of stack. As text the script can parse, with another meaning.
+    Never,
+}
+
+impl Retry {
+    fn after(error: crate::Error) -> Retry {
+        match error {
+            crate::Error::NestingTooDeep => Retry::Never,
+            _ => Retry::AsText,
+        }
+    }
+}
+
 /// Gives the tokens of `src` to `parse`. When that fails for a pass that `can_be_text`, every `${` is text in a second pass.
 #[inline]
 fn with_tokens<'bump, T, E>(
@@ -2394,15 +2411,16 @@ fn with_tokens<'bump, T, E>(
     src: &'bump [u8],
     strings_to_escape: &'bump [BunString],
     jsobjs_len: u32,
-    mut parse: impl FnMut(Result, LexerError>) -> Result,
+    mut parse: impl FnMut(Result, LexerError>) -> Result,
 ) -> Result {
     let pass = lex_pass(bump, src, strings_to_escape, jsobjs_len, true);
-    match parse(pass.result) {
-        Err(_) if pass.can_be_text => {
+    let parsed = match parse(pass.result) {
+        Err((Retry::AsText, _)) if pass.can_be_text => {
             parse(lex_pass(bump, src, strings_to_escape, jsobjs_len, false).result)
         }
         parsed => parsed,
-    }
+    };
+    parsed.map_err(|(_, error)| error)
 }
 
 /// Lexes and parses `src`. On `Err`, `out_lex_result` holds the errors of the lexer or `out_parser` those of the parser.
@@ -2419,12 +2437,15 @@ where
 {
     with_tokens(bump, src, strings_to_escape, jsobjs_len, |lexed| {
         (*out_parser, *out_lex_result) = (None, None);
-        let lex_result = lexed?;
+        let lex_result = lexed.map_err(|error| (Retry::AsText, error.into()))?;
         if !lex_result.errors.is_empty() {
             *out_lex_result = Some(lex_result);
-            return Err(ParseError::Lex.into());
+            return Err((Retry::AsText, ParseError::Lex.into()));
         }
-        Ok(out_parser.insert(Parser::new(bump, lex_result)).parse()?)
+        out_parser
+            .insert(Parser::new(bump, lex_result))
+            .parse()
+            .map_err(|error| (Retry::after(error), error.into()))
     })
 }
 
@@ -2435,21 +2456,16 @@ pub fn lex_script<'bump>(
     strings_to_escape: &'bump [BunString],
     jsobjs_len: u32,
 ) -> Result, LexerError> {
-    let parsed = with_tokens(
-        bump,
-        src,
-        strings_to_escape,
-        jsobjs_len,
-        |lexed| match lexed {
-            Ok(lex_result)
-                if lex_result.errors.is_empty()
-                    && Parser::new(bump, lex_result).parse().is_ok() =>
-            {
-                Ok(lex_result)
-            }
-            failed => Err(failed),
-        },
-    );
+    let parsed = with_tokens(bump, src, strings_to_escape, jsobjs_len, |lexed| {
+        let lex_result = match lexed {
+            Ok(lex_result) if lex_result.errors.is_empty() => lex_result,
+            failed => return Err((Retry::AsText, failed)),
+        };
+        match Parser::new(bump, lex_result).parse() {
+            Ok(_) => Ok(lex_result),
+            Err(error) => Err((Retry::after(error), Ok(lex_result))),
+        }
+    });
     parsed.or_else(|failed| failed)
 }
 
diff --git a/test/cli/run/run-shell.test.ts b/test/cli/run/run-shell.test.ts
index 5c49cbdc328b..765760df3268 100644
--- a/test/cli/run/run-shell.test.ts
+++ b/test/cli/run/run-shell.test.ts
@@ -228,10 +228,15 @@ const paramRows: ParamRow[] = [
 const bashVersion = await (async () => {
   const bash = Bun.which("bash", { PATH: bunEnv.PATH });
   if (!bash) return 0;
-  await using proc = Bun.spawn({ cmd: [bash, "-c", 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'], stdout: "pipe" });
-  return Number(await proc.stdout.text());
+  await using proc = Bun.spawn({ cmd: [bash, "-c", 'echo "${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}"'], stdout: "pipe" });
+  const [major, minor] = (await proc.stdout.text()).trim().split(" ").map(Number);
+  return major * 100 + minor;
 })();
 
+// Each name that `script` reads, as not set in the environment. A row reads a name as not set until the row sets it.
+const notSetIn = (script: string) =>
+  Object.fromEntries(Array.from(script.matchAll(/\$\{?([A-Za-z_]\w*)/g), match => [match[1], undefined]));
+
 // Bun's shell runs package.json scripts by default on Windows. The system
 // shell rows show that both shells give the same output.
 describe.each(isWindows ? ["bun"] : ["bun", "system"])("package script with --shell=%s", shell => {
@@ -242,7 +247,7 @@ describe.each(isWindows ? ["bun"] : ["bun", "system"])("package script with --sh
     await using proc = Bun.spawn({
       cmd: [bunExe(), "run", `--shell=${shell}`, "braced", ...args],
       cwd: String(dir),
-      env: { ...bunEnv, ...env },
+      env: { ...bunEnv, ...notSetIn(script), ...env },
       stdout: "pipe",
       stderr: "pipe",
     });
@@ -271,7 +276,7 @@ describe.each(isWindows ? ["bun"] : ["bun", "system"])("package script with --sh
   });
 
   // Each row runs in a subshell of one script, so that no row changes a variable of another row.
-  test.skipIf(isWindows || (shell === "system" && bashVersion < 5.2))(
+  test.skipIf(isWindows || (shell === "system" && bashVersion < 502))(
     "${NAMEword} gives what sh gives",
     async () => {
       const script = paramRows.map(([row]) => `echo @@row; (\n${row}\n) && echo @@ok || echo @@fail`).join("\n");
@@ -280,13 +285,10 @@ describe.each(isWindows ? ["bun"] : ["bun", "system"])("package script with --sh
         "args.sh": `#!/bin/sh\nfor a in "$@"; do printf '<%s>' "$a"; done; printf '\\n'\n`,
       });
       chmodSync(join(String(dir), "args.sh"), 0o755);
-      // A row reads a name as not set until the row sets it.
-      const names = new Set(Array.from(script.matchAll(/\$\{?([A-Za-z_]\w*)/g), match => match[1]));
-      const notSet = Object.fromEntries(Array.from(names, name => [name, undefined]));
       await using proc = Bun.spawn({
         cmd: [bunExe(), "run", `--shell=${shell}`, "rows"],
         cwd: String(dir),
-        env: { ...bunEnv, ...notSet, HOME: "/home/user" },
+        env: { ...bunEnv, ...notSetIn(script), HOME: "/home/user" },
         stdout: "pipe",
         stderr: "ignore",
       });
diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts
index 3386518b817b..cc1b8a03f2a3 100644
--- a/test/js/bun/shell/bunshell.test.ts
+++ b/test/js/bun/shell/bunshell.test.ts
@@ -41,6 +41,12 @@ export const bunEnv: NodeJS.ProcessEnv = {
   PWD: (process.env.PWD || process.cwd()).replaceAll("\\", "/"),
 };
 
+// The parameter expansion tests read these names as not set.
+const notSet = ["A", "B", "BAR", "C", "E", "EMPTY", "FOO", "NONE", "S", "U", "V", "VALUE", "W"];
+for (const name of Object.keys(bunEnv)) {
+  if (notSet.includes(name) || name.startsWith("SHELL_TEST_")) delete bunEnv[name];
+}
+
 $.env(bunEnv);
 $.cwd(process.cwd().replaceAll("\\", "/"));
 $.nothrow();
diff --git a/test/js/bun/shell/exec.test.ts b/test/js/bun/shell/exec.test.ts
index b94cda05656a..a9008c5f186f 100644
--- a/test/js/bun/shell/exec.test.ts
+++ b/test/js/bun/shell/exec.test.ts
@@ -27,38 +27,41 @@ describe("bun exec", () => {
     .stdout('hi "there bud"\n')
     .runAsTest("it works2");
 
+  // The scripts read NONE as not set.
+  const paramEnv = { ...bunEnv, NONE: undefined };
+
   TestBuilder.command`${BUN} exec ${'FOO=bar; echo ${FOO} "${FOO}" ${FOO}baz'}`
-    .env(bunEnv)
+    .env(paramEnv)
     .stdout("bar bar barbaz\n")
     .runAsTest("expands ${NAME}");
 
   TestBuilder.command`${BUN} exec ${'FOO=bar; echo ${FOO:-none} ${NONE:-"a  b"} ${FOO:+set}; echo ${NONE:=made} $NONE'}`
-    .env(bunEnv)
+    .env(paramEnv)
     .stdout("bar a  b set\nmade made\n")
     .runAsTest("expands ${NAMEword}");
 
   TestBuilder.command`${BUN} exec ${'true "${NONE:?need NONE}" && echo RAN'}`
-    .env(bunEnv)
+    .env(paramEnv)
     .stderr("bun: NONE: need NONE\n")
     .exitCode(1)
     .runAsTest("${NAME:?word} fails the command when NAME is not set");
 
   TestBuilder.command`${BUN} exec ${"ENV=${NONE:?} echo cmd"}`
-    .env(bunEnv)
+    .env(paramEnv)
     .stderr("bun: NONE: parameter null or not set\n")
     .exitCode(1)
     .runAsTest("${NAME:?word} in an assignment before a command");
 
   TestBuilder.command`${BUN} script.sh`
     .file("script.sh", "ENV=${NONE:?need NONE}\n")
-    .env(bunEnv)
+    .env(paramEnv)
     .stderr("bun: NONE: need NONE\n")
     .exitCode(1)
     .runAsTest("${NAME:?word} in an assignment in a .sh file");
 
   TestBuilder.command`${BUN} script.sh`
     .file("script.sh", 'FOO=bar\necho ${FOO} "${FOO}" ${FOO}baz ${NONE:-é ${FOO:+ü}}\n')
-    .env(bunEnv)
+    .env(paramEnv)
     .stdout("bar bar barbaz é ü\n")
     .runAsTest("expands ${NAME} and ${NAMEword} in a .sh file");
 
diff --git a/test/js/bun/shell/yield.test.ts b/test/js/bun/shell/yield.test.ts
index 39457b5c8ef9..b6780a53a658 100644
--- a/test/js/bun/shell/yield.test.ts
+++ b/test/js/bun/shell/yield.test.ts
@@ -138,7 +138,7 @@ describe("yield", async () => {
           Bun.gc(true);
           console.log(JSON.stringify(out));
         `),
-        env: bunEnv,
+        env: { ...bunEnv, U: undefined },
         stdout: "pipe",
         stderr: "pipe",
       });
@@ -227,7 +227,7 @@ describe("yield", async () => {
             await run("A=\${U:?x}; A=\${U:?x} true; true"),
           ]));
         `),
-        env: bunEnv,
+        env: { ...bunEnv, U: undefined },
         stdout: "pipe",
         stderr: Bun.file("/dev/full"),
       });

From f8401c36efe5ed60c5cb82e7b06f8c8ca1236529 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sun, 1 Nov 2026 06:12:58 +0000
Subject: [PATCH 08/10] shell: skip the sh comparison when the bash version is
 not known

The probe gives 0 when its output is not two integers, so the rows for
the system shell do not run on a bash of an unknown version.
---
 test/cli/run/run-shell.test.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/cli/run/run-shell.test.ts b/test/cli/run/run-shell.test.ts
index 765760df3268..cab6772be683 100644
--- a/test/cli/run/run-shell.test.ts
+++ b/test/cli/run/run-shell.test.ts
@@ -230,7 +230,7 @@ const bashVersion = await (async () => {
   if (!bash) return 0;
   await using proc = Bun.spawn({ cmd: [bash, "-c", 'echo "${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}"'], stdout: "pipe" });
   const [major, minor] = (await proc.stdout.text()).trim().split(" ").map(Number);
-  return major * 100 + minor;
+  return Number.isInteger(major) && Number.isInteger(minor) ? major * 100 + minor : 0;
 })();
 
 // Each name that `script` reads, as not set in the environment. A row reads a name as not set until the row sets it.

From 7b03601c7a05212795c062fc5f1e6f9f41892833 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Mon, 2 Nov 2026 00:18:33 +0000
Subject: [PATCH 09/10] shell: a last field with no * is text only in a word
 with a form

A word with a * has one glob walk, for its last field. For a word with a
${NAMEword}, that field is text when it has no * of the script: the * is
then in a word that is not the result, or in a field that ended.

That rule was applied to every word, so `echo *$(echo a b)` printed `*a b`
with exit code 0. On main the walk is for `b`, and the command fails with
`no matches found: b`. A word with no form takes the path of main again:
do_brace_expand has its condition of main, and transition_to_glob_state asks
is_text_of_form.
---
 src/runtime/shell/states/Expansion.rs | 32 +++++++++++++++++++++------
 test/js/bun/shell/bunshell.test.ts    | 15 +++++++++++++
 2 files changed, 40 insertions(+), 7 deletions(-)

diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs
index 6a3b9cfa7ba6..ab438a4039db 100644
--- a/src/runtime/shell/states/Expansion.rs
+++ b/src/runtime/shell/states/Expansion.rs
@@ -393,7 +393,7 @@ impl Expansion {
 
         let node = me.node;
         let atom = node.get();
-        if atom.has_glob_expansion() && Self::has_glob_syntax(me) {
+        if atom.has_glob_expansion() {
             // brace + glob composes. Keep
             // `current_out` (the original pattern, e.g. `src/*.{ts,tsx}`) so
             // the glob walker brace-expands and globs it; its matches are
@@ -473,11 +473,24 @@ impl Expansion {
         pattern
     }
 
-    /// Whether the field in `current_out` has a `*` of the script. The hint of the atom is for the whole word.
-    fn has_glob_syntax(me: &Expansion) -> bool {
-        me.meta_offsets
+    /// Whether the field in `current_out` is text: it has no `*` of the script, and the atom has a `${NAMEword}`.
+    /// The `*` of the hint is then in a word that is not the result, or in a field that ended. With no form, the one
+    /// walk of the atom is for this field also when it has no `*`.
+    fn is_text_of_form(me: &Expansion) -> bool {
+        let has_glob_syntax = me
+            .meta_offsets
             .iter()
-            .any(|&offset| me.current_out.get(offset as usize) == Some(&b'*'))
+            .any(|&offset| me.current_out.get(offset as usize) == Some(&b'*'));
+        if has_glob_syntax {
+            return false;
+        }
+        match me.node.get() {
+            ast::Atom::Simple(atom) => matches!(atom, ast::SimpleAtom::Param(_)),
+            ast::Atom::Compound(compound) => compound
+                .atoms
+                .iter()
+                .any(|atom| matches!(atom, ast::SimpleAtom::Param(_))),
+        }
     }
 
     /// Kick off an off-thread
@@ -488,8 +501,13 @@ impl Expansion {
         let cwd: Vec;
         {
             let me = interp.as_expansion_mut(this);
-            if !Self::has_glob_syntax(me) {
-                Self::push_current_out(me);
+            if Self::is_text_of_form(me) {
+                if matches!(me.state, ExpansionState::Glob) {
+                    // `do_brace_expand` added the variants.
+                    me.current_out.clear();
+                } else {
+                    Self::push_current_out(me);
+                }
                 me.state = ExpansionState::Done;
                 return Yield::Next(this);
             }
diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts
index cc1b8a03f2a3..d6fa02c18bc5 100644
--- a/test/js/bun/shell/bunshell.test.ts
+++ b/test/js/bun/shell/bunshell.test.ts
@@ -1812,6 +1812,21 @@ booga"
           .stderr("bun: no matches found: *.none\n")
           .runAsTest("a * is a glob only in the word that is the result");
 
+        // The rule for the last field is for a word with a form. A word with no form has its one walk for the last field
+        // as before, also when the `*` is in a field before it. sh walks the pattern of each field.
+        test("a * in a word with no form is as before", async () => {
+          using dir = tempDir("shell-word-no-form", { "qa": "" });
+          const run = async (word: string) => {
+            const { stdout, stderr, exitCode } = await $`echo ${{ raw: word }}`.cwd(String(dir)).quiet();
+            return [stdout.toString(), stderr.toString(), exitCode];
+          };
+          expect([await run("*$(echo a b)"), await run("*$(echo a qa)"), await run("*$(echo a b){x,y}")]).toEqual([
+            ["", "bun: no matches found: b\n", 1],
+            ["*a qa\n", "", 0],
+            ["", "bun: no matches found: b{x,y}\n", 1],
+          ]);
+        });
+
         test("whitespace at the end of a word adds no empty argument", async () => {
           expect(await argvOf("${U:-a}${U:-b c}d ${U:-a }${U:-} ${U:- } x${U:-b } ${U:-$(echo c) }")).toEqual([
             "ab",

From 350f8fe2b4b6eeecac7afac05288cfd04409c3a0 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Mon, 2 Nov 2026 00:38:51 +0000
Subject: [PATCH 10/10] shell: one line for the comment of is_text_of_form

---
 src/runtime/shell/states/Expansion.rs | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs
index ab438a4039db..b4c974f9dfcf 100644
--- a/src/runtime/shell/states/Expansion.rs
+++ b/src/runtime/shell/states/Expansion.rs
@@ -473,9 +473,7 @@ impl Expansion {
         pattern
     }
 
-    /// Whether the field in `current_out` is text: it has no `*` of the script, and the atom has a `${NAMEword}`.
-    /// The `*` of the hint is then in a word that is not the result, or in a field that ended. With no form, the one
-    /// walk of the atom is for this field also when it has no `*`.
+    /// Whether the field in `current_out` is text: it has no `*` of the script, and the atom has a `${NAMEword}`. With no form, the walk of the atom is for this field also when it has no `*`.
     fn is_text_of_form(me: &Expansion) -> bool {
         let has_glob_syntax = me
             .meta_offsets