ral — design rationale
Not part of the specification. The choices below explain the surface language; §20 of the spec fixes the underlying calculus.
Influences
- rc (Plan 9) and es (Haahr–Rakitzis): lists and functions as values; control structures in a library, not the grammar.
- Algol 60, Modernised Algol: block structure, lexical scoping, and the distinction between a value and a computation producing one.
- Call-by-push-value (Levy): a formal calculus in which that distinction is primitive, with thunks first-class.
- Haskell, Backus: immutable bindings, combinators, equational reasoning in the pure fragment.
- Tcl: commands are ordinary names, resolved at evaluation time, not keywords.
- Shill (Moore et al.) and capability systems: authority is explicitly delegated and may be attenuated, never amplified.
- JavaScript, Rust: destructuring, closures, spread, string interpolation.
The closest relatives are YSH/Oil and nushell; ral differs by not retaining POSIX compatibility and by not enforcing structured data on every pipeline stage.
Values and commands
The organising distinction is between values — inert data, named, passed, inspected — and commands — effectful processes that may read, emit bytes, return a value, or fail. Most shells collapse the two: every datum is a string, and every string is simultaneously data, a command name, and source text for further evaluation. The consequence is that captured output is re-lexed, split on whitespace, and glob-expanded. ral refuses this collapse and thereby avoids the class of bugs arising from it, without sacrificing first-class commands and pipes.
The formal account is call-by-push-value (Levy): values are inert,
computations effectful, a thunk packages a computation as a value, and
forcing runs it. The user need not know the theory to use the shell:
{M} thunks, ! forces.
System calls are algebraic effects
The value/command distinction leaves one question open: what is a
command? ral's answer is that running an external command is
performing an algebraic effect operation. A name such as git,
which the binding namespace declines, is an operation: it is
performed, it returns once with a result or a failure, and its meaning
is supplied separately by its interpretation — by default, the
operating system carrying out the syscall. This separation of the
operation from its interpretation is the defining move, and the rest
of ral's dynamic design rests on it.
Not every kernel call is an effect in this sense. A structured-query
builtin such as list-dir or file-info reaches the kernel yet is
kept inside the pure value language, answering with records rather
than bytes, precisely so the capture-then-reparse round-trip never
arises. What makes a name an operation is being an open external name
whose meaning comes from its interpretation, not merely touching the
OS; ral draws the effect boundary at the external-process surface, the
exec/wait family that defines a shell.
Two further choices are orthogonal to this principle. The first is whether a program may reinterpret an operation rather than defer to the OS; the second, should it reinterpret, is whether the reinterpreting handler may capture the continuation. Both belong to Effect handlers below, and ral takes the minimal setting on each. The principle stands without either: an external command is an effect whether or not any handler intercepts it, and performing one needs no captured continuation — the syscall is a single forward step and the continuation is the ordinary rest of the computation, never a reified value. From the identification alone follow scoped authority (a capability is permission over the effect set), the capability check at the point an effect is performed, audit as a record of the operations performed, and failure as an operation's exceptional outcome.
Blocks as the single abstraction mechanism
{M} stores a command; { |x| M } is a function; ! runs either.
This replaces the several mechanisms of conventional shells —
functions, aliases, eval, subshells, trap handlers — with one.
Forcing is always explicit, with a single exception: a bound name
resolved in head position is forced (or applied) implicitly. This
keeps ordinary calls natural (greet alice) while making the storage
of commands visible (let plan = { make build }).
Two sigils
$ retrieves data; ! runs stored commands. !$b composes: dereference
then force. A single sigil covering both would make "retrieve" and
"run" indistinguishable at a glance, and the ambiguity would propagate
into every expression that passes blocks as data.
Shadowing, not mutation
All let bindings are immutable; re-let shadows within the enclosing
scope. Closures capture at definition time, so equational reasoning
holds in the pure fragment. The cost is the absence of mutable
accumulators; fold, reduce, and the streaming fold-lines replace
them. The benefit is twofold: easier local reasoning, and a spawn
that is safe without synchronisation — the isolated copy shares
nothing that can be mutated concurrently.
External commands return strings
When an external command's stdout is captured in a let, the runtime
decodes it as UTF-8, strips one trailing newline, and binds the result
as String. Invalid UTF-8 fails with a message naming the command
and suggesting | from-bytes; Bytes remains available via that
terminator.
This trades a sliver of generality for a large reduction in
ceremony: most command output is text, and demanding an explicit
decode on every binding generates noise without adding protection
beyond what a strict error already gives. The returned String is
data, never re-lexed, split, or globbed; the classic
capture-then-reparse chain does not arise.
Piping and failure
| and ? are deliberately separate: | moves data between stages;
? reacts to command failure. Exit status and data flow remain
distinct concerns. if branches on Bool, not on command success; a
predicate returning false still succeeds, so confusing "false"
with "failed" is impossible. When success must be inspected as data,
try is the mechanism. When a command dies from a signal or is stopped
by terminal job control, ral keeps that distinction in the error it
reports instead of collapsing everything to a numeric status too early.
No command-level ||
try { a } { |_| b } replaces a || b in command context. A binary
|| on pipelines would force precedence rules relative to ? and
|, adding grammar for a case try already handles. The ||
operator that does exist is the Boolean connective inside
expression blocks $[a || b] (§2, SPEC) — it operates on Bool
values, not on command success.
Expression blocks
$[...] is one expression language spanning arithmetic
(+ - * / %), comparison (== != < > <= >=), and logic
(&& || not). Bash partitions these into (( )) and [[ ]]
because its history forced separate lexers; ral has no such
history. Comparisons already cross the numeric/Boolean boundary
by returning Bool, so the simplest and most honest grammar is
one. && and || short-circuit; operands are strict Bool —
$[1 && true] is a type error, not truthy. The not keyword
carries unary negation because ! is already force (!{...})
and ~ is tilde expansion; context-dependent symbol overloading
would be the worse trade.
Data-last argument order
map f items, fold f init items, filter p items. Piping and
partial application then align: items | map $f | filter $p reads
left-to-right, and map $f is a function waiting for its list.
No context-dependent lexer rules
: and = are in BARE but not IDENT. Names (IDENT) therefore
terminate naturally on these characters, while in command arguments
(BARE) expressions such as -DFOO=bar and http://host:port remain
single tokens. The lexer is single-pass and modeless.
Scoped execution contexts
within and grant are properties of the execution context, not of
source text: a function defined in one module and called inside a
restricted block runs under that restriction. within [env: [KEY:
VAL]] { body } overrides environment variables; within [dir: PATH] {
body } overrides the working directory. Both are facets of a single
scoping primitive. Lexical capture is the right model for data; dynamic
inheritance is the right model for ambient authority.
grant is a capability wrapper around a block: it narrows the active
authority for the duration of body and otherwise composes like any
other block-bodied builtin. An fs/net-restricting grant on a
platform with OS sandboxing additionally re-execs ral inside the
platform sandbox to run the block — but that re-exec is a transport
detail (§ Implementation: "Mobile-state eval transport"), not part of
grant's semantics. The block returns to the caller the same way
whether it ran locally or in an OS-sandbox child; the caller observes
only the outcome and the captured audit/byte observations.
Effect handlers: deep with self-masking
Handlers are the orthogonal reinterpretation layer of System calls
are algebraic effects above: they let a program supply its own
interpretation of an operation in place of the OS default. They are
additive — the effect identity holds whether or not any handler
intercepts a command — and ral's are a deliberately small fragment,
tail-resumptive with no first-class resume, less than algebraic-effect
handlers usually offer.
within [handlers: …, handler: …] installs effect handlers on a
dynamic stack. Two independent design questions arise, often conflated
under one "deep vs shallow" heading; ral commits to a definite answer
to each.
Handlers interpret open operation names after the language binding
namespace has declined the name. Lexical bindings, prelude names, and
builtins are not shell aliases: a handler cannot replace length, and
a local let foo = ... beats an active handler for foo. This keeps
ordinary language names stable while preserving command mocking for
open external names such as cat or git.
Deep vs shallow is the question of whether a handler H persists
across the continuation of the operation it handles. After
within [handlers: [git: H]] { git a; git b }, both git a and git b
trigger H; the installation is not consumed by the first call. By
the standard criterion (Plotkin–Pretnar deep handlers re-wrap H
around the continuation; Hillerström–Lindley shallow handlers do not),
ral's handlers are deep.
Self-masking vs self-transparent is a separate question: during
the evaluation of H's body, is H itself still in scope? In ral,
the matched frame is lifted off the dynamic stack for the dynamic
extent of the handler body — so a call to the same name from inside
H reaches the next outer frame, or the OS, never H itself. ral's
handlers are self-masking.
Without resume, deep handlers can re-trigger themselves only through
a raw recursive call from inside the handler body. The Plotkin–Pretnar
calculus avoids this issue because all re-entry happens through
resume k, and k evaluates under H by construction; the
continuation discipline does the work. ral has no resume —
handlers receive the command name and arguments and return a value or
fail — so self-masking is the operational rule that keeps the
dominant idiom (within [handlers: [git: { |args| my-git ...$args }]]) free of
infinite recursion without requiring ^git inside every handler
body.
The shell intuition is the same as a POSIX function named git whose
body wants to call the real git: the function shadows the name only
outside itself. ral generalises this to the whole handler stack,
typed and lexically scoped, with ^name available as an explicit
bypass of the lexical/prelude/builtin binding chain (handlers still
apply, because ^name is a syntactic flag on the lookup, not a
frame-unwind).
This combination is the practical content of ral's effect-handler
design: deep, so within covers its dynamic extent the way the user
expects; self-masking, so the wrap-and-forward idiom is the natural
reading and not a recursion trap.
Path construction uses interpolation
Outside quotes, $name is a separate atom — $dir/file is two
arguments. Paths are built by interpolation: "$dir/file.txt". This
inverts the bash convention, where quoting suppresses word-splitting;
in ral the unquoted form is already safe (there is no splitting), and
quoting performs concatenation.
Not POSIX
POSIX shell compatibility requires word-splitting, glob expansion on
unquoted variables, $IFS, and context-dependent quoting. ral
eliminates exactly these. Compatibility is therefore a non-goal.
Control flow is library; five control operators are syntax
The grammar knows about exactly five control operators — within,
grant, try, guard, audit — plus the two purely-syntactic
forms if and case and the chain operator ?. Everything else
that smells like control flow (for, while, map, each, the
prelude's attempt and retry) is an ordinary parameterised block
in the prelude. The split is principled, not pragmatic.
A construct earns a grammar arm when both of the following hold: its typing rule is not derivable from ordinary Hindley–Milner over the builtin signature, and its runtime semantics cannot be expressed as a function taking thunks without lying about that signature. The five operators meet both conditions:
try B Hhas a custom typing rule that unifiesB's output withH's output and threads the error record intoH's parameter; no monomorphic builtin signature captures this.guard B Cmediates which failure escapes (the body's; never the cleanup's) and which is logged-and-discarded.within OPTS Bandgrant CAPS Bmanipulate dynamic frames (working directory, environment, effect handlers, attenuable capabilities) that live inShellstate, not in any value the body can observe through its parameters.audit Bowns the audit subtree its body produces; the tree-shape question — which scope's children does this node belong to — is structural and cannot be answered after the fact by a function that receivedBas a thunk.
Surfacing these as keywords also shrinks the surface elsewhere. The
five names are reserved in let-binding position and in bare-head
command position; ^try keeps PATH-lookup semantics; $try and the
other four in value position are compile-time errors with a targeted
diagnostic. The IR carries a Within/Grant/Try/Guard/Audit
node per operator with named structural fields, and a Redirect
wrapper for the trailing-redirect case — none of the five appear as
string-keyed builtins anywhere in the typechecker or evaluator.
if, case, and ? remain in the grammar for a different reason:
they take an arbitrary number of arms and need parser support to
keep the surface readable, but their typing is ordinary. Everything
else stays in the prelude. The parser does not grow when a user
defines retry; it grows only when a new wrapper needs handler-frame
manipulation, audit-tree ownership, or a typing rule outside HM.
Aliases are semantic, not syntactic
Aliases live in the interactive command namespace, resolved at evaluation time after value-head lookup, active only in interactive mode. Scripts never see them, so script behaviour cannot depend on the user's interactive configuration.
guard, not on EXIT
guard wraps a body, runs cleanup regardless of outcome, and
propagates the original failure unchanged: scoped and lexically
apparent. Registration-based cleanup (on EXIT) is mutable global
state whose ordering follows execution flow rather than source
structure, and composes poorly with nested error handling.
Termination: return, fail, exit
Scripts end at the last statement. Three primitives end them earlier, each with its own scope:
returnexits the current block or file with success. Inside a sourced file, it stops that file, not the caller — so areturnin a library never kills the script that loaded it.-
failaborts the current evaluation with nonzero status and an error record:fail [status: 1] fail [status: 7, message: 'config missing'] fail $e # re-raise inside a try handler
Errors are values, not numbers. The record produced by try { ...
} { |e| ... } is the input shape fail accepts, so wrap-and-rethrow
composes without dropping fields.
- exit N (alias quit) terminates the whole shell process with
status N. Reserved for top-level use; scripts that want to halt
cleanly should prefer return.
try and audit are separate operators
try traps failure and dispatches to a handler that receives a small
structural record (status, cmd, message, line, col); it is
otherwise transparent, in that it does not redirect fd 1 or fd 2, so
side-effects inside the body remain observable as they happen.
audit builds the full execution tree, recording per-command bytes
regardless of outcome. Separating them keeps the common case
(catch-and-handle) from paying for the uncommon one (full tracing),
and lets the two compose: audit { try { … } { … } } traps errors
and records bytes. Both are control-operator keywords (§ "Control
flow is library; five control operators are syntax"), so the typing
rules are dedicated rather than shoehorned through a builtin scheme.
Audit is one mechanism
Every audit-producing site goes through the same lexical scope: the
scope-introducing operator (grant, within, guard, try,
audit) owns the nodes its body produces. Process boundaries —
the OS-sandbox child ral re-execs for an fs/net-restricting
grant, each pipeline stage helper — only transport audit fragments;
they never decide tree shape. The wrapping scope merges incoming
fragments into its own child trail, so reports stay readable: a
sandboxed grant { … } shows its body's nodes as direct children
of the grant node, not loose at the root.
source is kept for configuration
~/.ralrc and interactive configuration need scope merging, which
use (returning a module map) does not do. source exists for this;
use remains the default for library code.
Concurrency: isolation, not shared state
spawn creates an isolated copy of the evaluator; there is no shared
mutable state and no synchronisation. await is the only channel.
A second await on the same handle returns the cached result,
avoiding the need for affine types or runtime traps on aliased
handles.
A spawned handle buffers its output and replays it on await; a
watched handle (watch "label" P) streams each line live to the
caller's stdout, prefixed [label] (stdout) or [label:err]
(stderr). watch is a one-line prelude alias over the _watch
builtin, not a keyword. The framing lives in a single Sink
variant — LineFramed — that buffers bytes until \n and emits
prefix + line + '\n' as one write to the caller's stdout;
sibling watchers serialise through the OS stdout lock (or, under
the interactive REPL, rustyline's external printer) so each line
is atomic even when several watchers run concurrently. Live
watching hides the usual cmd > /tmp/log &; tail -f scaffolding
behind a library function. Ral deliberately does not ship a
read-API on handles (read-line $h, select-line [h₁,h₂]): value
builtins like each are value-complete, so a
handle-as-pipe-source would require a streaming-internals
refactor, whereas line-framed watching satisfies the observed
motivating use case at a much smaller surface.
Paths are strings
No Path type. UTF-8 for textual values, and the absence of word
splitting removes the historical reason shells needed path-specific
quoting.
let unifies binding, capture, and storage
The let RHS is a command context, and a single mechanism covers
three operations:
let x = foorunsfooand binds its result;let x = 'foo'binds the string;let f = { |x| … }stores the block.
Bare words run commands; quoted words are data; value forms
(literals, blocks, lists, maps, derefs, arithmetic) receive an
implicit return in command context. The shell convention
(unquoted words run commands) is preserved without collapsing the
language into strings.
Three layers, one asymmetry
The filesystem surface is split into three layers:
- Structured queries — primitives that return values:
list-dir,file-info,file-empty,path-*,temp-dir,temp-file. These are what drives a structured pipeline; they have no shell-tool analogue worth bothering with. - Bytes I/O — codecs (
from-string,to-json, …) plus redirects.to-json $v > $pathreplaces the oldwrite-json;from-string < $pathreplaces a read-file primitive. Atomic-rename-on-write is built into>for regular files. - Filesystem effects — bundled coreutils (
cp,mv,rm,mkdir,ln -s,chmod, …). Effects don't return structured values, so giving them ral-native primitives buys nothing the shell form doesn't already give. The oldcopy-file,move-file,make-dir,remove-filewrappers are gone for this reason.
The asymmetry is the design: structured returns earn a primitive; effects don't.
Core keeps the universal part of that surface. Exarch adds its
agent-facing search and edit tools (grep-files, hash-lines,
hash-replace, explore-dir, and small source helpers over them) as
host extensions, because they are a model workflow rather than a shell
language requirement.
remove-file was a footgun
The dropped remove-file did rm -rf if you pointed it at a
directory. That is the kind of behaviour ral exists to abolish. The
dangerous verb wears its name — rm -r (or rm -rf) — and the
caller writes it on purpose. Effects are bundled coreutils now;
the trap goes away.
Bundled coreutils are mandatory in exarch, optional in ral
A sealed exarch profile that depends on host coreutils isn't sealed —
it's reproducible only modulo whatever cp or mv the host happens
to ship (BSD vs GNU drift, version skew, locale defaults). Exarch
therefore bundles a curated coreutils set and pins behaviour. The
binary-size cost is paid once per profile build and is the price of
"I know exactly what's in this".
The bare ral binary keeps coreutils behind a feature flag. An
interactive shell on a developer machine has system coreutils
already; no reason to ship 30+MB of duplicate tools.
Capability-checked dispatch for bundled tools
Every uutils invocation goes through a wrapper that consults the
tool's own clap parser to find the path-argv positions, then calls
the same check_fs_read / check_fs_write that the structured
primitives use. Bypassing the sandbox by reaching for cp instead
of a primitive is therefore not possible — both paths land at the
same chokepoint. within [dir: ...] scope propagates by chdir under
a per-call lock, so relative paths resolve against ral's scoped CWD,
not the host process CWD.
Syscall bridge, not text parsing
The structured query builtins — list-dir, file-info, the is-*
predicates (is-file, is-dir, exists, …), resolve-path, and
glob — replace shelling out to stat, ls, or realpath and
parsing their text. Platform differences and the perpetual
bytes–text–structured round-trip disappear. Effects are not in the
bridge — they are bundled commands invoked through the
capability-checked dispatch.
Record types and scoped labels
The checker infers per-field types for map literals with static keys.
Representation is a row: a list of (label, type) pairs with an
optional tail variable standing for unknown fields. Field access
unifies the target with [label:α | ρ] and returns α. The unifier is
Rémy (1989): mismatched head labels permute past each other into a
shared fresh tail.
The spread [...$base, port: 9090] raises the question of duplicate
labels: if $base already has port, the result has two. Rémy's
original system assumes uniqueness and would require absence markers
(Pre(T) / Abs) and a restriction operator ρ ∖ port. Introducing
them means new row constructors and changes to unifier, generaliser,
and display.
ral instead adopts the scoped-label row types of Leijen (2005). Duplicates are permitted in rows; selection always takes the first; extension prepends, shadowing the prior entry rather than removing it. The key observation is that the Rémy rewrite rule already treats duplicates correctly — it swaps only different labels past each other, so same-label entries keep their relative order. No changes to unifier, generaliser, or occurs check are required.
Effect: [...$base, port: 9090] with $base : [host: String | ρ] infers
as [port: Int, host: String | ρ]. The explicit field prepends over the
spread's row variable, which becomes the result's open tail. Shadowed
duplicates are invisible to selection and suppressed in display. With
multiple spreads the result is open but imprecise — chaining two
arbitrary rows needs row concatenation, which is not part of Leijen's
system and is not included.
Plugins are modules
A plugin is an ordinary ral module (§8) whose return value is either
a manifest map or a block that takes an options map and returns a
manifest map. There is no plugin DSL, no separate loader language,
and no magic $config binding: a plugin's knobs are fields on the
options map it receives. _plugin 'load' 'fzf-files' [key: 'ctrl-t']
evaluates the file, applies the options map to the returned block,
and reads name, capabilities, hooks, keybindings off the
resulting record. Record destructuring, row polymorphism, and
grant already exist for other reasons; the plugin system is a
thin composition of them.
return { |options|
let key = get $options key 'ctrl-t'
return [
name: 'fzf-files',
capabilities: [
exec: [fzf: []],
fs: [read: ['.']],
editor: [read: true, write: true, tui: true],
],
keybindings: [[key: $key, handler: $_handler]],
]
}
The shell wraps each handler in grant $capabilities { … } before
installing it, so a plugin runs with exactly the authority it
declared. A handler that tries to do more fails at the capability
check, not on trust.
_ed-tui captures stdout
Interactive plugins invoke fuzzy finders (fzf, sk, …) that draw
on the terminal via /dev/tty and print the user's selection on
stdout. A plugin needs that selection as a value. If the body's
stdout went to the terminal, the selection would appear above the
prompt and the handler would get nothing back.
_ed-tui therefore opts into byte capture for the duration of
its body, analogous to what let x = !{ … } does at a binding site.
When the body returns Unit, the captured bytes (trimmed of one
trailing newline) become the return value; when it returns something
non-Unit, that wins. This is the same "last command's bytes are
the value" rule as let, applied inside a higher-order builtin.
let dir = ed-tui { fzf --walker dir +m }
Byte pipelines are processes; value pipelines are folds
Pipelines have two distinct execution models, and the type of the adjacent edges decides which one runs.
A pipeline whose every stage operates on values is just typed
data-last composition: x | f reduces to f !{x}, evaluated
sequentially in the parent. No process is spawned, no pipe exists.
This is the path range 1 21 | filter $even | sum takes — three
function calls threaded by the value channel.
A pipeline that touches bytes — at least one external command, or any
byte edge — runs as a Unix-style process pipeline. Every stage,
including ral-implemented ones, executes in a subprocess; all
subprocesses share one process group; the parent ral process is not
a member of that group. This shape is what makes cat README.md |
glow -p work: the kernel sees one foreground process group containing
every stage that can touch the terminal, regardless of whether cat
is /bin/cat, an alias, a handler, or a ral block that wraps bat.
External stages in foreground pipelines are not spawned directly.
Each one is wrapped in a small exec trampoline: a re-exec of ral
that joins the pipeline pgid, resets signal dispositions to default,
and then waits for the parent to release a one-frame gate. The
parent claims the foreground once — tcsetpgrp on the pipeline pgid
— and then writes every gate frame at once, which lets every stage
start its real work knowing the foreground is correct. Without the
trampoline, a fast external could start (and call tcsetattr) before
the parent finished tcsetpgrp, and the kernel would deliver SIGTTOU
to a process the user never asked to background. The trampoline is
an implementation detail: spawn-failure diagnostics still mention
the user's command, not the helper.
In non-interactive contexts (scripts, captured pipelines) the parent
never calls tcsetpgrp, so there is no foreground race to lose; the
trampoline is skipped and externals are spawned directly through the
same build_command funnel. Ral helper stages always run through
their own job-frame protocol regardless of foreground state, because
the frame carries the work to evaluate, not just a release signal.
Windows pipelines do not need a trampoline at all. Windows has no
tcsetpgrp: the console is shared by attached processes and there
is no per-stage "is this in the foreground" signal to race against.
External stages on Windows direct-spawn into a per-pipeline Job
Object (with CREATE_NEW_PROCESS_GROUP so each stage is individually
addressable for Ctrl-Break fan-out), and TerminateJobObject /
KILL_ON_JOB_CLOSE makes the abort path plain RAII. The trampoline
exists only to win a Unix-specific race, so it stays Unix-specific.
Mixed and ral-helper pipelines on Windows use the same gate / report
/ value-edge protocol as Unix, with two changes: the channel is an
anonymous OS pipe pair (os_pipe::pipe()) wrapped in a Reader/Writer
enum, and inheritance happens through
SetHandleInformation(HANDLE_FLAG_INHERIT) plus a numeric handle
value stashed in an env var. Helper subprocesses parse that value as
a Win32 HANDLE, wrap it via from_raw_handle, and clear
HANDLE_FLAG_INHERIT so the handle does not leak into nested children.
The exec trampoline path remains Unix-only — its sole purpose is to
win the tcsetpgrp race that does not exist here.
Job control on Windows is intentionally narrower than on Unix. There
is no SIGTSTP analogue: kernel-induced stops are unreachable, so a
pipeline cannot be parked in the job table. fg <id> blocks on the
group's Job Object until JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO arrives;
bg only validates pre-existing stopped-state bookkeeping (which on
Windows is always empty); disown clears JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
on the group's Job and drops ral's bookkeeping, leaving the children
running unsupervised. Ctrl-C escalates: the first delivery sends
CTRL_BREAK_EVENT to every active member, the second runs
TerminateJobObject on every live group, and the third forces ral
to exit.
The cost of running ral stages out-of-process is that they become
subshells with respect to mutation. A helper stage's cd,
env-set, alias / module / registry updates, or REPL changes do not
flow back to the parent — only the pipeline's pipe contents and final
value cross the boundary. This matches the way every traditional
shell treats process pipeline stages, and it is what lets job control
remain coherent: a shell process that participates in its own
foreground pipeline cannot consistently both own the terminal and not
own it.
Job control is narrower than bash, on purpose
Ctrl-Z parks the foreground command's process group as a numbered
job; fg [N] resumes it; bg [N] resumes it in the background;
jobs lists what is parked. Beyond that, ral does not reproduce
the bash machinery that exists to compensate for the shell having no
persistent UI for parked work.
There is no exit-time refusal ("There are stopped jobs"), no
asynchronous [N] Done <cmd> print stream interleaved with the next
prompt, and no %1 / %+ / %- short-form addressing. Job state
is observed by typing jobs, not by being interrupted at the
prompt. The bash exit-time guardrail and the async notification
stream both exist because the shell has no other UI for jobs; the
trade — printf-into-readline that occasionally collides with what
the user is typing — is the cost of that compensation. Modern
multiplexers (tmux, zellij) cover the "I want a second running
thing" use case better than juggling jobs in one shell, so ral's
narrower surface is sufficient for the case Ctrl-Z genuinely needs
to handle: vim's drop-to-shell idiom and the same pattern in
less, man, and top.
The kernel mechanism — SIGTSTP delivery to the foreground process
group, tcsetpgrp for terminal handoff, waitpid(..., WUNTRACED),
SIGCONT to resume — is unchanged from the canonical Unix
implementation. The narrowing is in the prompt-side bookkeeping,
not the OS-side machinery.
Pure-value pipelines (§4.3) are sequential folds in the parent evaluator. No process exists for SIGTSTP to suspend, so they are outside this discussion entirely.
Keybinding dispatch is handler composition
Multiple plugins can bind the same key. Dispatch walks handlers in
reverse load order; a handler returning true consumes the
keystroke, a handler returning false falls through to the next,
and if every plugin handler declines the shell runs its built-in
binding for the key.
This is the same shape as the ? fallback chain for commands: a
stack of alternatives where each one decides whether to handle or
pass. Load order controls precedence, the same way use order does
for bindings, so the user's last-loaded plugin wins by default and
earlier plugins remain reachable.
# if autosuggest's CTRL-F doesn't apply, the built-in binding still runs
load-plugin 'autosuggest' [:]
Plugin code runs on the real env with host authority
Hooks (buffer-change, pre-exec, post-exec, chpwd, prompt)
fire on the live evaluator, not a clone. They need to observe and
sometimes alter shell state — the prompt hook returns the prompt
segment, chpwd may update state cells.
The shell does not push a capability frame around handler,
keybinding, or plugin-alias dispatch: plugins run with whatever
capabilities the caller's stack already grants. The manifest's
capabilities: key is advisory documentation only.
This is a deliberate trade. Plugin manifests are self-declared, so
in-process attenuation gives zero defence against an adversarial
plugin author and only catches honest-but-buggy plugins. Paying the
conceptual cost — with_capabilities carrying two distinct meanings
(user-syntactic grant vs in-process plugin attenuation), bleeding
into the eval-boundary's transport dispatch — was disproportionate.
Users who want to confine a plugin call should wrap the call site in
grant { … }; that is what grant is for, and it composes with
plugin code the same way it composes with any other code.
Handlers for an event run in load order; a failing handler's error
is logged but does not cancel siblings. buffer-change runs under
a soft deadline (default 16ms) so a slow plugin cannot make typing
feel laggy; stale handlers are re-run at the next input idle.
Shadowing looks like mutation
let count = 0
for [1, 2, 3] { |x|
let count = $[$count + 1] # shadows in the loop body
}
echo $count # 0 — outer binding untouched
Idiomatic replacement:
let count = fold { |acc x| return $[$acc + $x] } 0 [1, 2, 3]
A block returns its last result, not its full stdout
let b = { echo one; echo two }
let x = !$b # "two", not "one\ntwo\n"
In a let RHS only the last command's byte output is captured as the
bound value. Earlier commands run for effect: their bytes are not
captured but are still forwarded to the terminal.
let x = !{ echo "log: starting"; hostname }
# "log: starting" appears on the terminal; x is the hostname
Return values do not enter pipes
let helper = { |x|
echo "log: $x"
return [result: $x]
}
let r = helper foo # captures the map
helper foo | grep log # pipes stdout; the map is discarded
let binds command results; | carries the byte stream (or
structured values on a value edge). These are separate channels.
Early exit from for
for stops on failure; there is no break. For early-termination
logic use a dedicated combinator: take-while / drop-while for
pure predicates, first for "find the first item matching a
predicate" (it fails if no item matches), or an explicit fold that
threads a decision through the accumulator.
Inline function application needs braces
_if !$pred $head { … } { … } # wrong — 4 atoms to _if
_if !{$pred $head} { … } { … } # right — 3 atoms, middle one evaluates the call
!$pred is one atom (force of the pred value); $head is another.
Writing them side-by-side passes both to the surrounding command as
separate arguments. To inline a call, wrap the application in braces
and force the block: !{$pred $head} evaluates to the call's return
value and occupies one atom position.