refactor(selfhost): reorganise into concern-based subdirectories
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 12s
ci / build-and-test (push) Successful in 50s
commit-lint / conventional-commits (push) Successful in 3s
docs / build-and-deploy (push) Successful in 2s

Split the flat 38-file selfhost/ into concern-based subdirectories:

  frontend/        lex, parse, parse_game, ast
  support/         str, buf, io
  backend/         core IR + expression/statement lowering
  backend/game/    ECS/scene/event/world lowering
  backend/stdlib/  the namespaced Math.*/Text.*/Crypto.*/… intrinsics

and split the three oversized emitters at responsibility boundaries so
no file mixes concerns:

  emit_game.ludic  -> + emit_world.ludic         (reflection world table,
                                                  tick helpers, @main synthesis)
  emit_expr.ludic  -> + emit_call.ludic          (namespaced builtins, call
                                                  lowering, expr dispatch)
  emit_text.ludic  -> + emit_text_prelude.ludic  (emitted string-builder runtime)

FRAGS in tools/x/selfhost.ludic is updated to the new paths with the link
order preserved, and the Python doc/vocabulary tooling is updated to walk
the new layout. Because the build is a plain in-order concatenation and
every split lands on a blank-line boundary, the regenerated seed is
byte-identical: `x reseed` leaves selfhost/ludicc.seed.ll unchanged,
`x bootstrap-cfree` still reaches its fixed point, and both `x test` (56)
and `x selfhost-test` (29, incl. golden renders) stay green.

Closes #29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 00:26:02 +03:00
parent f55216af50
commit 23726afa90
51 changed files with 780 additions and 771 deletions

View file

@ -0,0 +1,84 @@
# ast.ludic — the node kinds and the single Node shape they share, plus the
# declaration tables the emitter reads. Mirrors compiler/front/ast.h + sem/.
# declarations
const N_STRUCT: int = 0
const N_FIELD: int = 1
const N_VAR: int = 2
const N_CONST: int = 3
const N_FN: int = 4
const N_PARAM: int = 5
const N_MAIN: int = 6
const N_BLOCK: int = 7
const N_COMP: int = 8
const N_SYS: int = 9
const N_ARCH: int = 10
const N_EXTERN: int = 11
const N_UI: int = 12
const N_ENUM: int = 27 # enum Name { A, B, ... } — named int constants
const S_TOGGLE: int = 28 # enable/disable (ival: 1=enable 0=disable; s=target; a=entity or null)
const N_SCENE: int = 45 # scene Name [start] { on enter{} on exit{} layer L { handlers } }
# s=name ival=scene id a=on-enter block b=on-exit block
const S_ATTACH: int = 46 # attach P on e [{ overrides }] — add a property to a live entity
# s=property a=entity expr b=override record (E_REC) or null
const S_DETACH: int = 47 # detach P on e — remove a property from a live entity
# s=property a=entity expr
const N_EVENT: int = 48 # event Name { field: T = default, ... } — a public event payload
# s=name kids=payload fields (N_FIELD)
const S_EMIT: int = 49 # emit E(field: v, ...) — fire event E (calls its @On listeners)
# s=event name a=E_REC of named args; also usable as an
# expression (a cancellable event returns its cancelled flag)
const S_CANCEL: int = 50 # cancel — inside a listener, veto a `cancellable` event
# statements
const S_LET: int = 10
const S_ASSIGN: int = 11
const S_IF: int = 12
const S_WHILE: int = 13
const S_FOR: int = 14
const S_RETURN: int = 15
const S_EXPR: int = 16
const S_BREAK: int = 17
const S_CONTINUE: int = 18
const S_MATCH: int = 19
const S_MARM: int = 20
const S_QUERY: int = 21
const S_SPAWN: int = 22
const S_DESPAWN: int = 23
const S_MACHINE: int = 24
const S_STATE: int = 25
const S_BECOME: int = 26
# expressions
const E_INT: int = 30
const E_STR: int = 31
const E_BOOL: int = 32
const E_ID: int = 33
const E_CALL: int = 34
const E_MEMBER: int = 35
const E_INDEX: int = 36
const E_BIN: int = 37
const E_UN: int = 38
const E_NEW: int = 39
const E_FLOAT: int = 40
const E_REC: int = 41
const E_FINIT: int = 42
const E_NULL: int = 43 # the `null` pointer literal
const E_SLICE: int = 44 # s[a..b] — substring (a=base, b=start, c=end)
property Node {
kind: int = 0
s: pointer = null # name / operator / string / type-of-new
ival: int = 0 # int literal, bool, flags
ty: pointer = null # declared type (let/param/field/fn/var/const)
a: Node # fixed children (meaning per kind)
b: Node
c: Node
kids: []Node # variadic children
line: int = 0
}
function node(kind: int) -> Node {
let n = new Node
n.kind = kind
n.kids = new []Node
return n
}

158
selfhost/frontend/lex.ludic Normal file
View file

