Redirects are the three standard streams
ral models < and << on stdin, >, >> and >~ on stdout and on stderr,
and 2>&1. The type says so: Redirect<T> (core/src/syntax/ast.rs) is the
sum Stdin(StdinSource<T>) | Stdout(WriteMode, T) | Stderr(WriteMode, T) | StderrToStdout, one type for all three tiers, so a redirect outside the set is
not a value of any tier rather than a value checked again downstream.
ral has no fd plumbing. There is no exec 3< file, no {fd}>, no dup2 on a
user’s behalf: stdout and stderr are routed through the shell’s own Sinks,
never the process-global descriptors, because libtest, the REPL frontend and
sibling ral threads all share those (evaluator-machine).
An fd number is therefore not a general mechanism with three supported cases;
three streams are the whole of it, and an fd prefix in the source only names
one.
The fd number is spelling, eliminated at the lexer. The vocabulary is
exactly nine spellings: < f, << str, > f, >> f, >~ f, 2> f,
2>> f, 2>~ f and 2>&1. A digit run glued to > or < is read whole and
judged; the token stream never carries an fd number. Token::Redirect { stderr, op } is a word-taking operator, with stderr set only for the 2 writes (by
construction, so a stderr read cannot be built), and Token::StderrToStdout is
2>&1. The parser’s redirect_word is then a total function of (stderr, op)
into the sum.
A list of redirects is a set of bindings, one per stream. Redirects<T>
(core/src/syntax/ast.rs) is the checked list: stdin, stdout and stderr
each hold at most one binding, so a stream has one final destination and
nothing is opened only to be overridden. Redirects::bind refuses the second
binding of a stream at parse time, with a caret on the second redirect and the
question which one do you mean? — > a > b, 2> e 2>&1, 2>&1 2> e and
< a << b are not programs. StderrTarget is File(mode, t) or Stdout, so
2>&1 is a binding of stderr to stdout’s destination, not a step that reads
a destination established so far: its position in the list means nothing. The
interpreter opens the targets in one fixed order, stdin, stdout, stderr
(redirects-are-bindings).
Every tier is the same shape over a different operand: the parsed word
(Redirects<Ast>), the elaborated value (Redirects<Val>, inside the IR), the
evaluated string (Redirects<String>, at the runtime). map and try_map carry
one tier to the next, so no tier re-widens the type and every consumer — the
in-process redirect frame (core/src/runtime/redirect/scope.rs), the external
command’s stdio plan (core/src/runtime/command/stdio.rs), the write
observation (core/src/types/audit/observation.rs) — is an exhaustive case with no
unreachable arm. WriteMode is a write mode and nothing else, so a write
observation cannot carry a read mode.
This closes the wire as well. Comp crosses the wire (core/src/seed/table.rs)
and derives Deserialize; since the IR’s redirect is the sum, a peer cannot
hand this process a redirect that names fd 7 — it fails to decode instead of
reaching a runtime check.
The lexer refuses every excluded form, each with its own advice: 0< and
0<< (< already reads standard input), 1>, 1>> and 1>~ (> already
writes standard output; put a space before it to pass 1 as an argument, as
in echo 1>file), 1<, 2<, 1<< and 2<< (< always feeds stdin), 0>
and friends (standard input cannot be written to), fd ≥ 3 (the sentence naming
the three streams), 1>&2 and >&2 (use warn), the identity dups 1>&1 and
2>&2 (they name the stream they already are), and any other a>&b (no fd
plumbing beyond 2>&1).
This is a hard rule. Do not reintroduce an fd number below the lexer’s tokens, and do not widen the sum without giving the new form plumbing that means something.
See surface-syntax for where redirects are lexed
and parsed, capture for what a redirect does to a captured
command (let saved = echo hi > f binds ""), and
redirect-drop-on-handler-dispatch
for what a redirect does when the head turns out to be handled.