Learn rigid
rigid is a small language: integers, floats, strings, lists, records, functions, two loop forms, and two conditional forms. This page walks all of it from the first program. Every snippet here was run against the compiler.
If you have written any C-family language you will recognise most of the syntax. One thing is worth
knowing up front: if has two forms — a statement whose else is optional, and
an expression whose else is mandatory.
1. Install and run
You need a Rust toolchain from rustup.rs. There is nothing else to install and no package manager.
git clone https://github.com/DavidRDudas/rigidlang
cd rigid-lang
cargo build --release
export PATH="$PWD/target/release:$PATH"
A program is a folder of .rg files. No manifest, no project file, no build step. Make a
folder and put this in main.rg:
fn main() !io {
return print("hello, rigid");
}
$ rigid check
ok: 1 module(s), 1 definition(s), 1 hashed
$ rigid run "main.rg#main"
hello, rigid
"hello, rigid"
You see the text twice because print writes it and also returns it, and
run prints whatever the definition returned. The !io marker is required
because printing is an effect; effects get their own section.
Definitions are addressed as file.rg#name. Quote it in the shell, since
# starts a comment in most shells.
2. The three commands
Almost everything is these three:
| rigid check | Parse, resolve every name, hash every definition, reconcile the sidecars. This is your compile step. |
| rigid run PATH#NAME args… | Evaluate one definition and print the result. |
| rigid test | Run every test_* function. Results are cached by content hash. |
The first check writes a *.rg.bind.json next to each source file. Those are
lockfiles for meaning — they record which definition each imported name resolved to, pinned by
hash. Commit them; you never edit them by hand. Modules covers what they do.
There is also rigid repl --root DIR for a prompt against your workspace, rigid
fmt for canonical formatting, rigid deps / rigid rdeps for the
dependency graph, and rigid build --target js to compile to JavaScript.
3. Values
There are six kinds of value: integers, floats, strings, lists, records, and function references.
42 // integer — 64-bit, signed
"hello" // string
3.14159 // float
[1, 2, 3] // list
{ name: "ada", age: 36 } // record
&some_function // a reference to a definition
Integers and floats are separate. Two integers divide as integers, a float on either side promotes the
other, and float(n) converts explicitly — nothing is widened behind your back:
7 / 2 // 3 — integer division truncates
7.0 / 2 // 3.5 — a float promotes the other side
float(7) / 2 // 3.5 — or convert on purpose
1 == 1.0 // 1 — equality is numeric across both
There is no boolean type. Comparisons return the
integers 1 and 0, and any nonzero integer counts as true:
3 > 2 // 1
3 == 2 // 0
if (1) "yes" else "no" // "yes"
if (0) "yes" else "no" // "no"
Arithmetic is + - * / %, on integers and on floats. Integer division truncates; float
division does not. + is also concatenation for strings and for lists:
7 / 2 // 3
7 % 2 // 1
"foo" + "bar" // "foobar"
[1] + [2, 3] // [1, 2, 3]
Comparison operators are == != < > <= >=. The ordering operators take two
numbers or two strings, so "a" < "b" is 1. Comments are // to
the end of the line, and they survive rigid fmt.
4. Variables
let introduces a local. Assigning to it afterwards drops the let:
fn area(w, h) {
let a = w * h;
return a;
}
fn counter() {
let i = 0;
let total = 0;
while (i < 5) {
total = total + i; // assignment: no `let`
i = i + 1;
}
return total; // 10
}
Locals are the only mutable thing in the language. You cannot assign to a definition — top-level
functions and tables are immutable, and trying to reassign one is error E0210.
Scope is flat for the whole function body rather than per block. A let inside a loop body
keeps one stable slot across iterations, and reading it after a loop that never ran gives 0.
Local names are erased before hashing. Renaming a to result throughout a
function does not change that function's hash — see what identity covers.
5. Functions
A function is fn, a name, parameters, and a body in braces. There is no return type and no
parameter types:
fn greet(name) {
return "hello, " + name;
}
fn severity(impact, urgency) {
return impact * urgency;
}
$ rigid run "basics.rg#greet" world
"hello, world"
Argument counts are checked when you compile, at every call site. This is the one type-like check the
language has, and it applies to direct calls, dispatch table entries, and dyn targets:
$ rigid check
error[E0209] a.rg:2:19: 'add' has arity 2, called with 1 argument(s)
Recursion is fine, including mutual recursion between functions. fn(x) { ... } is an
anonymous function, and it captures: each one is lifted to a top-level definition named
<enclosing>$<n> with its own hash, and the variables it captures become that
definition's leading parameters. So a lambda is a definition like any other —
rigid rdeps reaches it, and editing its body moves a hash. Calling one goes through a
declared dyn set, covered in section 17.
6. Conditionals
if has two forms. The statement form takes blocks, chains with
else if, and its else is optional:
fn classify(n) {
if (n < 0) {
return "negative";
} else if (n == 0) {
return "zero";
} else {
return "positive";
}
}
The expression form produces a value, so its else is mandatory. You will
see it after return or on the right of a let:
fn absval(n) {
return if (n < 0) 0 - n else n;
}
fn label(score) {
let tier = if (score > 90) "high" else "normal";
return tier;
}
Chain them with else if for multi-way branching:
fn classify(n) {
return if (n < 0) "negative"
else if (n == 0) "zero"
else "positive";
}
$ rigid run "basics.rg#classify" -5
"negative"
$ rigid run "basics.rg#classify" 0
"zero"
The two forms are different constructs, and the compiler tells them apart by what follows the
condition: a { opens a statement block, anything else is the expression. So the
expression form always needs its else — there is no value without one.
7. Loops
rigid has two loops. for (x in xs) walks a list or the characters of a
string:
fn total(xs) {
let acc = 0;
for (x in xs) {
acc = acc + x;
}
return acc;
}
fn spell(s) {
let out = "";
for (c in s) {
out = out + c + ".";
}
return out; // spell("ab") is "a.b."
}
The loop variable is an ordinary local, so renaming it does not move the function's hash.
while is the general form. There is no break and no continue, so
a counted loop is written out longhand:
fn sum_to(n) {
let total = 0;
let i = 1;
while (i <= n) {
total = total + i;
i = i + 1;
}
return total;
}
$ rigid run "flow.rg#sum_to" 100
5050
Walking a list is the same shape, using len for the bound and xs[i] to
index:
fn total(xs) {
let sum = 0;
let i = 0;
while (i < len(xs)) {
sum = sum + xs[i];
i = i + 1;
}
return sum;
}
Without break, an early exit is a condition on the loop variable — set it past the bound to
stop:
fn find(xs, target) {
let i = 0;
let found = 0 - 1;
while (i < len(xs)) {
found = if (xs[i] == target) i else found;
i = if (xs[i] == target) len(xs) else i + 1;
}
return found;
}
Recursion is often clearer, and it is the style most of the bundled examples use. Both of these are ordinary rigid; pick whichever reads better:
fn sum_rec(xs, i) {
return if (i >= len(xs)) 0 else xs[i] + sum_rec(xs, i + 1);
}
8. Lists
Lists are written with square brackets, indexed with xs[i] starting at zero, measured with
len, and extended with push. push returns a new list
rather than modifying in place, so you assign the result back:
fn grow() {
let xs = [];
let i = 1;
while (i <= 4) {
xs = push(xs, i * i);
i = i + 1;
}
return xs;
}
$ rigid run "data.rg#grow"
[1, 4, 9, 16]
Indexing out of bounds is a runtime error, and it is catchable — see errors.
9. Records
Records are brace-delimited name/value pairs, read with a dot:
fn user() {
return { name: "ada", score: 42 };
}
fn score_of(u) {
return u.score;
}
Records are immutable. To "change" a field, build a new record:
fn bump(u) {
return { name: u.name, score: u.score + 1 };
}
$ rigid run "data.rg#user"
{name: "ada", score: 42}
Field order is not part of a record's identity. { name: "ada", score: 1 }
and { score: 1, name: "ada" } hash identically, because the canonical form sorts fields by
name. The field names do matter — renaming name to title is a
different record.
10. Strings
Strings support the escapes \n \t \r \e \" \\. Indexing a string gives a one-character
string. The builtins are len, substr(s, start, count), chars(s),
join(list, separator), str(n), and int(s):
fn text() {
let s = "hello world";
return substr(s, 6, 5); // "world"
}
fn spaced(s) {
return join(chars(s), "-"); // "abc" -> "a-b-c"
}
fn report(n) {
return "count=" + str(n); // ints must be converted explicitly
}
"abc"[1] is "b". int("nope") fails at runtime, which is
catchable:
11. Errors
Errors are values. fail(v) raises any value you like, and try(e) catches it,
returning a record: { ok: 1, value: … } on success or { ok: 0, error: … } on
failure.
fn checked_div(a, b) {
return if (b == 0)
fail({ code: 400, msg: "division by zero" })
else a / b;
}
fn safe_div(a, b) {
let r = try(checked_div(a, b));
return if (r.ok) r.value else 0 - 1;
}
$ rigid run "data.rg#safe_div" 10 0
-1
Because the payload is an ordinary value, you can inspect it:
fn err_code(a, b) {
let r = try(checked_div(a, b));
return if (r.ok) 0 else r.error.code; // 400
}
Runtime errors from builtins are catchable the same way — division by zero, an out-of-bounds index, a
failed int() parse, a missing table key:
fn parse_or(s, fallback) {
let r = try(int(s));
return if (r.ok) r.value else fallback;
}
12. Tests
A test is a function whose name starts with test_. It takes no arguments, must be pure, and
passes by returning a nonzero value:
fn test_shout() {
return shout("hi") == "hi!";
}
fn test_sum() {
let xs = [1, 2, 3, 4];
let sum = 0;
let i = 0;
while (i < len(xs)) {
sum = sum + xs[i];
i = i + 1;
}
return sum == 10;
}
$ rigid test
PASS tests.rg#test_shout
PASS tests.rg#test_sum
2 executed, 0 skipped via content-address cache, 0 failed
$ rigid test
PASS tests.rg#test_shout [cached — content unchanged, rerun provably identical]
PASS tests.rg#test_sum [cached — content unchanged, rerun provably identical]
0 executed, 2 skipped via content-address cache, 0 failed
The second run executed nothing. A test's hash covers everything it transitively depends on, so an
unchanged hash means a rerun would do exactly the same work. Edit one function and precisely the tests
downstream of it re-execute. The cache lives in .rigid-testcache.json; delete it to force
reruns, or pass --no-cache.
13. Modules
One file is one module. Import by relative path, naming what you want:
fn shout(s) {
return s + "!";
}import { shout } from "./words.rg";
fn main() !io {
return print(shout("hello"));
}Three modules ship with the compiler, imported the same way with a
std/ path instead of a relative one:
import { sort, unique } from "std/list.rg";
import { split, trim } from "std/str.rg";
import { abs, gcd } from "std/math.rg";
They are ordinary rigid, written in the language and tested in it. Only the modules a workspace actually imports are loaded, so using none of them costs nothing.
On the first check, each import is pinned in a sidecar file:
$ rigid check
info[I0402] app.rg:1:10: pinned 'shout' -> words.rg#shout (sha256:f2aeacb386b8…)
ok: 3 module(s), 4 definition(s), 4 hashed
That pin is what makes renames survivable. Rename shout to yell in
words.rg and forget the caller, and the compiler recognises the definition by its hash and
tells you where it went — then repairs it:
$ rigid check
error[E0201] app.rg:1:10: unresolved import 'shout' from "words.rg" —
the pinned definition (sha256:f2aeacb386b8…) now exists under the name 'yell'
$ rigid fix --apply
applied: 3 occurrences of 'shout' -> 'yell' in app.rg
converged: check is green
The import line and both call sites were rewritten. Now the case that gives the language its name — a different definition takes over the old name:
$ rigid check
error[E0202] app.rg:1:10: 'shout' from "words.rg" no longer means what it
meant: the pinned definition (sha256:f2aeacb386b8…) is now named 'yell',
and 'shout' now names a different definition (sha256:a9dd7e003ea9…).
Refusing to silently rebind. Run `rigid rebind app.rg#shout` to accept
the new meaning, or rename the reference to 'yell'.
In a name-bound language that situation compiles, and every existing call to shout quietly
starts doing something else. Here it stops the build and names both hashes. Accepting the new meaning is
a deliberate command, rigid rebind, and is never applied automatically.
If a dependency is merely edited, that is drift rather than a meaning change: you get
W0401 and the pin updates. Run rigid check --frozen in CI to turn any required
sidecar write into a failure.
14. Effects
A function that can perform I/O is marked !io. The io builtins are print,
input, read_file, and write_file:
fn greet(name) !io {
return print("hello, " + name);
}
Leave the marker off and the compiler stops you:
$ rigid check
error[E0207] a.rg:2:10: io builtin requires `!io`; declare `fn oops(...) !io`
Effects propagate along the real call graph. Anything that calls an !io
function must itself be !io, and this is checked exactly — including through dispatch
tables, so a pure-looking dispatcher over a table containing one effectful handler is rejected. The
practical consequence is that a function without !io is guaranteed not to touch the outside
world, which is what makes tests cacheable and memo sound.
!io is one of a set. When you reach the host through extern, say which part
of it you are reaching:
extern fn fetch(url) !net = "globalThis.fetch";
fn download(u) !net {
return fetch(u);
}
The vocabulary is !io !fs !net !env !time !rand !proc, and a function must declare every
capability it can reach. Because capabilities are part of a definition's identity, an imported function
that gains one is a named event — W0405 — and a build failure under
--frozen. If a dependency you pinned as pure starts reaching the network, you find out from
the compiler. rigid caps prints the whole surface with the externs that supply it.
15. What identity covers
This is the part that surprises people, so it is worth being precise. A definition's identity is the SHA-256 of its canonical form, in which bound names are erased and every reference to another definition is replaced by that definition's hash.
So renaming a function, its parameters, or its locals does not move its hash. All three of these have the identical identity:
fn shout(s) { return s + "!"; }
fn announce(text) { return text + "!"; }
fn zzz(q) { return q + "!"; }
$ rigid hash "m.rg#shout"
sha256:f2aeacb386b85feb14736c4e77ff928113079bc56b93e8799051f5795fc253f2
$ rigid hash "m.rg#announce"
sha256:f2aeacb386b85feb14736c4e77ff928113079bc56b93e8799051f5795fc253f2
$ rigid hash "m.rg#zzz"
sha256:f2aeacb386b85feb14736c4e77ff928113079bc56b93e8799051f5795fc253f2
That is deliberate, and everything else depends on it. Because a rename leaves identity untouched, the compiler can recognise a renamed definition under its new name and repair your call sites. If renaming moved the hash, a rename would be indistinguishable from deleting one function and adding another.
Change what the function computes and the hash moves:
$ # body changed from + "!" to + "!!!"
$ rigid hash "m.rg#announce"
sha256:b573b5a9846a3d3a0a667469728e5c8b3ff9c21ca36b010dae0036ff70962a19
But "names never matter" would be wrong. The rule is that names you bind are metadata, while names that are data are structure:
| Name | Part of identity? | |
|---|---|---|
| definition name | no | a metadata layer over the graph |
| parameter name | no | erased; slots are positional |
let name | no | erased; slots are positional |
| record field name | yes | the field name is data |
| record field order | no | canonical form sorts by name |
table key | yes | the key is data |
| whitespace, comments | no | they do not survive parsing |
One more consequence: because a reference is encoded as the target's hash, your hash changes when anything you transitively depend on changes. A hash pins an exact implementation and its whole dependency cone. That is what makes a cached test result a proof rather than a guess.
16. Dispatch tables
Most real programs dispatch on a runtime string: an event name, a route, a plugin key. In rigid you write the possible targets down, and the table becomes a definition in the graph:
fn on_created(p) { return "created:" + str(p); }
fn on_resolved(p) { return "resolved:" + str(p); }
table handlers {
"created" -> on_created,
"resolved" -> on_resolved,
}
fn route(event, payload) {
return handlers[event](payload);
}
$ rigid run "route.rg#route" created 5
"created:5"
The key is still ordinary runtime data. What is fixed is the set of things it can select. A key outside the set fails at the boundary and lists what was available:
$ rigid run "route.rg#route" deleted 5
runtime error: "no entry \"deleted\" in table 'handlers'
(keys: created, resolved)"
The payoff is that a handler reached only through a string key is still connected in the graph, so "who calls this?" has an exact answer in two hops:
$ rigid rdeps "route.rg#on_created"
rdeps of route.rg#on_created (1 result):
route.rg#handlers sha256:d010715b9f7c…
$ rigid rdeps "route.rg#handlers"
rdeps of route.rg#handlers (1 result):
route.rg#route sha256:759fa57bf790…
Effects and arity flow through the table too: every entry's argument count is checked at compile time,
and a dispatcher over a table containing an !io handler must itself be !io.
17. References and dyn
&name makes a first-class reference to a top-level definition. You cannot call one
directly — indirect calls are quarantined, and the compiler tells you so:
$ rigid check
error[E0204] a.rg:3:30: cannot call the value 'r' directly; indirect calls
are quarantined — write `dyn { possible, targets }(r, ...)`
info[I0405] a.rg:3:30: inferred quarantine for this call: dyn { f }(r, ...)
Note the second line: the compiler works out the set for you. The invocation form declares the possible targets, which are checked against the value at runtime:
fn double(n) { return n * 2; }
fn triple(n) { return n * 3; }
fn apply_one(which, n) {
let f = if (which == 2) &double else &triple;
return dyn { double, triple }(f, n);
}
$ rigid run "route.rg#apply_one" 2 21
42
Each name in the dyn set is a static edge in the graph, so indirect calls stay visible to
deps and rdeps. Use a table when dispatch is keyed by data, and
dyn when you are passing a function around.
18. memo and time travel
Two features fall out of hashing whole dependency cones.
memo
Mark a function memo and its results are cached on disk, keyed by the definition's hash
plus the arguments:
memo fn slow_square(n) {
return n * n;
}
The cache never goes stale, because any change to the function or to anything it depends on changes the
hash and therefore the key. Purity is required — if the dependency cone reaches !io you get
E0211. The cache is .rigid-memo.json and survives across runs; on this machine
a measured workload went from 101ms cold to 3ms on a later process.
Temporal references
Every green check writes each definition into a content store under its hash. You can then
call a previous version by hash prefix:
fn compare(s) !io {
print("then: " + @"f2aeacb386b8"(s));
return "now: " + shout(s);
}
The old version runs against the meanings it had when it was stored. Temporal calls require
!io, since a past version's effects are unknown, and they are VM-only for now. The CLI form
is rigid recall <prefix> [args].
19. What rigid doesn't have
An honest list, so you find these now rather than halfway into a program:
20. Cheat sheet
| Form | Meaning |
|---|---|
| fn f(a, b) { … } | Pure function |
| fn f(a) !io { … } | Function that may perform I/O |
| memo fn f(a) { … } | Persistently memoized (must be pure) |
| extern fn f(a) = "Math.max"; | Foreign function as a graph node |
| import { a, b } from "./m.rg"; | Import from another module |
| table t { "k" -> handler, } | Dispatch table with a closed target set |
| let x = e; | Declare a local |
| x = e; | Assign to a local |
| while (c) { … } | Loop while the condition is nonzero |
| for (x in xs) { … } | Loop over a list or a string |
| return e; | Return a value |
| if (c) a else b | Conditional expression, else required |
| if (c) { … } else { … } | Conditional statement, else optional |
| fn(x) { … } | Closure; lifts to <enclosing>$<n> |
| [1, 2, 3] | List literal |
| { a: 1, b: 2 } | Record literal |
| xs[i] · s[i] | Index a list or string |
| r.field | Read a record field |
| &f | Reference to a definition |
| dyn { f, g }(ref, args…) | Call a reference, targets declared |
| t[key](args…) | Dispatch through a table |
| @"hash"(args…) | Call a stored earlier version |
| len · push · str · int | Length, append, int→string, string→int |
| substr · chars · join | String slicing, explode, implode |
| fail(v) · try(e) | Raise a value, catch into {ok, value|error} |
| print · input · read_file · write_file | I/O builtins, all require !io |