@ -0,0 +1,158 @@
# lex.ludic — source text -> a token slice. Mirrors compiler/front/lex.c.
# Tokens carry their kind, their text (identifiers, strings, operators),
# an integer value (numbers, char literals) and a line for diagnostics.
const TK_ID: int = 0
const TK_INT: int = 1
const TK_STR: int = 2
const TK_OP: int = 3
const TK_NL: int = 4
const TK_EOF: int = 5
const TK_FLOAT: int = 6
const TK_INTERP: int = 7 # `text {expr} text` — raw content, split by the parser
property Tok { kind: int = 0, text: pointer = null, ival: int = 0, line: int = 0 }
var toks: []Tok
function tok_push(kind: int, text: pointer, ival: int, line: int) -> void {
let t = new Tok
t.kind = kind; t.text = text; t.ival = ival; t.line = line
push(toks, t)
}
# does src match the 2-char operator op at position i?
function two_at(src: pointer, i: int, a: int, b: int) -> bool {
return src[i] == a and src[i + 1] == b
}
function is_op1(c: int) -> bool {
# + - * / % < > = ( ) { } [ ] , : . ! @
if c == 43 or c == 45 or c == 42 or c == 47 or c == 37 { return true }
if c == 60 or c == 62 or c == 61 { return true }
if c == 40 or c == 41 or c == 123 or c == 125 { return true }
if c == 91 or c == 93 or c == 44 or c == 58 or c == 46 { return true }
if c == 33 or c == 64 { return true }
if c == 38 or c == 124 or c == 94 or c == 126 { return true } # & | ^ ~
return false
}
function lex(src: pointer) -> void {
toks = new []Tok
var i = 0
var line = 1
let n = len(src)
while i < n {
let c = src[i]
if c == 10 { tok_push(TK_NL, null, 0, line); line = line + 1; i = i + 1; continue }
if c == 32 or c == 9 or c == 13 { i = i + 1; continue }
if c == 35 { # '#' comment to end of line
while i < n and src[i] != 10 { i = i + 1 }
continue
}
if c == 34 { # "string"
i = i + 1
let start = i
let out = bytes(n)
var j = 0
while i < n and src[i] != 34 {
if src[i] == 92 { # backslash escape
let e = src[i + 1]
var r = e
if e == 110 { r = 10 }
if e == 116 { r = 9 }
if e == 48 { r = 0 }
out[j] = r; j = j + 1; i = i + 2
} else { out[j] = src[i]; j = j + 1; i = i + 1 }
}
i = i + 1
out[j] = 0
tok_push(TK_STR, out, 0, line)
continue
}
if c == 96 { # `interpolated string` — captured raw
i = i + 1
let out = bytes(n)
var j = 0
while i < n and src[i] != 96 { out[j] = src[i]; j = j + 1; i = i + 1 }
i = i + 1
out[j] = 0
tok_push(TK_INTERP, out, 0, line)
continue
}
if c == 39 { # 'c' char literal -> int
i = i + 1
var v = 0
if src[i] == 92 {
let e = src[i + 1]
if e == 110 { v = 10 }
if e == 116 { v = 9 }
if e == 48 { v = 0 }
i = i + 2
} else { v = src[i]; i = i + 1 }
if src[i] == 39 { i = i + 1 }
tok_push(TK_INT, null, v, line)
continue
}
if char_is_digit(c) {
if c == 48 and src[i + 1] == 120 { # 0x hex
var v = 0
i = i + 2
while i < n {
let h = src[i]
var d = 0
if char_is_digit(h) { d = h - 48 }
else { if h >= 97 and h <= 102 { d = h - 87 }
else { if h >= 65 and h <= 70 { d = h - 55 } else { break } } }
v = v * 16 + d
i = i + 1
}
tok_push(TK_INT, null, v, line)
continue
}
var v = 0
while i < n and char_is_digit(src[i]) { v = v * 10 + (src[i] - 48); i = i + 1 }
# a fractional part makes it a Q16.16 fixed literal
if i < n and src[i] == 46 and char_is_digit(src[i + 1]) {
i = i + 1
# Accumulate only the first 4 fractional digits: `fnum << 16` must stay
# in i32 (5+ digits overflow), and Q16.16 resolves ~4-5 decimals anyway.
# Extra digits are still consumed so they don't become a stray token.
var fnum = 0; var fden = 1
while i < n and char_is_digit(src[i]) {
if fden < 10000 { fnum = fnum * 10 + (src[i] - 48); fden = fden * 10 }
i = i + 1
}
let bits = (v << 16) + ((fnum << 16) + (fden >> 1)) / fden
tok_push(TK_FLOAT, null, bits, line)
continue
}
tok_push(TK_INT, null, v, line)
continue
}
if char_is_alpha(c) {
let start = i
while i < n and char_is_alnum(src[i]) { i = i + 1 }
tok_push(TK_ID, src[start..i], 0, line)
continue
}
if c == 59 { tok_push(TK_NL, null, 0, line); i = i + 1; continue } # ';'
# two-character operators
if two_at(src, i, 45, 62) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # ->
if two_at(src, i, 61, 62) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # =>
if two_at(src, i, 61, 61) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # ==
if two_at(src, i, 33, 61) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # !=
if two_at(src, i, 60, 61) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # <=
if two_at(src, i, 62, 61) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # >=
if two_at(src, i, 43, 61) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # +=
if two_at(src, i, 45, 61) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # -=
if two_at(src, i, 42, 61) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # *=
if two_at(src, i, 47, 61) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # /=
if two_at(src, i, 46, 46) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # ..
if two_at(src, i, 60, 60) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # <<
if two_at(src, i, 62, 62) { tok_push(TK_OP, src[i..i + 2], 0, line); i = i + 2; continue } # >>
if is_op1(c) { tok_push(TK_OP, src[i..i + 1], 0, line); i = i + 1; continue }
i = i + 1 # skip anything unrecognised
}
tok_push(TK_EOF, null, 0, line)
}

