Codecs: the typed crossing between bytes and values
The from-X / to-X builtins name the typed crossing between stdin bytes
and structured values. A decoder reads stdin and returns a value. An encoder
takes one value and writes the encoded bytes:
- every decoder has the computation type
from-X : Returns A; - every encoder has the type
to-X : A → Command.
The two types are inverse: an encoder writes what the matching decoder reads.
The crossing is a name
A command is a computation of type F^w Unit (Command): its value is its
output. An encoder is a command with an argument, a builtin row ending in
Command; an external command is [String] → Command too. A decoder is a row
that returns a value and writes nothing, so it ends the byte pipeline. A bind
sees what a command wrote because the command’s type says so:
let x = cat f binds captured text
(graded-f, capture), while
let x = cat f | from-bytes binds the bytes exactly, because the decoder is
where the pipeline’s value comes from. A
program crosses between bytes and values only when it names a codec. A
misspelled codec fails at command lookup
(why each codec is its own builtin).
The two directions
A decoder takes no value argument. It reads stdin, whether that comes from a
< file redirect, the left stage of a pipeline, or the terminal. Each decoder
is declared with arity 0, so a passed value is a type error, raised before the
call runs (`from-json` takes no argument — it reads the byte channel).
The error’s hint names the fix: apply the matching encoder, then send its bytes
through the pipeline so the decoder has a channel to read —
to-string $s | from-json for JSON in a String, to-bytes $b | from-string
for a Bytes value (for example $s[stdout] from a poll result). The decoders:
from-bytes→Bytes; the bytes pass through with no decode;from-string→String, strict UTF-8;from-line→String, strict, with one trailing\n/\r\nstripped;from-json→ a decoded value: strict UTF-8, then JSON; a boundary ([[decisions/260930_a-boundary-is-checked-against-its-type|checked at the door]]);from-jsonl→ a list, onefrom-jsonvalue per non-blank line split by the line rule, also a boundary; each line parses alone, so a failure names the input’s line;from-json-at tokens→ the value at a path of RFC 6901 reference tokens (an object member by exact key, an array element by decimal), decoded and checked likefrom-json; a path given as tokens, never as a pointer string, so no key needs escaping;from-csv→ a list of maps keyed by the header row,[Map String]and so not a boundary: its shape is decided; every field is aString, because CSV is untyped — coerce withint/float; the reader handles quoted fields, embedded commas, and embedded newlines;from-lines→[String], split by the line rule (below), lossy per line.
A decoder of undecided shape is checked. from-json’s type is ∀α. Returns α,
and its α would be a cast. It is instead weak (one type per unit) and the
decoder admits the value it read against the type the checker solved for it, so
what fails is the decode, naming the pointer and the line of the use that
needed the shape: the value at /items/3/size is text, but this script uses it as a number. The decoder never converts: a JSON 3 is an Int and 2.5 a
Float, and float at the use accepts both.
A decoder is the pipeline’s last stage. cat data.json | from-json returns
a decoded value, the pipeline’s value being its final stage’s. A decoder before
the last stage is refused: a stage feeds the next by writing, and a decoder
returns (pipelines). Bind the decoder’s result and apply
the next function to it instead — let document = cat data.json | from-json
followed by length $document.
An encoder takes one value and writes its encoded form to stdout.
to-bytes (a Bytes value, passed through unchanged), ints-to-bytes (a list
of Int, each 0 through 255 — ral has no byte literal, so this is how bytes are
written by number), to-string, to-lines (each element followed by \n),
to-json, to-jsonl (each element as to-json writes it, then \n),
to-csv, and to-line (the line writer that echo uses) all
write the encoded bytes (write_encoded in
core/src/builtins/codecs.rs). Each encoder names one operand type, so
to-bytes 3 and to-bytes hello are ordinary unification failures rather than
a union the checker has to resolve; the operand-prefixed name is what
distinguishes the second writer, as in bytes-to-string. In a pipeline, the write feeds the wire:
to-json $x | cmd gives cmd the encoded bytes. At a bind, the
capture coercion applies, the encoder being a command:
let e = to-json $x binds the encoded text as a String, and
let e = to-json $x > f binds (), the redirect having discharged the output.
to-csv takes a list of records and writes a header row plus one row for each
record. The columns are the first record’s keys in sorted order, because maps
are key-ordered and hold no original column order. A record that misses a
column contributes an empty field.
to-json maps a ral value to JSON structurally. A record or a Map A
becomes an object; a list becomes an array; Unit becomes null. A
variant `tag payload becomes {"tag": "tag", "payload": …}, and the
payload key is absent for a niladic tag. A Bytes value serialises as an
array of byte integers. A Lambda, a Block, or a Handle has no JSON image
and is an error; to-jsonl encodes every element before it writes, so its
refusal names the element’s index and leaves the channel empty.
Decoders return values; a fold streams
A decoder reads to EOF and returns a value, and a value holds no reader.
Every decoder is whole-buffer, from-lines included. A lazy sequence of lines
would be a value still owing reads: once it outlived its capture it would have
to hold a live reader on a producer still running, past the boundary that ends
that producer. So the one codec that streams is a fold, whose callback runs
while the pipe is open, and it is the way to process unbounded input without
holding it:
fold-lines <fn> <init>folds over stdin line by line:fold-lines : ∀ε α. U (α → String → F^ε α) → α → F^p α(scheme::fold_linesincore/src/typecheck/builtins.rs). It returns its accumulator; a callback of either grade is absorbed — its output streams, its value is kept. A fold is not a decoder, so it may end a pipeline, and precede the last stage only with a()accumulator.
One line rule
Every reader of lines agrees on where a line ends, because there is one
reader. The rule, with its table in docs/SPEC.md §7.3:
- a terminator is
\nor\r\n, and exactly one\rbelongs to it:a\r\r\nis the linea\r; - a lone
\ris text, never a terminator, mid-line or at EOF; - the final line need not be terminated;
- each line strips its own terminator, so mixed endings are fine.
One function measures a terminator (0, 1, or 2 bytes), and three kinds of caller share it:
- line readers —
from-lines,from-jsonl,fold-linesand the prelude filters over it,line-count, andlines— all split through one reader generic over its byte source (core/src/builtins/util.rs): stdin for the decoders, the string’s bytes forlines, which so agrees by construction rather than by test; - one-terminator strippers — capture,
from-line,ask— remove at most one terminator from the end:a\r\n\r\nbecomesa\r\n; - writers —
to-line,to-lines,to-jsonl,echo— emit\nonly. Reading accepts both endings the world writes; ral’s own output has one spelling.
to-lines terminates every element, as to-line and to-jsonl do, so
from-lines ∘ to-lines = id on every list whose elements contain no \n and
do not end in \r — such an \r would fuse with the written \n into one
terminator.
Strict values, lossy lines
The structured decoders and an [[design/capture|external command captured by
let]] are strict: invalid UTF-8 is an error that points at from-bytes,
because a String or JSON value you will compute with must not silently carry a
replacement character. from-lines alone is lossy, hence total: scanning
lines tolerates a �, while constructing a scalar String does not.
See also builtins, pipelines,
types, cbpv; map: builtins,
io-process.
Cite: RATIONALE §“Values and commands”, §“Pipelines follow their edges”;
docs/SPEC.md §7, §14, §17.5.