Redirects are bindings
Amended 2026-10-08. Redirects belong to the stage: every pipeline stage takes trailing redirects, and one AST node,
Ast::Redirected, holds them;CallandScopeno longer carry a field. The binding rule andRedirectStateare unchanged.
A redirect list binds each of stdin, stdout and stderr at most once, and one
interpreter, RedirectState, serves every shape that carries one. A stream
has one final destination, written once; nothing is opened only to be
overridden, and no position in the list means anything.
Context
Two interpreters read one redirect list and disagreed: a compound block and a fused external gave > a > b and 2>&1 > f different meanings. The language had no rule; it had two implementations.
What was decided
-
A list is a set of bindings.
Redirects<T>holdsstdin,stdoutandstderras options;Redirects::bindrefuses a second binding of a stream at parse time, caret on the second redirect: “standard output is redirected twice; which one do you mean?”, and its input and error counterparts.> a > b,2> e 2>&1,2>&1 2> eand< a << bare not programs. -
2>&1is position-free. It binds stderr to stdout’s destination, so2>&1 > fand> f 2>&1are one command. -
One interpreter.
RedirectState(runtime/redirect/scope.rs) opens the targets and installs the sinks for a fused external and aCompKind::Redirectblock alike; the IR keepsExec.redirectsand the redirect scope as two shapes, both read by it. Targets open in one fixed order — stdin, stdout, stderr — so the trail reads the same for both. -
An external gets a
>file as its own fd, with no pump, and2>&1stays adup2when stderr’s sink is the same destination as stdout’s (Sink::same_destination), solet x = sh -c 'echo err >&2; echo out' 2>&1is"err\nout".2>streams and is never atomic. -
A streaming write is
committedat its open, even if the body later fails; only an atomic>can beaborted.Amended 2026-10-06 by a-file-change-is-one-fact: the stream is still
committedwhatever the body does, but it is reported when the frame settles, so the report can carry what landed. -
A pipeline stage with redirects is a thread stage run through the same
RedirectState; a direct stage has none, andwire_stdiowires its stage pipes without reading any redirect list. -
Dup cycles cannot be written: the lexer refuses
1>&2, and an identity dup binds nothing.
Alternatives considered
- POSIX left-to-right fold. Faithful, and the source of
2>&1 > fversus> f 2>&1meaning different things for reasons a reader must simulate. It also opensain> a > bto truncate it for nobody. The fold is shell machinery ral exists to be free of. - Last wins. Total, so nothing is refused, but the dead redirect still costs an open with side effects (a truncation, an audit observation), and a typo that doubles a redirect is silently accepted.
Consequences
- POSIX
cmd 2>&1 2>/dev/nullis refused; write2>/dev/null. - A list’s meaning is a function of its bindings, not of their order, and a block and an external read it the same way.
- The audit order of a write’s observation is canonical, and a streaming write is observed at open whether the body succeeds or not.
- An external writing to a file is that file’s fd: no pump thread stands between them.
See redirects-are-the-three-standard-streams
for the type, surface-syntax for where the refusal
is raised, and docs/SPEC.md §7.4 for the language statement.