View file

@ -0,0 +1,551 @@
# parse.ludic — recursive-descent parser: the token slice -> an AST.
# Core grammar: properties, vars/lets, consts, fns, entry; the ECS/ui/match
# constructs live in parse_game.ludic. Uses the global `toks` and a cursor `pi`.
var pi: int = 0
var prog: []Node # the top-level declarations
var g_game_name: pointer # the `game`/`module` name
function cur() -> Tok { return toks[pi] }
function pk(o: int) -> Tok { return toks[pi + o] }
function is_op(v: pointer) -> bool { let t = toks[pi]; return t.kind == TK_OP and (t.text == v) }
function is_id(v: pointer) -> bool { let t = toks[pi]; return t.kind == TK_ID and (t.text == v) }
function is_kw(v: pointer) -> bool { return is_id(v) }
function perr(msg: pointer) -> void {
let e = file_stderr()
file_write(e, "ludicc(self): parse error: ", 27)
file_write(e, msg, len(msg))
file_write(e, "\n", 1)
exit(1)
}
function eat_op(v: pointer) -> void { if not is_op(v) { perr(v) }; pi = pi + 1 }
function eat_id() -> pointer {
let t = toks[pi]
if t.kind != TK_ID { perr("expected identifier") }
pi = pi + 1
return t.text
}
function skipnl() -> void { while toks[pi].kind == TK_NL { pi = pi + 1 } }
# a type: `[]T` slice, or a plain name (int/ptr/str/bool/struct)
function ptype() -> pointer {
if is_op("[") {
pi = pi + 1
eat_op("]")
let el = ptype()
let out = bytes(len(el) + 3)
out[0] = 91; out[1] = 93 # "[]"
var i = 0
while el[i] != 0 { out[2 + i] = el[i]; i = i + 1 }
out[2 + i] = 0
return out
}
return eat_id()
}
# ---- expressions -----------------------------------------------------------
function expr() -> Node { return p_or() }
# Call arguments. Each argument is either positional (`expr`) or named
# (`name: expr`) — a named argument is an identifier immediately followed by a
# colon, which is unambiguous inside a call. Named args are stored as E_FINIT
# (s=label, a=value) and reordered to the callee's parameter order at emit time.
function args_call(call: Node) -> void {
eat_op("("); skipnl()
while not is_op(")") {
let t = toks[pi]
let nx = toks[pi + 1]
if t.kind == TK_ID and nx.kind == TK_OP and (nx.text == ":") {
let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); skipnl(); fi.a = expr(); push(call.kids, fi)
} else {
push(call.kids, expr())
}
skipnl(); if is_op(",") { pi = pi + 1; skipnl() }
}
eat_op(")")
}
# ---- string interpolation --------------------------------------------------
# `text {expr} text` desugars to a `+` chain of string literals and `string(expr)`
# holes, so it reuses the string-concat operator and needs no new runtime.
function interp_lit(buf: pointer, len: int) -> Node { let n = node(E_STR); n.s = buf[0..0 + len]; return n }
function interp_add(acc: Node, part: Node) -> Node {
if acc == null { return part }
return mkbin("+", acc, part)
}
function interp_str(e: Node) -> Node { # wrap a hole in string(...)
let c = node(E_CALL); let id = node(E_ID); id.s = "string"; c.a = id; push(c.kids, e); return c
}
function parse_hole(inner: pointer) -> Node { # re-lex+parse an embedded expression
let saved_toks = toks; let saved_pi = pi
lex(inner); pi = 0; skipnl()
let e = expr()
toks = saved_toks; pi = saved_pi
return e
}
function parse_interp(raw: pointer) -> Node {
let n = len(raw)
var acc: Node = null
let lit = bytes(n + 1)
var lj = 0
var i = 0
while i < n {
let c = raw[i]
if c == 123 { # '{'
if raw[i + 1] == 123 { lit[lj] = 123; lj = lj + 1; i = i + 2; continue } # {{ -> {
if lj > 0 { acc = interp_add(acc, interp_lit(lit, lj)); lj = 0 }
i = i + 1
let hs = i
var depth = 1
while i < n and depth > 0 {
let d = raw[i]
if d == 123 { depth = depth + 1 }
else { if d == 125 { depth = depth - 1; if depth == 0 { break } } }
i = i + 1
}
acc = interp_add(acc, interp_str(parse_hole(raw[hs..i])))
i = i + 1 # skip the closing '}'
} else {
if c == 125 and raw[i + 1] == 125 { lit[lj] = 125; lj = lj + 1; i = i + 2; continue } # }} -> }
if c == 92 { # backslash escape in literal text
let e = raw[i + 1]; var r = e
if e == 110 { r = 10 }
if e == 116 { r = 9 }
lit[lj] = r; lj = lj + 1; i = i + 2; continue
}
lit[lj] = c; lj = lj + 1; i = i + 1
}
}
if lj > 0 or (acc == null) { acc = interp_add(acc, interp_lit(lit, lj)) }
return acc
}
# emit E(field: v, ...) — shared by the statement form and the expression form.
# As an expression it yields a cancellable event's cancelled flag (0/1); a
# non-cancellable event yields 0.
function parse_emit() -> Node {
pi = pi + 1; let n = node(S_EMIT); n.s = eat_id()
let r = node(E_REC)
eat_op("("); skipnl()
while not is_op(")") {
let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); fi.a = expr(); push(r.kids, fi)
skipnl(); if is_op(",") { pi = pi + 1; skipnl() }
}
eat_op(")")
n.a = r
return n
}
function p_primary() -> Node {
let t = toks[pi]
if t.kind == TK_INTERP { pi = pi + 1; return parse_interp(t.text) }
if t.kind == TK_ID and (t.text == "emit") and (toks[pi + 1].kind == TK_ID) { return parse_emit() }
if t.kind == TK_INT { let n = node(E_INT); n.ival = t.ival; pi = pi + 1; return n }
if t.kind == TK_FLOAT { let n = node(E_FLOAT); n.ival = t.ival; pi = pi + 1; return n }
if t.kind == TK_STR { let n = node(E_STR); n.s = t.text; pi = pi + 1; return n }
if t.kind == TK_ID {
if (t.text == "true") { let n = node(E_BOOL); n.ival = 1; pi = pi + 1; return n }
if (t.text == "false") { let n = node(E_BOOL); n.ival = 0; pi = pi + 1; return n }
if (t.text == "null") { pi = pi + 1; return node(E_NULL) }
if (t.text == "new") { pi = pi + 1; let n = node(E_NEW); n.s = ptype(); return n }
let n = node(E_ID); n.s = t.text; pi = pi + 1; return n
}
if is_op("(") { pi = pi + 1; skipnl(); let e = expr(); skipnl(); eat_op(")"); return e }
perr("expected expression")
return node(E_INT)
}
function p_postfix() -> Node {
var e = p_primary()
while true {
if is_op(".") { pi = pi + 1; let m = node(E_MEMBER); m.a = e; m.s = eat_id(); e = m }
else { if is_op("[") { pi = pi + 1; let lo = expr()
if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring
else { let ix = node(E_INDEX); ix.a = e; ix.b = lo; eat_op("]"); e = ix } }
else { if is_op("(") { let c = node(E_CALL); c.a = e; args_call(c); e = c } else { break } } }
}
return e
}
function p_unary() -> Node {
if is_op("-") { pi = pi + 1; let n = node(E_UN); n.s = "-"; n.a = p_unary(); return n }
if is_op("~") { pi = pi + 1; let n = node(E_UN); n.s = "~"; n.a = p_unary(); return n } # bitwise not
if is_id("not") { pi = pi + 1; let n = node(E_UN); n.s = "not"; n.a = p_unary(); return n }
return p_postfix()
}
function mkbin(op: pointer, l: Node, r: Node) -> Node { let b = node(E_BIN); b.s = op; b.a = l; b.b = r; return b }
# precedence (Go-style, so `flags & MASK == 0` needs no parens): shifts and `&`
# bind like `*`; `|` and `^` bind like `+`; both tighter than comparison.
function p_mul() -> Node {
var l = p_unary()
while is_op("*") or is_op("/") or is_op("%") or is_op("<<") or is_op(">>") or is_op("&") {
let op = toks[pi].text; pi = pi + 1; l = mkbin(op, l, p_unary()) }
return l
}
function p_add() -> Node {
var l = p_mul()
while is_op("+") or is_op("-") or is_op("|") or is_op("^") {
let op = toks[pi].text; pi = pi + 1; l = mkbin(op, l, p_mul()) }
return l
}
function p_cmp() -> Node {
var l = p_add()
while is_op("<") or is_op("<=") or is_op(">") or is_op(">=") or is_op("==") or is_op("!=") {
let op = toks[pi].text; pi = pi + 1; l = mkbin(op, l, p_add())
}
return l
}
function p_and() -> Node {
var l = p_cmp()
while is_id("and") { pi = pi + 1; l = mkbin("and", l, p_cmp()) }
return l
}
function p_or() -> Node {
var l = p_and()
while is_id("or") { pi = pi + 1; l = mkbin("or", l, p_and()) }
return l
}
# a record literal `{ field: value, ... }` — used by spawn component inits.
# Rule A: a named part uses `:` (`=` is assignment/binding only).
function record() -> Node {
eat_op("{")
let r = node(E_REC)
while true { skipnl(); if is_op("}") { break }
let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); fi.a = expr(); push(r.kids, fi)
if is_op(",") { pi = pi + 1 } }
eat_op("}"); return r
}
# ---- statements ------------------------------------------------------------
function block() -> Node {
skipnl(); eat_op("{")
let b = node(N_BLOCK)
while true { skipnl(); if is_op("}") { break }
push(b.kids, stmt())
# Rule B: statements are separated by a newline or ';' (both lex to TK_NL).
# After a statement the next token must be that separator or the block's end —
# two statements may not sit adjacent with only spaces between them.
var sep = toks[pi].kind == TK_NL
if is_op("}") { sep = true }
if not sep { perr("expected newline or ';' between statements") }
}
eat_op("}")
return b
}
function stmt() -> Node {
let t = toks[pi]
if t.kind == TK_ID {
if (t.text == "let") or (t.text == "var") {
var mut = 0; if (t.text == "var") { mut = 1 } # let = immutable, var = mutable
pi = pi + 1; let n = node(S_LET); n.ival = mut; n.line = toks[pi].line; n.s = eat_id()
if is_op(":") { pi = pi + 1; n.ty = ptype() }
if is_op("=") { pi = pi + 1; n.a = expr() }
return n
}
if (t.text == "return") {
pi = pi + 1; let n = node(S_RETURN)
if toks[pi].kind != TK_NL and not is_op("}") { n.a = expr() }
return n
}
if (t.text == "if") {
pi = pi + 1; let n = node(S_IF); n.a = expr(); n.b = block()
let save = pi; skipnl() # peek past newlines for a trailing `else`
if is_id("else") { pi = pi + 1; skipnl()
if is_id("if") { n.c = stmt() } else { n.c = block() } }
else { pi = save } # no else: keep the separator for block()'s Rule-B check
return n
}
if (t.text == "while") { pi = pi + 1; let n = node(S_WHILE); n.a = expr(); n.b = block(); return n }
if (t.text == "for") {
pi = pi + 1
if is_op("(") { return parse_query_for() }
let n = node(S_FOR); n.s = eat_id()
let inkw = eat_id() # 'in'
n.a = expr(); eat_op(".."); n.b = expr(); n.c = block()
return n
}
if (t.text == "spawn") { return parse_spawn() }
if (t.text == "machine") {
pi = pi + 1; let n = node(S_MACHINE); n.a = expr(); skipnl(); eat_op("{")
var sidx = 0 # states auto-number by declaration order
while true { skipnl(); if is_op("}") { break }
let stkw = eat_id() # 'state'
let s = node(S_STATE); s.s = eat_id()
if is_op("=") { pi = pi + 1; s.b = expr() } # explicit value (still allowed)
else { let iv = node(E_INT); iv.ival = sidx; s.b = iv } # else its ordinal
skipnl(); s.a = block()
push(n.kids, s); sidx = sidx + 1 }
eat_op("}"); return n
}
# `emit E(...)` fires an event, but a bare `emit(...)` is an ordinary call
# (the compiler dogfoods a function named `emit`), so require an event name.
if (t.text == "emit") and (toks[pi + 1].kind == TK_ID) { return parse_emit() }
if (t.text == "become") { pi = pi + 1; let n = node(S_BECOME); n.s = eat_id(); return n }
if (t.text == "despawn") { pi = pi + 1; let n = node(S_DESPAWN); n.a = expr(); return n }
if (t.text == "enable") or (t.text == "disable") {
var en = 0; if (t.text == "enable") { en = 1 }
pi = pi + 1; let n = node(S_TOGGLE); n.ival = en
if is_id("layer") { pi = pi + 1; n.ty = "layer"; n.s = eat_id(); note_toggled_layer(n.s); return n } # enable/disable layer L
n.s = eat_id() # `enable P on e` / `disable Model` / `disable Handler`
if is_id("on") { pi = pi + 1; n.a = expr() } # property on an entity
return n
}
if (t.text == "attach") { # attach P on e [{ field: val, ... }]
pi = pi + 1; let n = node(S_ATTACH); n.s = eat_id()
if not is_id("on") { perr("attach needs 'on <entity>'") }
pi = pi + 1; n.a = expr()
if is_op("{") { n.b = record() } # optional field overrides (same-line)
return n
}
if (t.text == "detach") { # detach P on e
pi = pi + 1; let n = node(S_DETACH); n.s = eat_id()
if not is_id("on") { perr("detach needs 'on <entity>'") }
pi = pi + 1; n.a = expr()
return n
}
if (t.text == "break") { pi = pi + 1; return node(S_BREAK) }
if (t.text == "continue") { pi = pi + 1; return node(S_CONTINUE) }
if (t.text == "cancel") { pi = pi + 1; return node(S_CANCEL) } # veto a cancellable event
if (t.text == "match") {
pi = pi + 1; let n = node(S_MATCH); n.a = expr(); skipnl(); eat_op("{")
while true {
skipnl(); if is_op("}") { break }
let arm = node(S_MARM)
while true { push(arm.kids, expr()); if is_op(",") { pi = pi + 1; skipnl(); continue }; break }
eat_op("=>"); skipnl()
if is_op("{") { arm.a = block() } else { let b = node(N_BLOCK); push(b.kids, stmt()); arm.a = b }
push(n.kids, arm)
}
eat_op("}"); return n
}
}
let e = expr()
if is_op("=") or is_op("+=") or is_op("-=") or is_op("*=") or is_op("/=") {
let n = node(S_ASSIGN); n.line = toks[pi].line; n.s = toks[pi].text; pi = pi + 1; n.a = e; n.b = expr(); return n
}
let n = node(S_EXPR); n.a = e; return n
}
# ---- declarations ----------------------------------------------------------
function parse_var() -> Node {
pi = pi + 1; let n = node(N_VAR); n.s = eat_id(); eat_op(":"); n.ty = ptype()
if is_op("=") { pi = pi + 1; n.a = expr() }
return n
}
function parse_const() -> Node {
pi = pi + 1; let n = node(N_CONST); n.s = eat_id(); eat_op(":"); n.ty = ptype(); eat_op("="); n.a = expr()
return n
}
function parse_fn() -> Node {
pi = pi + 1; let n = node(N_FN); 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() }
n.a = block()
return n
}
function parse_main() -> Node { pi = pi + 1; let n = node(N_MAIN); n.a = block(); return n }
# directory part of a path, including the trailing '/', or "" if none
function dir_of(path: pointer) -> pointer {
var last = 0 - 1
var i = 0
while path[i] != 0 { if path[i] == 47 { last = i }; i = i + 1 }
if last < 0 { return "" }
return path[0..0 + (last + 1)]
}
function path_join(dir: pointer, rel: pointer) -> pointer {
if rel[0] == 47 { return rel } # absolute
return (dir + rel)
}
var loaded_paths: []pointer
var cur_dir: pointer
function already_loaded(full: pointer) -> bool {
var i = 0
while i < len(loaded_paths) { if (loaded_paths[i] == full) { return true }; i = i + 1 }
return false
}
# parse one top-level declaration (or resolve an import) into `prog`.
# Modifiers are `@annotations` in front of the declaration: `@export`, `@edge`,
# `@pure`, `@deterministic`, … — one channel, not a zoo of prefix keywords.
function parse_one_decl() -> void {
var is_export = false
var qspec: Node = null
var onspawn_model: pointer = null
var ondespawn_model: pointer = null
var ondespawn_reason: pointer = null # @OnDespawn(M, reason: r) — LC1 teardown reason binding
var onattach_prop: pointer = null
var ondetach_prop: pointer = null
var onenable_prop: pointer = null
var ondisable_prop: pointer = null
var on_event: pointer = null # @On(Event) — a compile-time event listener
var is_public = false # @Public — promote a lifecycle hook to an event
var hook_phase: pointer = null # @OnStart / @OnQuit override the phase
var is_sync_prop = false # @Sync property P — every field replicates (NETWORKING N2)
var is_owned = false # @Owned model M — entities carry a network owner (N3)
var role: pointer = null # @Server / @Predicted — a handler's network role (N5)
var remote_dir: pointer = null # @ToServer / @ToClients — a remote event's direction (N4)
while is_op("@") {
pi = pi + 1; let a = eat_id() # collect a leading @annotation
if (a == "export") { is_export = true }
else { if (a == "Public") { is_public = true } # @Public hook promotion
else { if (a == "On") { eat_op("("); on_event = eat_id(); eat_op(")") } # @On(Event) listener
else { if (a == "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
else { if (a == "OnSpawn") { eat_op("("); onspawn_model = eat_id(); eat_op(")") }
else { if (a == "OnDespawn") { eat_op("("); ondespawn_model = eat_id()
if is_op(",") { pi = pi + 1; eat_id(); eat_op(":"); ondespawn_reason = eat_id() } # , reason: r
eat_op(")") }
else { if (a == "OnAttach") { eat_op("("); onattach_prop = eat_id(); eat_op(")") }
else { if (a == "OnDetach") { eat_op("("); ondetach_prop = eat_id(); eat_op(")") }
else { if (a == "OnEnable") { eat_op("("); onenable_prop = eat_id(); eat_op(")") }
else { if (a == "OnDisable") { eat_op("("); ondisable_prop = eat_id(); eat_op(")") }
else { if (a == "OnStart") { hook_phase = "Start" } # boot
else { if (a == "OnQuit") { hook_phase = "OnQuit" } # shutdown
else { if (a == "Sync") { is_sync_prop = true } # @Sync property (N2)
else { if (a == "Owned") { is_owned = true } # @Owned model (N3)
else { if (a == "Server") { role = "server" } # @Server handler (N5)
else { if (a == "Predicted") { role = "predicted" } # @Predicted handler (N5)
else { if (a == "ToServer") { remote_dir = "toserver" } # @ToServer event (N4)
else { if (a == "ToClients") { remote_dir = "toclients" } # @ToClients event (N4)
else { if is_op("(") { var d = 0 # any other @anno(args) — parsed and skipped
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } } } } } } } } } } } } } } } } } } }
skipnl()
}
if is_id("import") { pi = pi + 1
let t = toks[pi]
if t.kind != TK_STR { perr("expected \"path\" after import") }
let rel = t.text; pi = pi + 1
do_import(rel)
return
}
if is_id("enum") { push(prog, parse_enum()); return }
if is_id("event") {
let ev = parse_event()
if (remote_dir != null) { ev.ty = remote_dir } # N4: a directional remote event (RPC)
register_event(ev); return
}
if is_id("property") {
let c = parse_component()
if is_sync_prop { var fi = 0; while fi < len(c.kids) { c.kids[fi].ival = 1; fi = fi + 1 } } # N2: mark every field replicable
push(prog, c); return
}
if is_id("model") {
let m = parse_archetype()
if is_owned { m.ival = 1 } # N3: this model's entities carry a network owner
push(prog, m); return
}
if is_id("scene") { parse_scene(); return } # layers push handlers into prog; scene -> g_scenes
if is_id("handler") {
let h = parse_system()
if (role != null) { if (role == "server") { h.ival = 1 } else { h.ival = 2 } } # N5: @Server=1 / @Predicted=2
if (on_event != null) { register_onlisten(on_event, h.a); return } # @On(Event) listener
if (onspawn_model != null) {
register_onspawn(onspawn_model, h.a) # spawn hook
if is_public { ensure_event(`model_{onspawn_model}_spawn`, false) } # @Public -> model_<M>_spawn
return
}
if (ondespawn_model != null) {
register_ondespawn(ondespawn_model, h.a, ondespawn_reason) # despawn hook
if is_public { ensure_event(`model_{ondespawn_model}_despawn`, true) } # @Public -> model_<M>_despawn (with reason)
return
}
if (onattach_prop != null) { register_onattach(onattach_prop, h.a); if is_public { ensure_event(`prop_{onattach_prop}_attach`, false) }; return } # -> prop_<P>_attach
if (ondetach_prop != null) { register_ondetach(ondetach_prop, h.a); if is_public { ensure_event(`prop_{ondetach_prop}_detach`, false) }; return } # -> prop_<P>_detach
if (onenable_prop != null) { register_onenable(onenable_prop, h.a); if is_public { ensure_event(`prop_{onenable_prop}_enable`, false) }; return } # -> prop_<P>_enable
if (ondisable_prop != null) { register_ondisable(ondisable_prop, h.a); if is_public { ensure_event(`prop_{ondisable_prop}_disable`, false) }; return } # -> prop_<P>_disable
if (hook_phase != null) { # @OnStart/@OnQuit
h.ty = hook_phase
if is_public { # -> program_start / program_quit
if (hook_phase == "Start") { ensure_event_empty("program_start") }
else { ensure_event_empty("program_quit") }
}
}
if (qspec != null) { # @Queries wraps the body in its S_QUERY
qspec.a = h.a
let wrap = node(N_BLOCK); push(wrap.kids, qspec); h.a = wrap
}
push(prog, h); return
}
if is_id("ui") { push(prog, parse_ui()); return }
if is_id("var") { push(prog, parse_var()); return }
if is_id("const") { push(prog, parse_const()); return }
if is_id("function") { let f = parse_fn(); if is_export { f.ival = 1 }; push(prog, f); return }
if is_id("extern") { push(prog, parse_extern()); return }
if is_id("entry") { push(prog, parse_main()); return }
perr("expected declaration")
}
# lex and parse an imported fragment into `prog`, saving/restoring lexer state
function do_import(rel: pointer) -> void {
let full = path_join(cur_dir, rel)
if already_loaded(full) { return }
push(loaded_paths, full)
let src = read_file(full)
if (src == null) { perr(`cannot open import {full}`) }
let saved_toks = toks; let saved_pi = pi; let saved_dir = cur_dir
cur_dir = dir_of(full)
lex(src) # resets the global token stream
pi = 0
skipnl()
while toks[pi].kind != TK_EOF { parse_one_decl(); skipnl() }
toks = saved_toks; pi = saved_pi; cur_dir = saved_dir
}
# a game (has systems/components) links the Ludic runtime; auto-splice it the
# way the C compiler does. Tools (a `main` block, no ECS) get nothing.
function maybe_splice_runtime() -> void {
if not has_ecs() { return }
let saved = cur_dir
cur_dir = ""
do_import("runtime/native/core.ludic")
cur_dir = saved
}
function parse_program() -> void {
prog = new []Node
g_computed = new []Node
g_onspawn = new []Node
g_ondespawn = new []Node
g_onattach = new []Node
g_ondetach = new []Node
g_onenable = new []Node
g_ondisable = new []Node
g_scenes = new []Node
g_scene_count = 0
g_start_scene = 0
g_events = new []Node
g_onlisten = new []Node
g_toggled_layers = new []pointer
loaded_paths = new []pointer
skipnl()
g_game_name = "Ludic"
# imports may precede the program block
while is_id("import") { pi = pi + 1; let t = toks[pi]; let rel = t.text; pi = pi + 1; do_import(rel); skipnl() }
# @annotations on the program itself (e.g. @Handles(Movement)) — parsed, skipped
while is_op("@") {
pi = pi + 1; let a = eat_id()
if is_op("(") { var d = 0
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } }
skipnl()
}
if is_id("program") { pi = pi + 1; g_game_name = eat_id(); skipnl(); eat_op("{") }
while true {
skipnl()
if toks[pi].kind == TK_EOF { break }
if is_op("}") { break }
parse_one_decl()
}
}

