feat(types): tagged-union enums — variant payloads + binding match + exhaustiveness (#56)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 25s
ci / build-and-test (push) Successful in 1m26s
commit-lint / conventional-commits (push) Successful in 2s
docs / build-and-deploy (push) Successful in 23s

Extend `enum` from named int constants to a tagged union: a variant may
carry a payload (`enum Tile { Empty, Wall, Door(int), Portal(int, int) }`).
Such enums box to a heap record (an i32 tag at offset 0, then one 8-byte
slot per payload position); an all-bare enum keeps its zero-cost compile-
time-ordinal representation, byte-for-byte unchanged (every golden render
and the bootstrap fixpoint still hold).

- Parser: variant payload declarations, stored as N_PARAM kids on the
  variant node.
- Construction: by name — `Door(3)`, `Portal(x, y)`, bare `Empty` — resolved
  ahead of the function-call fallback and boxed with the payloads coerced to
  their declared types.
- match: destructures a tagged scrutinee, switching on the tag and binding
  each arm's payload names in a scoped local frame.
- Checking pass: a tagged `match` must be exhaustive (cover every variant or
  end in `_`), and constructor/pattern arities and binding forms are checked
  — all reported where the scrutinee's type is known.

Adds selfhost/tests/enums.ludic to the regression suite and documents the
feature in LANGUAGE.md and the enum/match pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-01 03:07:46 +03:00
parent 7c91d24595
commit 872f458cb2
11 changed files with 17990 additions and 16481 deletions

View file

@ -738,12 +738,33 @@ match reg(R_CUR) { Action.Attack => attack() Action.Guard => guard() _ => wait
if reg(R_MODE) == Mode.Battle { … }
```
A variant is a **compile-time `int`** accessed as `Enum.Variant` (`Action.Guard`
A bare variant is a **compile-time `int`** accessed as `Enum.Variant` (`Action.Guard`
is `1`), numbered from `0` by declaration order, so it works anywhere an int does
— `match` patterns, comparisons, `set_reg`. Enums are a naming layer over `int`:
there is no distinct enum runtime type yet, so an enum value lives in an ordinary
`int` or register (and is saved with it). See `examples/games/chronorift/combat.ludic`,
whose battle menus dispatch on `KnightAct`/`MageAct` instead of `0..3`.
— `match` patterns, comparisons, `set_reg`. A plain (all-bare) enum is a naming
layer over `int`: an enum value lives in an ordinary `int` or register (and is
saved with it). See `examples/games/chronorift/combat.ludic`, whose battle menus
dispatch on `KnightAct`/`MageAct` instead of `0..3`.
A variant may instead carry a **payload**, which makes the enum a *tagged union*:
```ludic
# doc-check: skip — composite: a declaration plus its uses
enum Tile { Empty, Wall, Door(int), Portal(int, int) }
let t: Tile = Door(3) # constructed by name; bare Empty for no payload
match t {
Empty => rest()
Wall => block()
Door(n) => open(n) # payload bound as `n` in this arm
Portal(x, y) => teleport(x, y) # both fields bound
}
```
A payloaded value is boxed (a tag plus its payload slots) and carries the enum's
type, so it flows through `let`, params and returns. A tagged `match` is checked
for **exhaustiveness** — every variant must be handled or a `_` arm given — and
constructor/pattern arities are checked, so adding a variant flags each match that
must learn it. Bare enums are untouched by this and keep their zero-cost form.
## Expressions

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
Tagged-union enums (#56) — an `enum` variant may now carry a payload (`enum Tile { Empty, Wall, Door(int), Portal(int, int) }`), turning the enum into a sum type. Variants are constructed by name (`Door(3)`, bare `Empty`) and boxed as a tag plus payload slots; `match` destructures them, binding each payload in the arm's scope (`Door(n) => …`, `Portal(x, y) => …`). A tagged `match` is checked for exhaustiveness — it must cover every variant or end in `_`, and constructor/pattern arities are checked too — so adding a variant surfaces every site that must handle it. Plain (all-bare) enums keep their zero-cost compile-time-ordinal `Name.Variant` representation, byte-for-byte unchanged.

View file

@ -11,6 +11,8 @@ order: 4
A <code>match</code> replaces an `if`/`else` ladder that tests one value against several constants. It evaluates the subject once, then takes the first arm whose pattern matches; an arm lists one or more literal patterns separated by commas and points at a body with `=>`, and a lone `_` arm is the catch-all default. Patterns are compile-time constants — integers, char literals like `'w'`, or `enum` variants such as `Action.Guard` — which makes `match` ideal for dispatching on a key press, a tile code, or a mode. Each arm's body is a single statement or a `{ … }` block; matching lowers to plain branches, so it is as cheap as the `if` chain it replaces.
When the subject is a **tagged-union enum** (an `enum` whose variants carry payloads), `match` also *destructures* it: an arm names a variant and binds its payload — `Door(n) => …`, `Portal(x, y) => …` — with `n`, `x`, `y` in scope for that arm's body. A tagged `match` must be **exhaustive**: it covers every variant or ends with a `_`, or the compiler rejects it, so a newly added variant flags every match that must handle it. See [enum](../structure/kw-enum) for the full picture.
```ludic
program Steering {
property Velocity { delta_x: int = 0, delta_y: int = 0 }

View file

@ -9,7 +9,32 @@ tip: A named set of integer constants — names for a magic-number space.
order: 50
---
An <code>enum</code> gives names to a set of related integer values so a magic-number space — a menu selection, a game mode, a machine state — reads as names instead of bare literals. Variants number themselves from `0` in declaration order, and you access one as `Name.Variant`, which is a compile-time `int` usable anywhere an int is: in `match` patterns, comparisons, and assignments. Ludic has no distinct enum runtime type yet — an enum value lives in an ordinary `int` or `var` and is saved with it — so `enum` is best understood as a readable naming layer over `int`.
An <code>enum</code> gives names to a set of related integer values so a magic-number space — a menu selection, a game mode, a machine state — reads as names instead of bare literals. Variants number themselves from `0` in declaration order, and you access one as `Name.Variant`, which is a compile-time `int` usable anywhere an int is: in `match` patterns, comparisons, and assignments. When every variant is a bare name, an enum value lives in an ordinary `int` or `var` and is saved with it, so a plain `enum` is best understood as a zero-cost naming layer over `int`.
## Tagged unions — variants with payloads
A variant may also carry a **payload**, written as a parenthesized list of types after its name. Doing so turns the whole enum into a *tagged union*: a value is now one of several shapes, each with its own data, and the type remembers which.
```ludic
enum Tile { Empty, Wall, Door(int), Portal(int, int) }
```
You **construct** a variant by name — `Door(3)`, `Portal(x, y)`, or bare `Empty` for a payload-less one — and store or pass it as a value of the enum's type (`let t: Tile = Door(3)`). Each value is a small boxed record: a tag naming the variant, plus its payload.
You take one apart with <code>match</code>, binding the payload names in each arm:
```ludic
function walk_cost(t: Tile) -> int {
match t {
Empty => { return 1 }
Wall => { return 0 }
Door(n) => { return 10 + n } # n is the door's int payload
Portal(x, y) => { return x + y } # both fields bound in this arm
}
}
```
A tagged `match` must be **exhaustive**: it either handles every variant or ends with a `_` catch-all. Leaving a variant out is a compile-time error (`match on Tile is not exhaustive: variant Door is unhandled`), so adding a new variant surfaces every site that must learn about it. Constructor and pattern arities are checked the same way. A plain (all-bare) enum keeps its compile-time-ordinal `Name.Variant` form and is unaffected.
```ludic
program BattleMenu {

View file

@ -539,6 +539,30 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
return emit_call(e)
}
# construct a tagged-enum variant box (issue #56): malloc the box, store the tag
# at offset 0, then each payload into its 8-byte slot (slot k at byte 8*(k+1)),
# coerced to the variant's declared payload type. `args` are the constructor
# argument nodes (empty for a bare nullary variant). Returns the box as an
# `en.s`-typed pointer so it flows through lets/params/returns like any handle.
function emit_variant_new(en: Node, ord: int, args: []Node) -> Val {
let variant = en.kids[ord]
let arity = len(variant.kids)
if (len(args) != arity) { perr(`enum variant {variant.s} takes {itoa(arity)} payload(s), got {itoa(len(args))}`) }
let box = emit_bind(`call ptr @malloc(i64 {itoa(enum_box_size(en))})`)
emit(" store i32 "); emit(itoa(ord)); emit(", ptr "); emit(box); emit("\n")
var k = 0
while k < arity {
let pty = variant.kids[k].ty
let v = emit_expr(args[k])
let cv = coerce_code(v, pty) # emit any widening BEFORE the store line
let p = nreg(); emit(" "); emit(p); emit(" = getelementptr inbounds i8, ptr "); emit(box)
emit(", i32 "); emit(itoa(8 * (k + 1))); emit("\n")
emit(" store "); emit(llty(pty)); emit(" "); emit(cv); emit(", ptr "); emit(p); emit("\n")
k = k + 1
}
return val(box, en.s)
}
function emit_call(e: Node) -> Val {
# `Subject.action(...)` — a namespaced builtin (Screen/Random/Input).
if e.a.kind == E_MEMBER {
@ -865,6 +889,11 @@ function emit_call(e: Node) -> Val {
emit(")\n")
return val(erreg, ext.ty)
}
# a tagged-enum variant constructor with a payload: `Door(3)`, `Portal(x, y)`.
# A user function of the same name would have been resolved above; variants are
# capitalized by convention, so this rarely competes.
let ctor = variant_enum(name)
if (ctor != null) { return emit_variant_new(ctor, g_var_ord, e.kids) }
var fn2 = find_fn(name)
var cname = name
if (fn2 == null) {
@ -935,6 +964,10 @@ function emit_expr(e: Node) -> Val {
}
# a UI_<name> that is not a const/var resolves to its widget index
if is_ui_ident(e.s) { return val(itoa(ui_index_of(e.s)), "int") }
# a bare payload-less tagged-enum variant: `Empty` boxes a tag with no
# payload. Locals/globals were checked first, so a same-named binding wins.
let nv = variant_enum(e.s)
if (nv != null) { return emit_variant_new(nv, g_var_ord, new []Node) }
perr(`unknown identifier {e.s}`)
}
if e.kind == E_MEMBER {

View file

@ -197,6 +197,67 @@ function find_fn(name: pointer) -> Node {
return null
}
# ---- tagged-union enums (issue #56) ----------------------------------------
# A tagged enum is one where at least one variant declares a payload. Such enums
# use a boxed value ABI (a heap block: an i32 tag at offset 0, then one 8-byte
# slot per payload position); a payload-less enum keeps its compile-time-ordinal
# int representation untouched, so every existing enum + golden render is byte-
# identical. `g_var_ord` carries the ordinal matched by variant_enum().
var g_var_ord: int = 0
function find_enum(name: pointer) -> Node {
var i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_ENUM and (d.s == name) { return d }; i = i + 1 }
return null
}
function enum_is_tagged(en: Node) -> bool {
var i = 0
while i < len(en.kids) { if len(en.kids[i].kids) > 0 { return true }; i = i + 1 }
return false
}
# the tagged enum whose type name is `ty`, or null (a plain enum / non-enum type
# yields null, so the scalar match/ordinal paths stay in charge of those).
function tagged_enum_of(ty: pointer) -> Node {
let en = find_enum(ty)
if (en == null) { return null }
if enum_is_tagged(en) { return en }
return null
}
# the ordinal of variant `vname` within enum `en`, or -1.
function variant_ordinal(en: Node, vname: pointer) -> int {
var j = 0
while j < len(en.kids) { if (en.kids[j].s == vname) { return j }; j = j + 1 }
return 0 - 1
}
# find the tagged enum that declares a variant named `vname` (used to resolve a
# bare constructor like `Door(3)` / `Empty`); sets g_var_ord to its ordinal.
# Only tagged enums participate, so a plain enum's `Enum.Variant` member form and
# any like-named function are left alone.
function variant_enum(vname: pointer) -> Node {
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_ENUM {
if enum_is_tagged(d) {
let ord = variant_ordinal(d, vname)
if ord >= 0 { g_var_ord = ord; return d }
}
}
i = i + 1
}
return null
}
# the widest payload arity across an enum's variants — sizes the box.
function enum_max_arity(en: Node) -> int {
var m = 0
var i = 0
while i < len(en.kids) { let a = len(en.kids[i].kids); if a > m { m = a }; i = i + 1 }
return m
}
# byte size of a variant box: an 8-byte tag slot + one 8-byte slot per payload
# position (8 bytes holds any i32/i64/ptr payload with natural alignment).
function enum_box_size(en: Node) -> int { return 8 * (1 + enum_max_arity(en)) }
# `extern function name(params) -> T = "sym"` binds a Ludic name to a link symbol. A
# call to `name` lowers to a direct `@<sym>` call (no @fn_ prefix — the string is
# the exact linked symbol), and emit_extern_decls emits a matching `declare`. This

View file

@ -127,8 +127,90 @@ function arm_is_default(arm: Node) -> bool {
return false
}
# bind a tagged variant's payloads as locals for one arm's body. Pattern `pat`
# is the E_CALL `Door(n)` / `Portal(x, y)`; each argument E_ID names a payload
# slot. Each binding gets its own stack slot (loaded from the box), so it reads
# and shadows uniformly with any other local. `sv` is the box pointer.
function emit_bind_payload(en: Node, pat: Node, sv: Val) -> void {
let ord = variant_ordinal(en, pat.a.s)
let variant = en.kids[ord]
if (len(pat.kids) != len(variant.kids)) {
perr(`match: {pat.a.s} binds {itoa(len(pat.kids))} name(s) but carries {itoa(len(variant.kids))}`)
}
var k = 0
while k < len(pat.kids) {
if not (pat.kids[k].kind == E_ID) { perr(`match: {pat.a.s} payload {itoa(k)} must be a binding name`) }
let pty = variant.kids[k].ty
let lt = llty(pty)
let p = nreg(); emit(" "); emit(p); emit(" = getelementptr inbounds i8, ptr "); emit(sv.code)
emit(", i32 "); emit(itoa(8 * (k + 1))); emit("\n")
let lv = emit_bind(`load {lt}, ptr {p}`)
let slot = emit_alloca(lt); store_at(lt, lv, slot)
loc_push(pat.kids[k].s, slot, pty)
k = k + 1
}
}
# `match` over a tagged-union enum (issue #56): switch on the box's tag, bind
# each arm's payload names, and require the arms to be exhaustive (cover every
# variant) unless a `_` default is present — the checking pass the proposal asks
# for, run where the scrutinee's type is known.
function emit_match_tagged(st: Node, sv: Val, en: Node) -> void {
let tag = emit_bind(`load i32, ptr {sv.code}`)
let endl = lbl("mend")
var deflt: Node = null
var covered = 0 # bitmask of matched ordinals
var i = 0
while i < len(st.kids) {
let arm = st.kids[i]
if arm_is_default(arm) { deflt = arm }
else {
var acc = "0"
var first = true
var bindPat: Node = null
var p = 0
while p < len(arm.kids) {
let pat = arm.kids[p]
var vname = pat.s
if pat.kind == E_CALL { vname = pat.a.s; bindPat = pat }
let ord = variant_ordinal(en, vname)
if (ord < 0) { perr(`match: {vname} is not a variant of enum {en.s}`) }
covered = covered | (1 << ord)
let c = emit_bind(`icmp eq i32 {tag}, {itoa(ord)}`)
if first { acc = c; first = false } else { acc = emit_bind(`or i1 {acc}, {c}`) }
p = p + 1
}
let bodyl = lbl("mbody"); let nextl = lbl("marm")
emit(" br i1 "); emit(acc); emit(", label %"); emit(bodyl); emit(", label %"); emit(nextl); emit("\n")
emit(bodyl); emit(":\n"); g_term = false
let save = nloc
if (bindPat != null) { emit_bind_payload(en, bindPat, sv) }
emit_block(arm.a)
nloc = save # drop the arm's payload bindings
if not g_term { emit(" br label %"); emit(endl); emit("\n") }
emit(nextl); emit(":\n"); g_term = false
}
i = i + 1
}
if (deflt != null) { emit_block(deflt.a) }
else {
let full = (1 << len(en.kids)) - 1
if not (covered == full) { # find and name the first uncovered variant
var m = 0
while m < len(en.kids) {
if (covered & (1 << m)) == 0 { perr(`match on {en.s} is not exhaustive: variant {en.kids[m].s} is unhandled (add it or a _ arm)`) }
m = m + 1
}
}
}
if not g_term { emit(" br label %"); emit(endl); emit("\n") }
emit(endl); emit(":\n"); g_term = false
}
function emit_match(st: Node) -> void {
let sv = emit_expr(st.a)
let te = tagged_enum_of(sv.ty)
if (te != null) { emit_match_tagged(st, sv, te); return }
let endl = lbl("mend")
var deflt: Node = null
var i = 0

View file

@ -210,10 +210,25 @@ function parse_scene() -> void {
# enum Name { A, B, C } — named int constants; a variant's value is its index.
# Accessed as `Name.A` (a compile-time int), so it names magic-int value spaces
# (state ids, menu selections, mode registers) without a runtime cost.
#
# A variant may also carry a payload — `enum Tile { Empty, Wall, Door(int),
# Portal(int, int) }` (issue #56). A payload turns the whole enum into a tagged
# union: variants are constructed by name (`Door(3)`, bare `Empty`) and boxed
# (a tag + payload slots), and `match` destructures them (`Door(n) => …`). An
# all-payload-less enum keeps the zero-cost compile-time-ordinal representation.
# Each variant node is an E_ID (s=variant name); its payload types are stored as
# N_PARAM kids, one per slot, carrying only `.ty`.
function parse_enum() -> Node {
pi = pi + 1; let n = node(N_ENUM); n.s = eat_id(); skipnl(); eat_op("{")
while true { skipnl(); if is_op("}") { break }
let v = node(E_ID); v.s = eat_id(); push(n.kids, v)
let v = node(E_ID); v.s = eat_id()
if is_op("(") { # variant payload: Door(int), Portal(int, int)
pi = pi + 1; skipnl()
while not is_op(")") {
let p = node(N_PARAM); p.ty = ptype(); push(v.kids, p)
if is_op(",") { pi = pi + 1 }; skipnl() }
eat_op(")") }
push(n.kids, v)
if is_op(",") { pi = pi + 1 }; skipnl() }
eat_op("}"); return n
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
program T {
# a tagged-union enum (issue #56): variants carry payloads, are constructed by
# name, and destructure in `match` with the payload names bound in each arm.
enum Tile { Empty, Wall, Door(int), Portal(int, int) }
# match binds each variant's payload; the arms are exhaustive (no `_`).
function walk_cost(t: Tile) -> int {
match t {
Empty => { return 1 }
Wall => { return 0 }
Door(n) => { return 10 + n }
Portal(x, y) => { return x + y }
}
return -1
}
# a nested match and an enum flowing through a return
function open(t: Tile) -> Tile {
match t {
Door(n) => { return Portal(n, n) }
_ => { return t }
}
return t
}
entry {
print(walk_cost(Empty)) # 1
print(walk_cost(Wall)) # 0
print(walk_cost(Door(5))) # 15
print(walk_cost(Portal(3, 4))) # 7
let d = Door(9)
print(walk_cost(d)) # 19
print(walk_cost(open(d))) # 18 (Door(9) -> Portal(9,9) -> 18)
print(walk_cost(open(Wall))) # 0 (default arm returns t unchanged)
# a match with a default arm, OR-ed nullary patterns, and a bound arm
let t = Portal(2, 40)
match t {
Empty, Wall => { print(100) }
Portal(x, y) => { print(x * y) } # 80
_ => { print(999) }
}
# payload-less variant boxed and matched
var here: Tile = Empty
here = Door(7)
print(walk_cost(here)) # 17
}
}

View file

@ -86,6 +86,7 @@ function cmd_selfhost_test() -> int {
sh_case("sort", "0 39 20 1 2 3 3 1 30 10 20 40 2 8")
sh_case("control", "55 4 15 1")
sh_case("match_bits", "1 2 9 16 4 15")
sh_case("enums", "1 0 15 7 19 18 0 80 17")
sh_case("fixed", "2 3 0 6 1")
sh_case("const", "1 0 10 5 20 0 10 7 1 0")
sh_case("math", "3 7 5 10 -1 0 1 2 3 3 2 5 25 50")