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 let sees only earlier bindings. The emitter renames every let to a unique name (c_1) to avoid silent capture.
  • Names resolve late. A ref holds 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.