View file

@ -0,0 +1,271 @@
# 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.
function 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
var is_sync = false # @Sync — this field replicates (NETWORKING N2)
if is_op("@") { pi = pi + 1; let ann = eat_id(); if (ann == "Computed") { is_computed = true } else { if (ann == "Sync") { is_sync = 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_sync { f.ival = 1 } # mark the field replicable (read by emit_net)
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
}
# event Name { field: T = default, ... } — a public event's POD payload. Same
# field grammar as a `property`, but stored in g_events, not prog: an event is a
# signal shape, not per-entity storage. Zero fields is allowed (`event Ping {}`).
function parse_event() -> Node {
pi = pi + 1; let n = node(N_EVENT)
if is_id("cancellable") { pi = pi + 1; n.ival = 1 } # a decision event: listeners may `cancel` it
n.s = eat_id(); skipnl(); eat_op("{")
while true { skipnl(); if is_op("}") { break }
let f = node(N_FIELD); f.s = eat_id(); eat_op(":"); f.ty = ptype()
if is_op("=") { pi = pi + 1; f.a = expr() }
push(n.kids, f)
if is_op(",") { pi = pi + 1 } }
eat_op("}"); return n
}
function 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.
function 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 }`
function 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.
function mk_and(a: Node, b: Node) -> Node {
if (a == null) { 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.
function qualify_fields(e: Node, base: Node) -> Node {
if (e == null) { 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).
function parse_queries_anno() -> Node {
eat_op("(")
let qn = node(S_QUERY)
let terms = node(N_BLOCK)
var wh: Node = null
while not is_op(")") {
skipnl()
if is_op(")") { break }
let key = eat_id(); eat_op(":")
if (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 (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
}
function 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
}
# scene Name [start] { on enter {..} on exit {..} layer L { handler .. } .. }
# A scene groups handlers behind an implicit active-scene register; only the
# active scene's handlers run each phase. `on enter`/`on exit` are lifecycle
# blocks (scene .a/.b); each layer's handlers are pushed straight into `prog` as
# ordinary N_SYS nodes, tagged with the owning scene in `.c`, so the whole
# system backend (functions, phases, enable/disable) is reused unchanged.
function parse_scene() -> void {
pi = pi + 1 # 'scene'
let n = node(N_SCENE); n.s = eat_id()
n.ival = g_scene_count
# optional modifiers after the name, any order: `start` (the boot scene) and
# `public` (promote its on-enter/on-exit to scene_<S>_enter / scene_<S>_exit).
var is_pub = false
while is_id("start") or is_id("public") {
if is_id("start") { pi = pi + 1; g_start_scene = g_scene_count }
else { pi = pi + 1; is_pub = true }
}
g_scene_count = g_scene_count + 1
if is_pub { ensure_event_empty(`scene_{n.s}_enter`); ensure_event_empty(`scene_{n.s}_exit`) }
skipnl(); eat_op("{")
while true {
skipnl(); if is_op("}") { break }
if is_id("on") { # on enter { .. } / on exit { .. }
pi = pi + 1; let which = eat_id(); skipnl()
if (which == "enter") { n.a = block() }
else { if (which == "exit") { n.b = block() } else { perr("expected 'enter' or 'exit' after 'on'") } }
continue
}
if is_id("layer") { # layer Name [public] { handler .. }
pi = pi + 1; let lname = eat_id()
if is_id("public") { pi = pi + 1; ensure_event_empty(`layer_{lname}_show`); ensure_event_empty(`layer_{lname}_hide`) }
skipnl(); eat_op("{")
let ltag = node(E_ID); ltag.s = lname # the layer name, tagged onto each handler
while true {
skipnl(); if is_op("}") { break }
if not is_id("handler") { perr("expected 'handler' in layer") }
let h = parse_system() # N_SYS: .s name, .ty phase, .a body
h.c = n # tag the owning scene (null = global)
h.b = ltag # tag the owning layer (for enable/disable layer)
push(prog, h)
skipnl()
}
eat_op("}")
continue
}
perr("expected 'on', 'layer' or '}' in scene")
}
eat_op("}")
push(g_scenes, 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.
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)
if is_op(",") { pi = pi + 1 }; skipnl() }
eat_op("}"); return n
}
# archetype Name { CompA, @Sync CompB } — a named entity kind (bundle of
# components). A member marked `@Sync` *participates* in replication (NETWORKING
# N2): its @Sync-marked fields cross the wire for this model. Participation is
# per model use-site — the same property syncs in one model, not another. The
# per-member @Sync sets the member E_ID's ival=1 (read by emit_net).
function 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)
if is_op("@") { pi = pi + 1; let a = eat_id(); if (a == "Sync") { c.ival = 1 }; skipnl() }
c.s = eat_id(); push(n.kids, c)
if is_op(",") { pi = pi + 1 }; skipnl() }
eat_op("}"); return n
}
# extern function name(params) -> T = "symbol"
function 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)
function 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 (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
}
function 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
}