Bindings now signal mutability the way Rust/Swift do, instead of `let` meaning
"local" and `var` meaning "module-level":
- `let x = e` -> immutable binding; a later `x = …` is a compile error
(`cannot assign to immutable 'x' … use var`).
- `var x = e` -> mutable binding, at local OR module scope (position decides
scope; the keyword decides mutability).
- `const` -> unchanged (compile-time).
Immutability is of the *binding*, not the object: `let n = new Node; n.kind = 1`
is fine (mutation through the reference); only rebinding `n` is rejected. The
check lives in emit_assign — a direct `name =` whose target is a `let` local
(loc_mut == 0) errors; field/index targets and `var`/param/loop bindings are
unaffected.
Delivered as three reseeds so the self-hosting compiler never had to compile
source its own rules would reject:
A) add `var` as a local statement + per-local mutability tracking (loc_mut),
no enforcement;
B) migrate every reassigned `let` -> `var` across the compiler, runtime and
examples (337 declarations), driven by a per-function, string/comment-aware
scan (binding targets only, never `x.f =` / `x[i] =`);
C) turn on the check. bootstrap-cfree (compiler vs its own source) and every
golden build (which splices the runtime) then proved zero reassigned `let`
was missed anywhere.
Also folded in: removed leftover debug instrumentation in block() (a `cur=` /
print_int(777…) trace on the separator-error path) and fixed parse.ludic's stale
header comment (no more `struct`). Reseeded (21711 lines); C-free fixpoint holds;
goldens identical; 17/17; vocab + doc-fences clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
197 lines
7.8 KiB
Text
197 lines
7.8 KiB
Text
# parse_game.ludic — the ECS front-end: component and system declarations,
|
|
# `for (vars) in query [terms] where cond`, spawn and despawn. Mirrors the
|
|
# game-construct parsing in compiler/front/parse.c.
|
|
|
|
fn parse_component() -> Node {
|
|
pi = pi + 1; let n = node(N_COMP); n.s = eat_id(); skipnl(); eat_op("{")
|
|
while true { skipnl(); if is_op("}") { break }
|
|
var is_computed = false
|
|
if is_op("@") { pi = pi + 1; let ann = eat_id(); if streq(ann, "Computed") { is_computed = true }; skipnl() }
|
|
let f = node(N_FIELD); f.s = eat_id(); eat_op(":"); f.ty = ptype()
|
|
if is_op("=") { pi = pi + 1; f.a = expr() }
|
|
if is_computed { register_computed(n.s, f.s, f.ty, f.a) } # derived: no storage
|
|
else { push(n.kids, f) }
|
|
if is_op(",") { pi = pi + 1 } }
|
|
eat_op("}"); return n
|
|
}
|
|
|
|
fn parse_system() -> Node {
|
|
pi = pi + 1; let n = node(N_SYS); n.s = eat_id(); n.ty = "Update"
|
|
# postfix clauses on `handler Name …`: @anno(...) (parsed and reserved, e.g.
|
|
# @deterministic / @Reads(...) / @Writes(...)) and `phase X`. The handler's
|
|
# query lives in a prefix `@Queries(...)` annotation (see parse_one_decl), not
|
|
# in a signature clause.
|
|
while true {
|
|
skipnl() # clauses may span several lines
|
|
if is_op("@") { pi = pi + 1; let a = eat_id(); if is_op("(") { var d = 0 # @anno, one per turn so a
|
|
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } }
|
|
continue } # newline-separated @anno re-skips at the loop top
|
|
if is_id("phase") { pi = pi + 1; n.ty = eat_id(); continue }
|
|
break
|
|
}
|
|
skipnl()
|
|
n.a = block()
|
|
return n
|
|
}
|
|
|
|
# `[Term, ...]` with optional `where <expr>`, returning a node whose kids are
|
|
# the terms (E_ID with ival=1 for {Tag} filters) and .a the where-expr or null.
|
|
fn parse_query_tail() -> Node {
|
|
eat_op("[")
|
|
let q = node(N_BLOCK)
|
|
while not is_op("]") {
|
|
if is_op("{") { pi = pi + 1; let t = node(E_ID); t.s = eat_id(); t.ival = 1; push(q.kids, t); eat_op("}") }
|
|
else { let t = node(E_ID); t.s = eat_id(); t.ival = 0; push(q.kids, t) }
|
|
if is_op(",") { pi = pi + 1 }
|
|
}
|
|
eat_op("]")
|
|
if is_id("where") { pi = pi + 1; q.a = expr() }
|
|
return q
|
|
}
|
|
|
|
# `for (a, b) in query [Pos, Vel] where ... { body }`
|
|
fn parse_query_for() -> Node {
|
|
let n = node(S_QUERY)
|
|
eat_op("(")
|
|
while not is_op(")") { let v = node(E_ID); v.s = eat_id(); push(n.kids, v); if is_op(",") { pi = pi + 1 } }
|
|
eat_op(")")
|
|
let inkw = eat_id() # 'in'
|
|
if not is_id("query") { perr("expected 'query' in for-loop") }
|
|
pi = pi + 1 # 'query'
|
|
n.c = parse_query_tail()
|
|
n.b = n.c.a # where
|
|
n.a = block()
|
|
return n
|
|
}
|
|
|
|
# ---- @Queries annotation -----------------------------------------------------
|
|
# `@Queries(these: [Prop{constraint}, ...], on: Model)` on a handler is an
|
|
# annotation spelling of the `for (Prop, ...) in query [Prop, ..., {Model}]
|
|
# where <constraints> { body }` loop. It desugars to the same S_QUERY node, so
|
|
# the whole query backend (iteration, filters, binding, break/continue) is reused.
|
|
|
|
fn mk_and(a: Node, b: Node) -> Node {
|
|
if ptr_is_null(a) { return b }
|
|
let n = node(E_BIN); n.s = "and"; n.a = a; n.b = b; return n
|
|
}
|
|
|
|
# Rewrite each bare identifier in `e` as `base.field` — used both by
|
|
# `Prop{constraint}` (base is the property binding) and by @Computed field
|
|
# expansion (base is the accessed value). Non-destructive: builds a fresh tree,
|
|
# so a stored computed expression can be expanded at many access sites.
|
|
fn qualify_fields(e: Node, base: Node) -> Node {
|
|
if ptr_is_null(e) { return e }
|
|
if e.kind == E_ID {
|
|
let m = node(E_MEMBER); m.a = base; m.s = e.s; return m
|
|
}
|
|
if e.kind == E_BIN {
|
|
let n2 = node(E_BIN); n2.s = e.s; n2.a = qualify_fields(e.a, base); n2.b = qualify_fields(e.b, base); return n2
|
|
}
|
|
if e.kind == E_UN {
|
|
let n2 = node(E_UN); n2.s = e.s; n2.a = qualify_fields(e.a, base); return n2
|
|
}
|
|
return e
|
|
}
|
|
|
|
# parse `(these: [...], on: Model)`, returning an S_QUERY with its vars/terms/where
|
|
# filled in (the body `.a` is attached by the caller once the handler is parsed).
|
|
fn parse_queries_anno() -> Node {
|
|
eat_op("(")
|
|
let qn = node(S_QUERY)
|
|
let terms = node(N_BLOCK)
|
|
var wh: Node = ptr_null()
|
|
while not is_op(")") {
|
|
skipnl()
|
|
if is_op(")") { break }
|
|
let key = eat_id(); eat_op(":")
|
|
if streq(key, "these") {
|
|
eat_op("["); skipnl()
|
|
while not is_op("]") {
|
|
let pname = eat_id()
|
|
let v = node(E_ID); v.s = pname; push(qn.kids, v) # binding var = property name
|
|
let t = node(E_ID); t.s = pname; t.ival = 0; push(terms.kids, t)
|
|
if is_op("{") { pi = pi + 1; let ce = expr(); eat_op("}")
|
|
let cb = node(E_ID); cb.s = pname; wh = mk_and(wh, qualify_fields(ce, cb)) }
|
|
if is_op(",") { pi = pi + 1 }
|
|
skipnl()
|
|
}
|
|
eat_op("]")
|
|
} else { if streq(key, "on") {
|
|
let mname = eat_id(); let t = node(E_ID); t.s = mname; t.ival = 1; push(terms.kids, t) # {Model} tag
|
|
} else { expr() } } # unknown key: skip its value
|
|
if is_op(",") { pi = pi + 1 }
|
|
skipnl()
|
|
}
|
|
eat_op(")")
|
|
qn.c = terms; qn.b = wh
|
|
return qn
|
|
}
|
|
|
|
fn parse_spawn() -> Node {
|
|
pi = pi + 1; let n = node(S_SPAWN); n.s = eat_id(); skipnl(); eat_op("{")
|
|
while true {
|
|
skipnl(); if is_op("}") { break }
|
|
let ci = node(E_FINIT); ci.s = eat_id()
|
|
ci.a = record() # Comp { field: val, ... } — no `=` before the record
|
|
push(n.kids, ci)
|
|
if is_op(",") { pi = pi + 1 }
|
|
}
|
|
eat_op("}"); return n
|
|
}
|
|
|
|
# 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.
|
|
fn 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)
|
|
if is_op(",") { pi = pi + 1 }; skipnl() }
|
|
eat_op("}"); return n
|
|
}
|
|
|
|
# archetype Name { CompA, CompB } — a named entity kind (bundle of components)
|
|
fn parse_archetype() -> Node {
|
|
pi = pi + 1; let n = node(N_ARCH); n.s = eat_id(); skipnl(); eat_op("{")
|
|
while true { skipnl(); if is_op("}") { break }
|
|
let c = node(E_ID); c.s = eat_id(); push(n.kids, c)
|
|
if is_op(",") { pi = pi + 1 }; skipnl() }
|
|
eat_op("}"); return n
|
|
}
|
|
|
|
# extern fn name(params) -> T = "symbol"
|
|
fn parse_extern() -> Node {
|
|
pi = pi + 1 # 'extern'
|
|
let fnkw = eat_id() # 'fn'
|
|
let n = node(N_EXTERN); n.s = eat_id(); eat_op("(")
|
|
while not is_op(")") { let p = node(N_PARAM); p.s = eat_id(); eat_op(":"); p.ty = ptype(); push(n.kids, p)
|
|
if is_op(",") { pi = pi + 1 } }
|
|
eat_op(")")
|
|
n.ty = "void"
|
|
if is_op("->") { pi = pi + 1; n.ty = ptype() }
|
|
eat_op("=")
|
|
let t = toks[pi] # "symbol"
|
|
n.a = node(E_STR); n.a.s = t.text; pi = pi + 1
|
|
return n
|
|
}
|
|
|
|
# ui Name { widget-tree } — parsed into a widget node tree (emitted later)
|
|
fn parse_widget() -> Node {
|
|
let w = node(N_UI); w.s = eat_id() # widget type name
|
|
w.b = node(N_BLOCK) # b.kids = props (E_FINIT)
|
|
while toks[pi].kind == TK_ID and toks[pi + 1].kind == TK_OP and streq(toks[pi + 1].text, ":") {
|
|
let pr = node(E_FINIT); pr.s = eat_id(); eat_op(":"); pr.a = expr(); push(w.b.kids, pr) # widget prop `key: value` (Rule A)
|
|
}
|
|
skipnl()
|
|
if is_op("{") { pi = pi + 1
|
|
while true { skipnl(); if is_op("}") { break }; push(w.kids, parse_widget()) }
|
|
eat_op("}") }
|
|
return w
|
|
}
|
|
fn parse_ui() -> Node {
|
|
pi = pi + 1; let n = node(N_UI); n.s = eat_id(); n.ival = 1 # ival=1 marks the top ui block
|
|
skipnl(); eat_op("{"); skipnl()
|
|
n.a = parse_widget()
|
|
skipnl(); eat_op("}")
|
|
return n
|
|
}
|