ral — a tutorial
Like every shell, ral runs commands:
ls
cat notes.txt | wc -l
echo "hello" > greeting.txt
It also has values, functions, structured data, typed pipelines, concurrency, and scoped authority. You do not need those ideas all at once.
This tutorial starts with the shell you need today. Each section adds one idea. The last sections explain the model underneath.
1 Commands
A command name comes first. Its arguments follow:
ls -la /tmp
git status --short
make -j4
| connects commands. External commands send bytes through the pipe:
cat access.log | grep ' 500 ' | wc -l
Newlines and ; sequence commands. Failure stops the sequence, so make runs
only if ./configure succeeds:
./configure
make
make install
? supplies a fallback:
curl -fsS $primary ? curl -fsS $fallback
cat VERSION ? echo unversioned
There is no command-level && or ||. Sequencing already stops on failure;
? handles the failure.
Redirects look familiar:
command > output.txt
command >> log.txt
command < input.txt
command 2> errors.txt
command > output.txt 2>&1
Each stream (stdin, stdout, stderr) takes at most one redirect, and 2>&1 sends stderr wherever stdout goes, wherever it is written; cmd > a > b and cmd 2> e 2>&1 are parse errors.
> is atomic for regular files: a reader sees the old file or the complete new
file, never a half-written file. >~ is the streaming form for live logs and
FIFOs.
There is no 1>&2. To tell the human something without putting it on the byte
channel a caller may be binding, say it:
warn "no matches under $dir"
2 Values and bindings
let gives a value a name. Use $name to retrieve it:
let name = 'Ada'
let port = 8080
let debug = true
echo "$name is listening on $port"
The first important rule is:
A bare word in command position runs a command. A quoted word is data.
These definitions do different things:
let greeting = 'hello' # bind the String "hello"
let greeting = hello # run hello and bind its stdout
Capturing a byte-producing command gives you its UTF-8 stdout as a String,
with one trailing newline removed:
let branch = git branch --show-current
let host = hostname
let count = wc -l < notes.txt
echo "$branch on $host has $count lines of notes"
A let captures the command that produces its value. A function, a block in
hand, or a handle in that position binds what it returns; add | from-line
to bind what it writes:
let f = { hostname }
let x = f # prints the host name; x is ()
let host = f | from-line # binds it
A bare word runs a command only at the head of a command. In a list, record,
argument, or return, it is a string:
let colours = [red, green, blue]
echo green
Bindings are immutable. A later let shadows an earlier binding; it does not
change it:
let n = 1
let n = $[$n + 1]
ral never word-splits a value:
let file = 'my report.txt'
rm $file # exactly one argument
Quote when you want to join values and text into one argument:
echo "$dir/report.txt"
curl "$host:$port/api"
Outside quotes, $dir/file is not concatenation.
Strings
Single quotes are literal. Double quotes interpolate:
'literal: $name \n !{date}'
"hello $name"
"host , sum "
Inside double quotes, $name inserts a binding, $record[field] inserts a
field, !{command} runs a command, and $[…] computes an expression. The
usual explicit escapes include \n, \t, \\, \", \xNN, and \u{…}.
Only text, numbers, and true/false interpolate directly; () is nothing to
print. Convert collections, bytes, blocks, and handles explicitly:
echo "items: "
$(name) marks the end of a name:
echo "$(name)[old]"
echo "$(os)-$arch"
Raise the hash level when literal text contains apostrophes:
let message = #'it's working'#
let code = ##'
print('hello')
'##
Both string forms may span lines. dedent removes common indentation:
let query = dedent '
SELECT name
FROM users
'
There are no heredocs. << feeds a string to stdin:
sqlite3 app.db << $query
cat << #'
first line
second line
'#
3 Blocks and functions
A block { … } stores a command without running it:
let clock = { date +%s }
Force it to run:
clock
!$clock
!{date +%s}
A bound block in head position is forced implicitly. !$clock is explicit.
!{…} makes and forces an anonymous block, which is useful inside an argument
or expression.
! means force, never logical negation. Logical negation is not.
Blocks may take space-separated parameters:
let greet = { |name|
echo "hello, $name"
}
greet Ada
greet 'Grace Hopper'
Commas do not separate parameters:
{ |left right| $[$left + $right] } # two parameters
{ |left, right| () } # parse error
A block returns its last command's result. return leaves early:
let absolute = { |x|
if $[$x < 0] { return $[-$x] }
return $x
}
Blocks are lexically scoped. They remember the values visible at definition, and their local bindings disappear at the closing brace:
let outer = 'kept'
!{
let temporary = 'gone soon'
echo "$outer, $temporary"
}
echo $temporary # error: not bound here
Multi-parameter blocks are curried:
let add = { |x y| return $[$x + $y] }
let add-five = add 5
add-five 3 # 8
A function in ral is a parameterised, stored command.
4 Expressions and control flow
Arithmetic, comparison, and Boolean logic live in $[…]:
$[$x + 1]
$[$width * $height]
$[$count % 2]
$[$x >= 0 && $x < 10]
$[not $done]
Arithmetic requires numbers. &&, ||, and not require booleans; there is no
truthiness conversion. / on two integers is integer division.
if takes a Bool and block branches:
if $debug { echo 'debug logging enabled' }
if $[$mode == 'check'] {
echo checking
} elsif $[$mode == 'build'] {
echo building
} else {
fail [status: 2, message: "unknown mode: $mode"]
}
A two-armed if returns a value:
let sign =
if $[$n < 0] { return negative }
else { return nonnegative }
== inside $[…] compares data structurally. lt and gt compare strings lexicographically.
Failure and falsehood are different:
if !{exists $path} { echo present } # branch on Bool
cat $path ? echo missing # recover from failure
exists returning false is a successful computation. ? reacts only to
failure.
5 Structured values
Lists and records share square brackets:
let hosts = [web-1, web-2, web-3]
let server = [host: 'db.example.com', port: 5432]
$hosts[0]
"$server[host]:$server[port]"
Entries use commas. [] is an empty list; [:] is an empty map. A record has
known fields, possibly of different types. A map has string keys that are data
and values of one type; a "quoted" key is data, as a computed one is:
let key = 'staging'
let ports = [$key: 8080, "production": 443]
... spreads collections and command arguments:
let defaults = [host: localhost, port: 8080]
let config = [...$defaults, port: 9090]
let flags = [-l, -a]
ls ...$flags ...$directories
A spread of a record comes first, and the fields written after it replace the ones it already has; it never adds one.
Patterns take values apart:
let [first, ...rest] = args
let [host: host, port: port] = $config
let [name: name, address: [city: city]] = $person
A mismatch is a catchable runtime error. range 1 5 returns [1, 2, 3, 4].
Collection functions
Blocks are values, so functions can take them:
map { |x| return $[$x * 2] } [1, 2, 3] # [2, 4, 6]
filter { |x| return $[$x > 2] } [1, 2, 3] # [3]
fold { |s x| return $[$s + $x] } 0 [1, 2, 3] # 6
for puts the data first:
for $hosts { |host|
echo "checking $host"
ping -c 1 $host
}
There are no mutable loop counters. Carry state through fold, or use a
tail-recursive block. Tail calls reuse the current frame.
A named function used as data needs $:
map $upper [hello, world] # [HELLO, WORLD]
map upper [hello, world] # passes the string "upper"
Only head position invokes a bound block implicitly. Use explain NAME for
the current signature of map, first-where, sort-list-by, group-by, and the
rest of the prelude.
Variants
A variant says that a value is one of several alternatives:
let result =
if !{exists $path} { return `found !{file-info $path} }
else { return `missing }
case handles each alternative:
case $result [
`found: { |info| echo "$path: $info[size] bytes" },
`missing: { |_| echo "$path: not found" },
]
The arms are written out at the case itself, one per tag, which is what lets
the type checker prove they cover every alternative the value can be. A tag
without a payload binds (); _ ignores it. An arm may also name what to
run — `missing: $explain runs $explain on the payload — but the list of
arms is always there to read.
6 Pipelines, codecs, and files
ral has one pipeline. | is an operating-system byte pipe: the stage on its
left writes its standard output into the standard input of the stage on its
right. Position is the whole of the wiring:
cat data.txt | sort | uniq
Neither side has to use the wire. A stage may write nothing, and a stage may
never read. What | never carries is a value: a non-final stage's return value
is simply discarded, and the pipeline's value is the final stage's.
Each stage must be a command ready to run. A function still waiting for an argument is a type error, because there is nothing there to start:
cat notes.txt | fold-lines { |n line| return $[$n + 1] } # error
cat notes.txt | fold-lines { |n line| return $[$n + 1] } 0 # runs
Apply such a stage to its argument rather than piping into it. The second line runs and returns the number of lines.
Codecs cross between bytes and values:
| Decoder | Bytes become |
|---|---|
from-line |
one String, trailing newline removed |
from-string |
one String |
from-lines |
a list of strings, one per line |
from-json |
a ral value decoded from JSON |
from-jsonl |
a list of values, one per line of JSON |
from-csv |
a list of header-keyed maps of text |
from-bytes |
a Bytes value |
The encoders are to-line, to-string, to-lines, to-json, to-jsonl,
to-csv, and to-bytes — each takes one value and writes its encoded bytes. to-bytes is
from-bytes's inverse and takes a Bytes value; ral has no byte literal, so
ints-to-bytes writes bytes given as numbers, and
ints-to-bytes [104, 105] | from-bytes is how a Bytes value is minted.
Decode while bytes are flowing, and let the decoder be the last stage. Its
returned value is the pipeline's, and let binds it:
let branch = git branch --show-current | from-line
let config = curl -fsS $url | from-json
let rows = curl -fsS $csv-url | from-csv
A value already in hand is not on any pipe. Encode it into one first, or read the file directly:
let config = to-string $text | from-json # right
let config = from-json < $path # right
let config = $text | from-json # refused
The last line is refused before anything runs. $text is a value, and a value
in stage position writes nothing to the pipe, so from-json would read end of
input. The string never reached it.
| moves bytes: a stage feeds the next by writing, and the pipeline's value is
its final stage's. The type checker checks every stage before a process starts.
Values compose by application, not by |:
let evens = filter { |n| return $[$n % 2 == 0] } !{range 1 10}
let doubled = map { |n| return $[$n * 2] } $evens
File I/O is a redirect plus a codec:
let body = from-string < $path
let rows = from-lines < $path
let config = from-json < $path
to-string $body > $path
to-json $config > $path
echo $line >> $log
Mutations use cp, mv, rm, mkdir, and the other bundled tools. Queries
return values:
glob #'src/**/*.rs'#
list-dir $path
file-info $path
line-count $path
exists $path
is-file $path
map-lines, filter-lines, each-line, and fold-lines read the byte pipe a
line at a time, in bounded memory, so they belong in a pipeline:
cat access.log | filter-lines { |line| re-match ' 500 ' $line } | wc -l
from-lines instead reads to the end and returns a list, so it ends the
pipeline like any other decoder:
let commits = git log --oneline | from-lines
take 5 $commits
Every line reader splits the same way: a line ends at \n or \r\n, and the
last line need not end at all. to-lines ends every line with \n.
7 Scripts, scope, and modules
A script begins:
#!/usr/bin/env ral
let [target, port] = args
echo "deploying to $target on $port"
args returns only user arguments. Forward them with ...!{args}. $SCRIPT
is the current file's path. The shell's own state is read by builtins, not
variables: env is a map of environment variables, nproc is the CPU count,
and cwd, home, and user are what they say.
Use within for a scoped directory or environment:
within [dir: build] {
cmake ..
make -j !{nproc}
}
within [env: [RUST_LOG: debug, PORT: 8080]] {
cargo run
}
The old directory and environment return at the closing brace. Functions called inside the block see the active overrides, even if defined elsewhere.
use evaluates a file in its own scope and returns its public bindings:
let strings = use 'lib/strings.ral'
$strings[trim] ' hello '
Bindings beginning with _ stay private. Paths are relative to the
containing file; RAL_PATH adds search directories.
8 Failure and audit
Failure propagation is always on. A nonzero exit, fail, or a runtime error
stops the current computation unless something handles it.
try catches a failure:
let config = try {
return !{curl -fsS $primary | from-json}
} { |error|
echo "primary failed: $error[message]"
return !{curl -fsS $fallback | from-json}
}
Keep the handler on the same line as the body's closing brace: } {.
The handler receives an error record with status, cmd, message and
site fields, where site is `some [script: String, line: Int, col: Int]
or `none. message describes the failure; it is not the command's stderr.
Raise and re-raise with an error record:
fail [status: 2, message: 'usage: deploy HOST']
try { risky-work } { |error| fail $error }
guard always runs cleanup, then lets the original failure continue — and a
cleanup that fails in turn pre-empts it, since a cleanup that could not fail
the run would have nowhere to report from:
let temporary = temp-file
guard {
curl -fsS -o $temporary $url
consume $temporary
} {
rm -f $temporary
}
The prelude contains common policies:
retry 3 { curl -fsS $url }
attempt { rm stale.lock }
succeeds { cargo check -q } # Bool
audit turns success or failure into a report — [outcome, trail]:
let report = audit { make -j4 }
echo !{bytes-to-string !{commands $report}[0][stderr]}
outcome is `ok of the body's value or `err of the very record
try would have handed a handler, so failure reads the same either way:
if !{succeeded $report} { echo built } else { echo 'build failed' }
case $report[outcome] [
`ok: { |value| return $value },
`err: { |error| echo $error[message] },
]
trail is the flat list of what happened during the body, each entry an
envelope — source location, timing, principal — around one what, a variant
tagged `command, `write, `read, `check, or one of
their kin. Entries arrive in settlement order, so a command lands after
everything it ran; their start/end intervals say which contained which.
commands $report picks out the `command payloads — argv, status,
origin, stdout, stderr, error — and is what you want whenever only commands
matter.
ral --audit script.ral prints the same report for a whole script as JSON on
stderr; add --pretty for indented JSON.
9 Concurrency
spawn runs a block on another thread and returns a handle at once. The
string before the block is its title, which says what the work is:
let tests = spawn "tests" { cargo test -q }
let lint = spawn "lint" { cargo clippy -q }
# do other work
await $tests
await $lint
await behaves as if the block had run on that line: its output appears
here, its value is returned, and its failure is raised here, so put the
await inside try to recover. let log = await $tests binds the output, as
let log = { cargo test -q } would. The interactive shell lets you leave the
title out, spawn { cargo test -q }; an agent host requires it. There is no
trailing &: a POSIX & makes a job for fg/bg, while spawn makes a
worker you await, so ral asks you to say which you meant.
A handle's result is claimed once: a second await of it fails, as does an
await after race or cancel claimed it. poll checks without blocking,
printing or claiming: it returns `pending with the output so far and the
seconds elapsed, or `settled with the output, the time it took, and an
`ok / `err outcome, and the handle can still be awaited after it.
cancel $tests stops a worker, and everything it started. A spawn inside
a spawned block belongs to that block, which finishes only after it has, its
output and failure folded into its own.
Every worker has a numeric id beside its title. workers lists them, one
record each with its id, title, owner, state and timing; it is data, not
handles. When a binding is gone, worker takes the id back to the handle:
workers
await !{worker 7}
Fan-out that must leave nothing running is a gather, a block that owns
the workers spawned inside it:
gather {
let t = spawn "tests" { cargo test -q }
let l = spawn "lint" { cargo clippy -q }
return [tests: !{await $t}, lint: !{await $l}]
}
When the block returns, every worker in it that no one has awaited is awaited in the order it was born; when it fails or is interrupted, they are all cancelled. The prelude builds the common shapes on it:
par { |file| convert $file } $files !{nproc}
both { cargo test -q } { cargo clippy -q }
first-of [{ fetch-from a }, { fetch-from b }]
race [$first, $second]
par is a bounded parallel map that keeps input order, output included.
both runs two blocks at once and returns [fst: …, snd: …]; it awaits fst
then snd, so a failure in either is raised when its turn comes and the other
is cancelled. first-of runs several blocks and behaves as the first to
finish, cancelling the rest. race does the same for handles you already hold:
it claims the first to finish and cancels the others. In par, first-of and
race the first failure cancels the others and is raised, and Ctrl-C cancels
them all. A gather leaves nothing running when it
returns.
watch build { make -j !{nproc} } is a spawn that streams labelled output.
Spawned blocks inherit immutable values, so there is no shared mutable state
or data race.
Some agent hosts also provide detach for an OS process that may outlive the
session. It is absent when the host cannot provide those semantics; ask
explain before relying on it.
10 Reinterpreting commands
within can change what an external command means for one block. A named
handler receives its argument list:
within [
handlers: [
curl: { |args| echo #'{"status": "ok", "revision": "abc123"}'# },
],
] {
let response = curl -fsS https://example.test/deploy | from-json
echo "deployed $response[revision]"
}
No request occurs: the handler supplies curl's meaning inside the block.
Handlers are deep, so functions called by the body see them too.
Handlers are self-masking. They may call the same command to forward it without recursing:
within [handlers: [git: { |args|
echo "+ git "
git ...$args
}]] {
git status --short
}
A catch-all handler: receives |name args|. Bindings are lexical, but
directory, environment, handlers, and capabilities follow the call site for
the whole block.
11 Capabilities
grant removes authority for one block:
grant [
exec: [allow: [`name git, `name rg]],
fs: [
read: [`cwd .],
write: [`tempdir .],
],
net: `offline,
] {
git status --short # allowed
curl $url # denied: curl was not granted
}
Each mentioned dimension becomes deny-by-default:
exec:listsallowanddenytargets:`name git, a place such as`absolute /opt/tools/lint, or`dir `system(or`dir `path, or a package manager such as`dir `rustupor`dir `homebrew) for a directory. An allowed target runs with any arguments; ral does not filter arguments;fs:lists the places that are readable, writable, and denied;net:is`offlineor omitted; a grant cannot ask for the network;editor:andshell:list verbs, for exampleshell: [`chdir].`detachis ashellverb: it permits work that outlives the session, so ashelllist without it refuses that work.
An omitted dimension keeps the caller's authority. A nested grant can only
reduce authority, never restore it.
Three rules decide what a grant means:
- An exclusion (
deny) beats every allowance, however specific: an exact file or a deeper directory inside an excluded directory does not run. A refusal says which exclusion applies; no allowance overrides it. An exclusion is a guardrail, not a wall: it names programs by path and name, so a copy of one, or a script fed to an allowed interpreter, is a different request. - Whatever a grant lets run by path, directory or name, or lets be written, may
be read. A nested
fsgrant therefore does not hide a tool an enclosing grant runs; exclude it to hide it. - An allowance by path names what it named when it was granted: a symlink put
in its place later redirects nothing. An exclusion follows its name, and a
bare program name follows
PATHafresh at each run.
Every dimension grants or refuses authority; none decides what is written
down. Whenever an audit trail is open, a denial is recorded as a `check
entry and an allowed check never is, so no inner grant can hide a refusal
from the trail you opened around it.
Places are a tag and a relative subpath: `cwd src, `tempdir .,
`gitdir ., `xdg-config tools, `home project, or
`absolute /usr/share. They are fixed when the policy is loaded, so a
later directory or environment change cannot retarget them. Every list is a
set, so naming something twice changes nothing. Tags are checked before the
program runs: net: true and a bare string where a place belongs are errors.
Filesystem rules cover redirects, structured queries, and bundled tools; execution and network rules cover external processes. If the operating system cannot enforce a requested external restriction, ral fails closed. Start a script under saved profiles with:
ral --capabilities read-only.ral script.ral
12 Types and the model underneath
ral infers types for values, functions, records, variants, pipelines, and handles. Check a script without performing any command:
ral --check script.ral
The checker catches a pipeline stage that is still waiting for an argument,
non-scalar interpolation, wrong function arguments, incompatible if branches,
missing case arms, and impossible record fields. Every type is inferred: the
language has no syntax for writing one.
The deeper model is now small:
- a value is inert data;
- a command may work, return a value, emit bytes, or fail;
{ command }turns a command into a value;!blockruns that stored command;- an external command call is an effect;
within [handlers: …]changes an effect's interpretation;grantlimits which effects may occur;auditrecords the effects that occurred.
This is call-by-push-value with one practical effect: executing commands. The name is optional; the separation between values and commands is not.
13 A complete script
This script writes a JSON manifest of a directory's regular files:
#!/usr/bin/env ral
if $[!{length !{args}} != 2] {
fail [status: 2, message: 'usage: manifest ROOT OUTPUT.json']
}
let [root, output] = args
if $[not !{is-dir $root}] {
fail [status: 2, message: "not a directory: $root"]
}
let manifest = within [dir: $root] {
let paths = glob #'**/*'#
let files = filter { |path| return !{is-file $path} } $paths
return !{map { |path|
let info = file-info $path
return [path: $path, bytes: $info[size]]
} $files}
}
to-json $manifest > $output
echo "wrote files to $output"
Check it, then run it:
ral --check manifest.ral
ral manifest.ral ./src manifest.json
There is no text parsing for metadata, temporary file for the atomic write, mutable loop state, or directory change that can leak past its block.
14 Coming from bash
ral keeps commands, arguments, pipes, and redirects. It removes the features that turn data back into source text.
| bash | ral |
|---|---|
x=foo |
let x = 'foo' |
x=$(command) |
let x = command |
$(command) inline |
!{command} |
"$value" to prevent splitting |
$value — values never split |
"$dir/$file" |
"$dir/$file" — quotes join atoms |
${name} |
$(name) |
a && b |
a then b; failure already stops the sequence |
a || b |
a ? b |
set -e |
always on |
$@ |
args; forward with ...!{args} |
export KEY=value |
within [env: [KEY: value]] { … } |
cd dir in a script |
within [dir: dir] { … } |
trap cleanup EXIT |
guard { body } { cleanup } |
| heredoc | command << #'…'# |
$((x + 1)) |
$[$x + 1] |
[ -f "$path" ] |
is-file $path |
| mutable loop state | fold or tail recursion |
Remember six things:
let x = foorunsfoo;let x = 'foo'stores text.$fileis already one argument.- Decode before capture:
let config = curl … | from-json. ?andtryhandle failures;ifhandles booleans.- Pass a named function as
$functionwhen it is an argument. - Use
withinfor directory and environment changes.
The running shell knows its own surface:
help
explain map
explain from-json
explain grant
The examples contain complete programs paired with the bash failure mode they replace. The specification is the precise language definition, and the rationale explains the design.