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