2. Core IR
The desugared language that actually gets hashed, with examples of what each sugar becomes.
The Core IR (packages/compiler/src/core.ts) is the language’s meaning. The AST has sugar;
Core does not. Anything two programs share after lowering has the same hash, so keeping Core small
is what keeps hashing sound.
The tags
Every Core node is { tag: ... }. The full set is short:
| tag | meaning |
|---|---|
lit |
int, string, bool literal |
local |
a lambda parameter or let binder, by name |
ref |
another top-level definition (name now; hash once resolved) |
prim |
a builtin operator such as int.add |
lam, app |
function and application |
let, if |
binding and conditional |
abilityOp, handle |
ability operation call; handler |
recordNew |
record literal (accessors are ordinary refs) |
variantNew, switch |
constructor call; pattern match |
One example, end to end
let add = (a: int, b: int): int => a + b
lowers to:
{ "name": "add",
"type": { "tag": "fn", "params": ["int", "int"], "result": "int" },
"body": { "tag": "lam",
"params": [{ "name": "a", "type": "int" }, { "name": "b", "type": "int" }],
"body": { "tag": "app",
"fn": { "tag": "prim", "op": "int.add" },
"args": [{ "tag": "local", "name": "a" }, { "tag": "local", "name": "b" }] } } }
+ became app(prim int.add, ...); the operator disappeared as syntax. And in twice, the call to
add became { "tag": "ref", "ref": "add" }: a reference to another definition, currently by name.
Sugar becomes plain terms
Lowering is where operators and shorthand are erased. From the Unison the emitter writes for one line:
let a = (x: int, y: int): int => if x != y && x < 3 { x % 2 } else { -y }a x y =
if ((builtins.Boolean.not) ((builtins.Universal.==) x y)) && ((builtins.Universal.<) x +3)
then ((builtins.Int.mod) x +2) else ((builtins.Int.negate) y)
x != y is not (x == y), -y is negate y, % is Int.mod. None of those exist in Core as their own node.
var, loops, return/break/continue, ??, d[k], the pipe ->, string interpolation and let (a, b) = e are the same: they desugar before hashing
(all built), so a guard with return hashes like the equivalent if/else and a->f(b) hashes like f(a, b).
Two Core rules that prevent bugs
- Unique binders. Unison’s block bindings see the whole block; Clay’s
letsees only earlier bindings. The emitter renames everyletto a unique name (c_1) to avoid silent capture. - Names resolve late. A
refholds a name until hashing time. Only when the kernel term is built do names become hashes; this is what lets a rename be a label change instead of a code change.
Next: Hashing.