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.

naming

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 checkParse, 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 testRun 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.

identity

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"
gotcha

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:

words.rg
fn shout(s) {
  return s + "!";
}
app.rg
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:

NamePart of identity?
definition namenoa metadata layer over the graph
parameter namenoerased; slots are positional
let namenoerased; slots are positional
record field nameyesthe field name is data
record field ordernocanonical form sorts by name
table keyyesthe key is data
whitespace, commentsnothey 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

FormMeaning
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 bConditional 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.fieldRead a record field
&fReference 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 · intLength, append, int→string, string→int
substr · chars · joinString slicing, explode, implode
fail(v) · try(e)Raise a value, catch into {ok, value|error}
print · input · read_file · write_fileI/O builtins, all require !io