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,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 — a decoder’s
Returns Afor anAthe stream decides and an encoder’sA → Commandthat writes (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 Agda core’s scalar fragment now has a shared value-or-error semantics in
CK.Semantics.Scalar: arithmetic, comparisons, concatenation and scalar
rendering return a value or raise an error without emitting bytes. Its
deterministic reductions agree with machine execution and compose with
higher-order code, recursion and recovery in ScopedFundamental.
The numerical domains are still an abstraction: integers are unbounded,
raw float literals are not indexed by finiteness, and mixed-number promotion
is absent. Rust’s checked integers and finite-float representation remain
separate refinement obligations; the scalar theorem does not cover the
whole builtin library.
The core entries group by what they compute:
- List & higher-order —
eachmapfilterfoldsort-listsort-list-byrange. Each takes a thunk.eachandfoldabsorb a command callback (run it, stream its output, keep its value), whilemap,filterandsort-list-bydemand a value:map { |x| echo $x } $xscaptures each call’s output as aString. - String & regex —
upperlowerdedentsliceintercalatere-matchre-splitre-find-matchre-find-matchesre-replacere-replace-allstring-replaceral-quoteposix-quoteposix-split. - Parsing —
intfloatstr: value→scalar coercions. - Structure & comparison —
lengthis-emptykeyshasltgt: 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-pathabbreviate-hometemp-dir/temp-file. - Codecs — the
from-X/to-Xpairs and the streamingfold-lines: the typed byte↔value crossing, given its own page (codecs). - Concurrency —
awaitpollfirst-settledracecancelworkerworkersand the host’swatch, beside thespawnandgathercontrol operators (par,bothandfirst-ofare prelude code over them): a worker scheduler and the gradedHandle^ε αvalues only the host runtime can mint.awaitbehaves as the block run where it is written;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, await-is-the-block-run-here). - Writes —
echo: every argument rendered through the totalto-string, single-space intercalation, a trailing newline, typedList String -> Command: its value is its output, like every command. 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 -> Returns Unit, a row that returns and does not write, so a bind capturing it 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/unaliasuseexit/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 result type 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. - Writing — every encoder,
help,explainand the terminal controls are… → Commandrows: the type tells a bind or a pipeline that the command writes (graded-f).
One scheme closes a computation variable, which a written quantifier list
cannot bind, so it generalises against the empty environment instead: 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 -> Command and
List String -> F [pid: Int, desc: String] — 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).
detach refuses a head a handler intercepts: a handler runs inside this
session, so nothing could be detached.
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
intercepts at the bare head, and ^name — like a path head — skips both and
reaches the external directly
(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.