examples
A guided tour of typical shell tasks written in ral. Each is a complete, runnable script — `ral <file>.ral` to execute. The comment block at the top explains the bash equivalent and what ral does differently.
hello.ral
Greet a list of people.
ral hello.ral alice bob "carol smith"
In bash, "carol smith" would split into two words unless you remembered
to quote everything in the loop body too. Here it cannot — `$args` is
a list of String values, not a re-lexed token soup.
let names = if !{is-empty $args} { return [world] } else { return $args }
for $names { |n| echo "hello, $n" }
wc.ral
Count lines, words, and bytes for each file.
ral wc.ral file1 file2 ...
`length` is polymorphic — Strings, Lists, Maps, Bytes share one name —
so the three counts read like three projections of the same datum,
which is what they are. Bytes come from `file-info` because `length`
of a String is in Unicode scalar values, not octets, and the
distinction is worth keeping honest.
if !{is-empty $args} {
echo 'usage: wc.ral FILE...' 1>&2
exit 2
}
let count = { |path|
let body = !{from-string < $path}
return [
path: $path,
lines: !{length !{lines $body}},
words: !{length !{words $body}},
bytes: !{file-info $path}[size],
]
}
for !{map $count $args} { |s|
echo "$s[lines]\t$s[words]\t$s[bytes]\t$s[path]"
}
dedup.ral
Print stdin lines unique, preserving first-seen order.
cat access.log | ral dedup.ral
`sort -u` reorders. `awk '!seen[$0]++'` works but is a magic
incantation — print-on-falsy-post-increment, only legible if you have
the K&R awk paper open. Here the shape is the obvious one: a fold
over stdin lines whose accumulator is the set of seen lines.
fold-lines { |seen line|
if !{has $seen $line} { return $seen } else {
echo $line
return [...$seen, $line: true]
}
} [:]
rename-extension.ral
Rename every *.FROM in a directory to *.TO. Lower-casing a stack of
JPEGs:
ral rename-extension.ral ~/photos JPEG jpg
The bash idiom
for f in *.JPEG; do mv "$f" "${f%.JPEG}.jpg"; done
silently misbehaves when the glob has zero matches (the literal
pattern becomes the filename), and when filenames contain spaces or
newlines. Neither failure mode exists here: `glob` returns `[]` when
nothing matches, and filenames are values that are never re-split.
let [dir, from, to] = $args
let targets = glob "$dir/*.$from"
if !{is-empty $targets} {
echo "no *.$from files in $dir"
exit 0
}
for $targets { |old|
let new = "!{path-join [!{dir $old}, !{stem $old}]}.$to"
echo "$old -> $new"
mv $old $new
}
find-large.ral
List the 10 largest files under a directory, biggest first.
ral find-large.ral .
The bash one-liner
find . -type f -printf '%s\t%p\n' | sort -rn | head
is one tab character in a filename away from disaster. Here every
file-info record is a value of its own; sorting and slicing are
operations over typed records, not field-cut strings.
let [root] = $args
let stats = map { |p|
return [path: $p, size: !{file-info $p}[size]]
} !{filter { |p| is-file $p } !{glob "$root/**/*"}}
let biggest = take 10 !{reverse !{sort-list-by { |s| return $s[size] } $stats}}
for $biggest { |s|
echo "$s[size]\t$s[path]"
}
du-by-ext.ral
Sum file sizes under a directory, grouped by extension; biggest
total first.
ral du-by-ext.ral ~/Pictures
The bash idiom is
find . -type f -printf '%s\t%f\n' \
| awk -F'\t' '{n=split($2,a,".");s[a[n]]+=$1} END {for(k in s) print s[k],k}' \
| sort -rn
which works until a filename contains a tab. Here the bucket map is
keyed by the actual extension value; the aggregation is a fold;
nothing is stringly typed.
let [root] = $args
let files = filter { |p| is-file $p } !{glob "$root/**/*"}
let totals = fold { |acc p|
let key = ext $p
let bytes = !{file-info $p}[size]
let prev = !{get $acc $key 0}
return [...$acc, $key: $[$prev + $bytes]]
} [:] $files
let rows = map { |k| return [ext: $k, size: $totals[$k]] } !{keys $totals}
let sorted = reverse !{sort-list-by { |r| return $r[size] } $rows}
for $sorted { |r|
let label = if !{equal $r[ext] ''} { return '(none)' } else { return ".$r[ext]" }
echo "$r[size]\t$label"
}
kv-to-json.ral
Parse a `key=value` config file, emit JSON to stdout.
ral kv-to-json.ral app.env
Bash habit: `source app.env`. That exposes every key as a global —
and a line like `RM='rm -rf /'` is happily executed by the shell on
the way in. Configuration is data; reading it should not be running
it. Here it stays data.
let [path] = $args
let kv-of = { |line|
let parts = re-split '=' $line
let key = $parts[0]
let val = intercalate '=' !{drop 1 $parts}
return [$key: $val]
}
let result = fold { |acc line|
let l = re-replace-all '^\s+|\s+$' '' $line
if $[!{equal $l ''} || !{re-match '^#' $l}] { return $acc } else {
return !{union $acc !{kv-of $l}}
}
} [:] !{from-lines-list $path}
echo !{to-json $result | from-string}
csv-summary.ral
Group a CSV by one column, average a numeric column.
ral csv-summary.ral sales.csv region revenue
The shape is the standard awk associative-array trick — except the
records survive the loop as records (so column names are real
identifiers), and the average is one more fold instead of a printf
trick.
let [path, group_col, value_col] = $args
let [head, ...rows] = !{from-lines-list $path}
let header = re-split ',' $head
let row-of = { |line|
let cells = re-split ',' $line
fold { |m pair|
let k = $pair[0]
let v = $pair[1]
return [...$m, $k: $v]
} [:] !{zip $header $cells}
}
let buckets = fold { |acc r|
let k = $r[$group_col]
let v = float $r[$value_col]
let prev = !{get $acc $k [count: 0, total: 0.0]}
return [...$acc, $k: [count: $[$prev[count] + 1], total: $[$prev[total] + $v]]]
} [:] !{map $row-of $rows}
echo "$group_col\tavg($value_col)\tn"
for !{keys $buckets} { |k|
let b = $buckets[$k]
let n = float $b[count]
let avg = $[$b[total] / $n]
echo "$k\t$avg\t$b[count]"
}
atomic-update.ral
Read a JSON state file, bump a counter and append a timestamp,
write it back.
ral atomic-update.ral state.json
Bash:
jq '.count += 1' state.json > state.json.tmp \
&& mv state.json.tmp state.json
The tmp-then-rename is what gives you atomicity, and you have to
remember it. In ral, `>` *is* rename-on-close: readers see either
the old contents or the new, never a partial write. Crash, ^C,
power loss — the file is still valid.
let [path] = $args
let state = if !{is-file $path} {
return !{from-json < $path}
} else {
return [count: 0, history: []]
}
let stamp = !{date -u +%Y-%m-%dT%H:%M:%SZ | from-string}
let next = [
...$state,
count: $[$state[count] + 1],
history: [...$state[history], $stamp],
]
to-json $next > $path
echo "tick $next[count] at $stamp"
hash-tree.ral
Hash every file under a directory in parallel.
ral hash-tree.ral . # 8 workers
ral hash-tree.ral . 16 # 16 workers
Bash:
find . -type f -print0 | xargs -0 -P 8 sha256sum
The `-print0` / `-0` dance exists only because filenames are shell
tokens that get re-split. Here a path is a value; `par` distributes
values; the same paths come back out the other side paired with
their hashes, in input order.
let [root, ...rest] = $args
let jobs = if !{is-empty $rest} { return 8 } else { return !{int $rest[0]} }
let files = filter { |p| is-file $p } !{glob "$root/**/*"}
let results = par { |p|
let line = !{sha256sum $p | from-string}
let parts = words $line
return [hash: $parts[0], path: $p]
} $files $jobs
for !{sort-list-by { |r| return $r[path] } $results} { |r|
echo "$r[hash] $r[path]"
}
fanout-fetch.ral
Fetch a list of URLs in parallel; write a JSON manifest of outcomes.
ral fanout-fetch.ral urls.txt out/
Bash:
cat urls.txt | xargs -P 8 -I{} curl -fsSL -o "out/$(basename {})" {}
which silently loses per-URL failures and depends on filenames being
unique within `basename`. `par` returns a structured result per
input — failures land in the manifest alongside successes, so the
"what didn't fetch?" answer is one filter call.
let [list_file, out_dir] = $args
let urls = from-lines-list $list_file
mkdir -p $out_dir
let fetch = { |url|
let dst = "$out_dir/!{re-replace-all '[^a-zA-Z0-9.]+' '_' $url}"
try {
curl -fsSL --max-time 15 $url > $dst
return [url: $url, path: $dst, ok: true, status: 0]
} { |err|
return [url: $url, path: '', ok: false, status: $err[status]]
}
}
let results = par $fetch $urls 8
to-json $results > "$out_dir/manifest.json"
let ok = filter { |r| return $r[ok] } $results
let bad = filter { |r| return $[not $r[ok]] } $results
echo "fetched: !{length $ok} ok, !{length $bad} failed"
for $bad { |r|
echo "!{styled $ansi-red ' fail'} status=$r[status] $r[url]" 1>&2
}
tail-multi.ral
Multiplex `tail -f` across several log files, with each line
prefixed by its source. ^C ends.
ral tail-multi.ral access.log error.log audit.log
`tail -f a b c` already prints `==> file <==` blocks, but its output
garbles when two streams write at once — the prefix lands separated
from its lines. Here `watch LABEL { body }` spawns the command and
line-frames its stdout: every line emerges atomic, prefixed.
if !{is-empty $args} {
echo 'usage: tail-multi.ral FILE...' 1>&2
exit 2
}
let handles = map { |f|
return !{watch !{base $f} { tail -f $f }}
} $args
for $handles { |h| await $h }
safe-eval.ral
Capability-sandboxed evaluation.
ral safe-eval.ral
`grant` attenuates authority — it can only narrow, never amplify.
Inside the block, the program sees a synthetic universe where one
scratch directory is writable, nothing else is, and exec is dead.
Bash's analogue is `chroot` + `setuid` + `seccomp`, each at a
different layer of the system, all process-scoped, none in-language.
Here it is one expression around the offending code.
let sandbox = temp-dir
let try-op = { |label body|
let outcome = try { !$body ; return 'allowed' } { |_| return 'blocked' }
echo " $outcome $label"
}
echo 'outside the grant block:'
try-op 'write inside /tmp' { to-string 'ok' > "$sandbox/scratch" }
try-op 'read /etc/hosts' { let _ = !{from-string < /etc/hosts} ; return unit }
echo ''
echo 'inside grant [exec: [:], fs: [read+write only the sandbox]]:'
grant [exec: [:], fs: [read: [$sandbox], write: [$sandbox]]] {
try-op 'write inside sandbox' { to-string 'ok' > "$sandbox/inside" }
try-op 'write outside sandbox' { to-string 'no' > "/tmp/escape" }
try-op 'read /etc/hosts' { let _ = !{from-string < /etc/hosts} ; return unit }
try-op 'exec curl' { curl http://example.com 2> /dev/null }
}
rm -rf $sandbox