Builtins: the structured primitive set
A builtin is a Rust atom the language keeps in-process precisely because the value language cannot reduce it to anything simpler — it reaches a syscall or the shell’s own state, performs a base computation the prelude has no smaller pieces for, or carries a type no ordinary binding could be given. The prelude is written over the builtins, so anything expressible as a value-function is a prelude binding, not a builtin ([[design/name-resolution|where a capability lives]]). The set is small and in-process; it computes structured values or touches the shell’s own state, never the filesystem effects the bundled coreutils own.
Why a capability earns a Rust body
The prelude bottoms out in the builtins, so a capability is one exactly when it cannot be written as a value-function over simpler ones. Three kinds of irreducibility:
- It reaches outside the value language — a syscall or structured OS query
(the filesystem family), or the shell’s own runtime state a spawned process
could never touch (
cd, thespawnfamily,source/use,surface,warn,ask). The query case is a syscall bridge, not text parsing: records and lists in place of a shell-out tostat/ls/dirnameand a re-parse, so the bytes→text→structured round-trip never arises. - It is a base computation — an operation the prelude has no smaller pieces to build from: a regex engine, the string transforms, structural comparison dispatched on the runtime value, scalar coercion.
- Its type cannot be given to an ordinary binding — the codec routes
F[Value] A/A → F[Bytes] Unit(codecs) andfail’s divergent, open-row result.
Filesystem effects are deliberately none of these: there is no copy-file or
make-dir, because cp / mv / rm / mkdir already own that and a second
spelling would be a second thing to keep capability-checked
(name-resolution).
The families
The core entries group by what they compute:
- List & higher-order —
eachmapfilterfoldsort-listsort-list-byrange. Each takes a thunk, and the callback’s payload route is universally quantified:map { echo $x }typechecks because the callback may itself be byte-routed while the list operation still returns a value. - String & regex —
upperlowerdedentsliceintercalatere-matchre-splitre-find-matchre-find-matchesre-replacere-replace-allstring-replaceshell-quoteshell-split. - Parsing —
intfloatstr: value→scalar coercions. - Structure & comparison —
lengthis-emptykeyshasequalltgt: ad-hoc-polymorphic, dispatched on the runtime value’s shape. - Structured filesystem queries —
list-dirfile-infois-file/is-dir/is-link/is-readable/is-writableexistsglobresolve-pathabsolute-pathtemp-dir/temp-file. - Codecs — the
from-X/to-Xpairs and the streamingfold-lines: the typed byte↔value crossing, given its own page (codecs). - Concurrency —
spawnwatchawaitpollracecancel: a worker scheduler and theHandle αvalues only the host runtime can mint.pollis the non-blocking probe of a handle — total over a finished block, reporting completion or failure as one settle variant rather than blocking or re-raising (the settle decision). - Byte writes —
echo: every argument rendered through the totalto-string, single-space intercalation, a trailing newline, typedList String -> Return(Bytes, Unit)so a value boundary reads the bytes it wrote. Mixed argument types coexist because the argv boundary renders each element before the list is formed. - Diagnostics —
warn: oneStringand a newline to standard error, typedString -> F[Value] Unit. Deliberately not a byte write — the route staysValue, so a caller binding the computation’s payload never picks the message up. ral has no redirect pointing standard output at standard error, and this verb is what stands where the bash idiom did (diagnostics-are-a-builtin). - Session & terminal —
cdcwdalias/unaliassource/useexit/quitaskclearresetsurfacehelp/explain, with the underscore probe_ansi-ok.
fail sits outside these: it diverges rather than computing, and its role in
fallback chains is failure.
How a builtin is typed
A builtin is a function, and its type rule is a scheme. There is one rule,
fn(&mut Unifier) -> Scheme: an ordinary first-class polytype, allocated fresh
per call, usable in command position and reifiable as a value ($map). A
table entry has an arity and a value form, both by construction
(fixed-arity).
Nullary and divergent are shapes a scheme writes, not a second rule:
- Nullary —
clear,reset,help, and thefrom-Xcodecs, which read the byte channel — is a body with no arrow over it. - Divergent —
failandexit/quit, whose escape unwinds past every binding — quantifies a fresh value and route directly, soif $c { exit 1 } else { return "x" }takes its type from the arm that returns.failalso carries the nonzero-status diagnostic (failure), which is a facet of its registry row rather than of its type. - Byte-routed — every encoder returns
Return(Bytes, Unit): those bytes belong to whoever consumes the command as a value.
Two schemes close a computation variable, which a written quantifier list
cannot bind, so they generalise against the empty environment instead:
from-lines, whose stream type recurses through its own tail, and alias,
whose block argument is a thunk over an unconstrained computation
(schemes-leave-closed).
A builtin’s argument diagnostics need no vocabulary of their own. What a
mismatched argument earns follows from the type that was expected, not from a
tag naming who expected it: a Ty::Thunk parameter is the block case, a row
carrying status: Int is the error-record case, and a list meeting Bytes
earns the codec hint wherever it happens. So the help reaches a user lambda’s
argument as readily as a builtin’s, and one application path serves both
(type-inference).
echo and detach are not table entries. They are the two rows of the
base-frame manifest, typed List String -> Return(Bytes, Unit) and
List String -> F Any — the argv convention a handler and an external already
share, List String inside and bytes at the OS call — and their schemes are
seeded into the checker’s env at boot, so a base frame is looked up as a handler
is (argv-is-a-list-of-strings).
Each codec being its own entry rather than one polymorphic decode / encode
is what lets from-json < file dispatch straight through the command arm with
the concrete return type in view, and a misspelled codec fail at command
lookup rather than as a runtime “unknown codec” string.
A name is a value or it is handled
The set is not a third kind of name: the manifest is authored as two, one half for each of ral’s two existing mechanisms (a-name-is-a-value-or-it-is-handled, argv-is-a-list-of-strings).
- A table entry → a native value. It declares its arguments, so it has a
curried function type (arity 0: a thunk type) and is a first-class value bound
in the base scope.
$upperis the entry, not a lambda around a name-dispatched command: it curries by collecting arguments until the entry’s arity is reached, prints as<native NAME>, is equal by name plus collected arguments, and crosses the scope envelope by name, re-linked against the receiving shell’s manifest. Its type is the η-equivalent lambda’s, uncurried all the way, so partial application in a typed position goes through aletrethunk rather than a provenance-sensitive rule. - A base-frame row → a base frame. It is variadic over a list of strings,
so there is no arity, nothing to curry, and no meaning for partial
application: it is only interpretable as command syntax and lives at the
bottom of the handler stack;
echoanddetachare the two. A user frame stacks above it and forwards into it (handler-dispatch).
Interception is therefore lexical shadowing rather than admission: a binding
under a native’s name shadows it, a handler under any name installs, and
^name — which skips the env — reaches the handler
(resolution).
See also syscalls-are-effects (builtins are the pure fragment — not every kernel call is an effect),
name-resolution, codecs,
failure, pipelines;
builtins-registry, map: builtins,
map: typecheck.
Cite: RATIONALE §“Structured values cross once”,
§“The grammar is the residue”; docs/SPEC.md §14, §16.7.