Skip to content

feat: support interpolating a CommandBuilder into a template literal expression - #34

Merged
dsherret merged 16 commits into
mainfrom
feat-command-builder-interpolation
Jul 24, 2026
Merged

feat: support interpolating a CommandBuilder into a template literal expression#34
dsherret merged 16 commits into
mainfrom
feat-command-builder-interpolation

Conversation

@dsherret

Copy link
Copy Markdown
Owner

Implements dsherret/dax#239.

Interpolating a CommandBuilder into a $ template literal now substitutes its captured stdout, similar to $(...) command substitution:

const cmd = $`echo 1`;
await $`echo ${cmd}`; // 1
await $`echo 'example ${cmd} example'`; // works inside quotes
await $`cat < ${cmd}`; // and as an input redirect

How it works

  • Template layer: an interpolated builder is registered in a commandRefs map on the command state and a sentinel token (\0<nonce>:<id>\0) is emitted into the command text. The nonce is per-process random so escaped user text can't forge one, and NUL passes through the parser as literal text in bare, single-quoted, and double-quoted contexts.
  • Post-parse rewrite: after parsing, replaceCommandRefSentinels walks the AST and splits sentinel-bearing text parts into a new commandRef word part (including inside quoted parts, braces, subshells, redirect words, and env var values). No changes to the Rust parser.
  • Execution: when word evaluation reaches a commandRef, an invoker runs the builder with stdout piped. Evaluation is lazy and sequential, matching how words evaluate today — exit 1 && echo ${cmd} never runs cmd.

Semantics

  • output is whitespace-normalized the same way as $(...)
  • the builder executes with its own configuration (env, cwd, stdin, hooks); stdout is captured and an "inherit" stderr routes to the evaluating command's stderr
  • a failing interpolated command fails the outer command with its exit code and a stderr message like failed evaluating interpolated command `exit 5`. Exited with code: 5 — add .noThrow() to the interpolated command to tolerate failure
  • killing the outer command kills any in-flight interpolated command (linked through an intermediate KillController so kills only propagate outer → inner)
  • runs once per interpolation site
  • printCommand / tail headers display the sentinel as $(inner command text)

Also fixes a pre-existing gap where a ShellEvaluateError (e.g. failglob) thrown while evaluating a redirect word or a standalone VAR= assignment escaped the executor instead of becoming a command error, and gives ShellEvaluateError an exit code so interpolated command failures propagate theirs.

dsherret added 16 commits July 14, 2026 14:10
- propagate an already-aborted signal to interpolated commands (linkChild
  only forwards future kills)
- link input redirect interpolated commands to the evaluating command's
  signal and cancel their stream on dispose so they don't leak
- fix CommandBuilder.then leaving awaiters hanging on a synchronous throw
  (ex. no command set)
- unwrap the non-thenable proxy handed to beforeCommand callbacks when
  interpolated
- avoid a per-invocation listener leak when the interpolated builder has
  its own signal
- make an empty expanded command a no-op instead of crashing (pre-existing)
- make CommandBuilderStateCommand.commandRefs optional for downstream
  consumers constructing the type
- document word splitting and redirect-position semantics in the readme
Sentinel forgery: the parser echoes the command text back in its errors,
which disclosed the per-process nonce and made it possible to promote an
interpolated string to a command ref. Reject NUL in interpolated values and
in `command()` text (it's never valid in a command anyway), and resolve
sentinels in parse errors so they read as `$(...)` instead of raw NULs.

Input redirect parity: `< ${builder}` took a different route than argument
position and diverged from it. It now resolves async `beforeCommand` hooks
instead of hard-failing on them, routes the interpolated command's stderr to
the evaluating command's, reports failures through the shell (so `.noThrow()`
applies) with the interpolated command's exit code rather than the reading
command's, and only reports a status when the reading command consumed the
output to the end.

Process leaks: dispose the redirect pipe when the command throws rather than
completes, so an interpolated command isn't left running, and kill an
executable whose signal was already aborted when it started, since the
listener only receives future kills.

Also don't drop a `PipedBuffer` error or close that arrives before a listener
is attached (an early-failing command's stream never ended), treat a trailing
redirect operator within quotes as literal text rather than a redirect, and
route `inheritPiped` stderr like `inherit`.
… can't be streamed

`child.stdout()` throws from `#assertBufferStreamable` before it reaches
its own `this.catch()`, so a builder that can't expose a piped stdout (ex.
one using `captureCombined()`) left the already-spawned `CommandChild`
rejection unobserved. That surfaces as an unhandled rejection, which
terminates the process by default in both Deno and Node.
`detectInputOrOutputRedirect` rescanned the whole accumulated command text
once per interpolated expression, making building a command quadratic in
its length. A template with a few megabyte-sized expressions took hundreds
of milliseconds.

The scan is now a stateful scanner that only walks the text appended since
the previous expression, and the per-character `/\s/` test has an ascii
fast path.
…equence

The engine leaves a template's cooked segment undefined when its escape
sequences aren't valid JavaScript, which the recently added NUL check then
tripped over with `Cannot read properties of undefined`.
The empty command args case is currently only exercised through command
builder interpolation, but the same path is reached by a command
substitution or a variable expanding to nothing, which used to crash.
The catch-up paths that propagate a signal that was already aborted before
their listener was attached defaulted to SIGTERM, so an abort by another
signal (ex. SIGINT) lost its specific exit code. Retain the aborting signal
on the state and replay it instead.
…ommand

An interpolated command in an input redirect executes as `<&<fd>` to wire
its stream, which is what `printCommand` and the tail header showed. Rewrite
it back to `< $(...)` for display, matching how an interpolated command in
an argument renders.
The only caller (`Context.getFdReader`) always supplies them, so the
optional parameter left the reader factories guarding a case that couldn't
occur.
…subshell isolation

When a simple command's command word expands to nothing (ex. `FOO=1 $(true)`),
its variable assignments now affect the current execution environment as a bare
`FOO=1` assignment would, rather than being dropped.

Making that correct surfaced that subshells didn't isolate their body's changes:
`executeSubshell` ran the body in the parent context, so assignments, `cd`,
and `set`/`shopt` inside `( ... )` leaked out. The body now runs in a clone,
matching POSIX subshell semantics, and `Context.clone` copies the shell options
so an option change in the clone no longer mutates the parent by reference.
Resolving the ephemeral publish-on-tag tool from the project directory added
it (and `@std/semver`) to deno.lock, dirtying the tree so the `deno publish`
it invokes aborted.
Adding the tool to the import map pins it in deno.lock, so resolving it at
publish time no longer mutates the lockfile and dirties the tree. Reverts the
--no-lock workaround.
@dsherret
dsherret merged commit 5f10d58 into main Jul 24, 2026
4 checks passed
@dsherret
dsherret deleted the feat-command-builder-interpolation branch July 24, 2026 23:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant