ludic/selfhost/check/check_stmt.ludic
Orkuncakilkaya e4265f5fdb feat(lang): 27.1 - a state machine as data: @Machine(Record.field) on a registry of transitions
A registry marked `@Machine(Deer.mood)` is the transitions of a machine over that enum field of the
records a state's Table<Deer> holds. Its record has from and to (the enum's variants), on: string (an
action's name, "" for a transition the tick asks), guard: fn(Row<Deer>, reads...) -> bool and
enter: fn(Row<Deer>, reads...) -> void; the states are the enum's variants and the start is the
field's default. The rows are data (an .lres or defs), the names the studio already edits.

Written by the compiler (machines.ludic, machines_write.ludic): for each action an `on` names, a row
reducer in the registry's file (named ..__machine__DeerSteps, so it sits beside the program's own
row reducer on the same action, after it): the row's state, the first transition from it on that
action whose guard passes, the field set, enter run - guards and enters called by name. When a row
leaves a state on a guard alone, `state DeerStepsMachine` (the kept row view) and
deer_steps_tick(m: mut DeerStepsMachine, s: mut Herd, reads...), one transition a row a tick.
Nothing allocates.

The table is the whole machine: the field written anywhere else - an assignment, or a `machine`
block's become over it - is a type error (check_stmt.ludic, ck_machine_write). Guards and enters take
the row first, are the record's module's, keep a row reducer's rules (and may be handed the row);
a guard writes nothing through it. The graph is checked, each error at its row (in the .lres when
the rows are there): a state never reached from the start, a state with no way out, an `on` naming
no action or an action with no @Target, a self-transition with no guard, two ways out of a state on
one trigger behind an unguarded first. Also refused: @Machine off a registry, a field that is not a
plain enum with a default, a @Column field, no table (or two) of the record, a transitions record of
another shape, a machine outside its table's state's module.

ludic schema's code section gains `machines` (registry, record, field, enum, table, start, states,
actions, tick, module, at); ludic deps names a machine's reducer `reducer Deer in Herd.deer on Spook
(machine DeerSteps)`. vocab @Machine; docs annot-machine, kw-machine; LANGUAGE.md "A machine as
data"; examples actions/machine (+ deer_steps.lres) and ten rejects; test.ludic feat, reject and
schema cases (not run); changes/machines.md. Reseeded; bootstrap-cfree fixpoint holds (317642
lines); Maroon Lake's `ludic build --check` is clean against this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:37:37 +03:00

359 lines
10 KiB
Text

# check_stmt.ludic — L4: statements, and the pass itself. check_program runs between the parse and
# the emitter over everything the program wrote (not the runtime spliced in after it): every
# function, the entry, the tests, the globals' initializers and every @On listener with its
# event's fields in scope. ECS handler, query, spawn and machine bodies bind names the checker
# does not model, so they are left to the emitter.
function ck_block(b: Node) -> void {
if b == null { return }
if b.kind != N_BLOCK {
ck_stmt(b)
return
}
let m = ck_mark()
var i = 0
while i < len(b.kids) {
ck_stmt(b.kids[i])
i += 1
}
ck_pop(m)
}
function ck_target(t: Node) -> pointer {
if t.kind == E_ID { return ck_id(t) }
return ck_expr(t)
}
function ck_let(s: Node) -> void {
var t: pointer = "?"
ck_expect = s.ty
if s.a != null { t = ck_expr(s.a) }
if s.ty != null {
if s.a != null { ck_give(s.ty, t, s.a, s.s) }
ck_bind(s.s, s.ty)
if s.tps != null and (s.tps == "state") { # an entry point's state (state.ludic)
if not is_state_ty(s.ty) { ck_err("state", s, `an entry point takes states, and {s.s}: {s.ty} is not one`) }
ck_set_ro(s.uns == 0)
} else { ck_let_from(s.a, s.ty) }
return
}
ck_bind(s.s, t)
# a local bound to a read-only state, or to a reference out of one, is read-only too
if s.a != null and s.a.kind == E_ID {
let li = ck_local(s.a.s)
if li >= 0 and li < ck_top - 1 and ck_is_ro(li) { ck_ro_like(li) }
}
ck_let_from(s.a, t)
}
function ck_assign(s: Node) -> void {
# a local rebound is not a write through it
if not (s.a.kind == E_ID and ck_local(s.a.s) >= 0) { ck_write_check(s.a, "this assignment") }
let lt = ck_target(s.a)
ck_machine_write(s.a, "this assignment")
if (s.s == "=") { ck_expect = lt }
let rt = ck_expr(s.b)
var what: pointer = "this assignment"
if s.a.kind == E_ID { what = s.a.s }
if s.a.kind == E_MEMBER { what = `field {s.a.s}` }
if (s.s == "=") {
ck_give(lt, rt, s.b, what)
return
}
let op = s.s[0..1]
let res = ck_binop(s, op, lt, rt, s.a, s.b)
ck_give(lt, res, s.b, what)
}
# 27.1: a @Machine's field is its table's to change - a transition writes it, and nothing else does
function ck_machine_write(t: Node, what: pointer) -> void {
if t == null or t.kind != E_MEMBER or not (ck_mem_node == t) or ck_unknown(ck_mem_base) { return }
let m = mc_of_field(ck_mem_base, t.s)
if m < 0 or mc_is_gen(ck_fn_node) { return }
ck_err("machine", t, `{what}: {g_mc_rec[m]}.{t.s} is the machine {g_mc_reg[m]}'s (@Machine({g_mc_rec[m]}.{t.s})) - it changes only by a transition in its table; add a row to {g_mc_reg[m]}, or dispatch the action one names`)
}
function ck_match(s: Node) -> void {
ck_expr(s.a)
var i = 0
while i < len(s.kids) {
let arm = s.kids[i]
let m = ck_mark()
var p = 0
while p < len(arm.kids) {
let pat = arm.kids[p]
# `Door(n) =>` binds its payload's names
if pat.kind == E_CALL {
var q = 0
while q < len(pat.kids) {
if pat.kids[q].kind == E_ID { ck_bind(pat.kids[q].s, "?") }
q += 1
}
}
p += 1
}
ck_block(arm.a)
ck_pop(m)
i += 1
}
}
function ck_stmt(s: Node) -> void {
if s == null { return }
let k = s.kind
if k == N_BLOCK { ck_block(s); return }
if k == S_LET { ck_let(s); return }
if k == S_ASSIGN { ck_assign(s); return }
if k == S_IF {
ck_cond(s.a, ck_expr(s.a))
ck_block(s.b)
ck_block(s.c)
return
}
if k == S_WHILE {
ck_cond(s.a, ck_expr(s.a))
ck_block(s.b)
return
}
if k == S_FOR {
ck_expr(s.a)
ck_expr(s.b)
let m = ck_mark()
ck_bind(s.s, "int")
ck_block(s.c)
ck_pop(m)
return
}
if k == S_RETURN {
if s.a == null { return }
ck_expect = ck_ret
let t = ck_expr(s.a)
if (ck_ret != "void") { ck_give(ck_ret, t, s.a, "the result") }
return
}
if k == S_EXPR { ck_expr(s.a); return }
if k == S_MATCH { ck_match(s); return }
if k == S_EMIT { ck_emit(s); return }
if k == S_UNSAFE {
ck_unsafe_here(s)
ck_unsafe += 1
ck_block(s.a)
ck_unsafe -= 1
return
}
# a machine's store: its states' `become` writes it
if k == S_MACHINE {
if s.a != null and not (s.a.kind == E_INT) { ck_write_check(s.a, "a machine's become") }
if s.a != null and s.a.kind == E_MEMBER {
ck_expr(s.a)
ck_machine_write(s.a, "a machine block's become")
}
ck_walk(s)
return
}
# 0.S: a query's body, a spawn's fields, a machine's states - read for what they touch
if k == S_QUERY {
let m = ck_mark()
var t = 0
var i = 0
while i < len(s.kids) {
var ty: pointer = "?"
if s.c != null {
while t < len(s.c.kids) and s.c.kids[t].ival == 1 { t += 1 }
if t < len(s.c.kids) { ty = s.c.kids[t].s }
t += 1
}
ck_bind(s.kids[i].s, ty)
i += 1
}
ck_any(s.b)
ck_block(s.a)
ck_pop(m)
return
}
ck_walk(s)
}
# a name given to two parameters: the second would shadow the first, and nothing could reach it
function ck_param_twice(d: Node, i: int) -> void {
var j = 0
while j < i {
if d.kids[j].kind == N_PARAM and (d.kids[j].s == d.kids[i].s) {
ck_err("arity", d.kids[i], `{d.s} names two parameters {d.kids[i].s}`)
return
}
j += 1
}
}
function ck_fn_body(d: Node) -> void {
mg_enter(d, 0)
let m = ck_mark()
var i = 0
while i < len(d.kids) {
if d.kids[i].kind == N_PARAM {
ck_param_twice(d, i)
ck_bind_param(d.kids[i])
}
i += 1
}
ck_ret = d.ty
if ck_ret == null { ck_ret = "void" }
ck_fn_node = d
if d.uns == 1 {
ck_unsafe_here(d)
ck_unsafe += 1
}
ck_block(d.a)
if d.uns == 1 { ck_unsafe -= 1 }
ck_ret = "void"
ck_fn_node = null
ck_pop(m)
mg_leave()
}
function ck_listener(l: Node) -> void {
mg_enter(l, 4)
let ev = find_event(l.s)
ck_vis(ev, l.s, l)
let m = ck_mark()
if ev != null {
var i = 0
while i < len(ev.kids) {
ck_bind(ev.kids[i].s, ev.kids[i].ty)
i += 1
}
}
ck_ret = "void"
ck_block(l.a)
ck_pop(m)
mg_leave()
}
# a program's function named like one of the runtime's takes every call the runtime makes to its
# own - the runtime's ui_measure started answering to a package's - so the name is refused; one
# named like a compiler built-in never ran at all (every call to `run` was C's system()), so too
function ck_runtime_shadows() -> void {
var u = 0
while u < g_prog_user_end {
let d = prog[u]
if d.kind == N_FN and (is_intrinsic(d.s) or is_intrinsic2(d.s) or is_call_builtin(d.s)) { ck_err("shadow", d, `{d.s} is a built-in of the compiler, and a call to it would never reach this function; choose another name`) }
u += 1
}
var r = g_prog_user_end
while r < len(prog) {
let d = prog[r]
if d.kind == N_FN {
let u = ck_fn(d.s)
if u != null and u != d { ck_err("shadow", u, `{d.s} is also a function of the engine runtime ({d.file}); choose another name`) }
}
# a type the runtime declares - `property PadButton` beside its `enum PadButton` compiled,
# and the two layouts met in the IR
if ck_type_kind(d) != "" {
var k = 0
while k < g_prog_user_end and k < len(prog) {
let o = prog[k]
if ck_type_kind(o) != "" and (o.s == d.s) {
ck_err("shadow", o, `{d.s} is the runtime's {ck_type_kind(d)} ({d.file}); choose another name for this {ck_type_kind(o)}`)
}
k += 1
}
}
r += 1
}
}
function ck_type_kind(d: Node) -> pointer {
if d.kind == N_ENUM { return "enum" }
if d.kind == N_COMP and d.uns == 2 { return "state" }
if d.kind == N_COMP { return "property" }
if d.kind == N_STRUCT { return "record" }
if d.kind == N_EVENT { return "event" }
return ""
}
function ck_hooks(hs: []Node) -> void {
var i = 0
while i < len(hs) {
mg_enter(hs[i], 4)
ck_block(hs[i].a)
mg_leave()
i += 1
}
}
function ck_scene_block(b: Node) -> void {
if b == null { return }
mg_enter(b, 5)
ck_block(b)
mg_leave()
}
# a retained ui widget (N_UI, whose number is also S_IF's): its props and its children
function ck_widget(w: Node) -> void {
if w == null { return }
var i = 0
while w.b != null and i < len(w.b.kids) {
ck_expr(w.b.kids[i].a)
i += 1
}
i = 0
while i < len(w.kids) {
ck_widget(w.kids[i])
i += 1
}
}
# the pass also makes the generics real (check_gen.ludic), so it always runs
function check_program() -> void {
gen_collect()
ck_index()
ck_runtime_shadows()
ck_tynames() # 0.R4: a type written names a type
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_FN { ck_fn_body(d) }
if d.kind == N_MAIN or d.kind == N_TEST {
mg_enter(d, 1)
ck_block(d.a)
mg_leave()
}
if d.kind == N_UI and d.ival == 1 and g_migrate { # 0.S2: a ui block's props, for what they read
g_mg_cur = -3
ck_widget(d.a)
g_mg_cur = -1
}
if d.kind == N_SYS and g_migrate { # a handler's body: only the migration reads it here
mg_enter(d, 2)
ck_block(d.a)
mg_leave()
}
if (d.kind == N_VAR or d.kind == N_CONST) and d.a != null {
ck_vis_file = d.file
ck_vis_skip = d.kind == N_VAR and is_port_var(d)
ck_give(d.ty, ck_expr(d.a), d.a, d.s)
ck_vis_file = null
ck_vis_skip = false
}
i += 1
}
var j = 0
while j < len(g_onlisten) {
ck_listener(g_onlisten[j])
j += 1
}
if g_migrate { # 0.S2: the hooks' bodies and the scenes', for what they touch
ck_hooks(g_onspawn)
ck_hooks(g_ondespawn)
ck_hooks(g_onattach)
ck_hooks(g_ondetach)
ck_hooks(g_onenable)
ck_hooks(g_ondisable)
j = 0
while j < len(g_scenes) {
ck_scene_block(g_scenes[j].a)
ck_scene_block(g_scenes[j].b)
j += 1
}
}
# a test block is checked like `entry`: that is also what makes its generic calls real
j = 0
while j < len(g_tests) {
mg_enter(g_tests[j], 3)
ck_block(g_tests[j].a)
mg_leave()
j += 1
}
gen_finish()
if ck_errors > 0 and not ck_reporting() and not g_migrate {
if g_diag_json { diag_exit(1) }
let m = `{itoa(ck_errors)} type error(s)\n`
file_write(file_stderr(), m, len(m))
exit(1)
}
}