Completes the half of #24 that was explicitly deferred as blocked: entity-space
queries to sit alongside the grid-space Grid.*/pathfinding that shipped in
07e5a20. Query.* answers questions about the live entities that carry a
property, built directly on the EV2 reflection ABI (world_query_next/world_get):
- Query.count(prop) -> int how many live entities carry prop
- Query.first(prop) -> int the lowest-id bearer, or -1
- Query.nearest(prop, pos, xf, yf, x, y) the bearer closest to (x,y), or -1
- Query.within(prop, pos, x, y, r, xf, yf) -> []int every bearer within r
prop is a property id (World.prop_id); the spatial forms read a position from a
coordinate property `pos` at two int field ids (World.field_id), so `prop` can be
a discriminating tag distinct from the position component ("nearest Enemy"), or
the same id to query the coordinate component itself. Distances are exact squared
integers (no sqrt), ties break to the lower entity id, and `within` returns
entities in ascending id order — so every answer is deterministic and replay-safe.
The engine (runtime/native/query.ludic, ~55 lines of Ludic, C-free) is a linear
scan over the entity table — ample for the entity counts Ludic targets, the same
reasoning as the grid pathfinder's open set; a bucketed/quadtree index is a
future optimisation, not a correctness need. It is spliced on demand when the
parser sees Query.* (g_uses_query), which also force-emits the reflection ABI so
a Query program needs no @events of its own (previously the ABI required them).
examples/library/query.ludic asserts 18 cases over five entities at known
positions (count/first with a component filter, nearest with a separate tag vs
position property, within radii incl. r=0 and the empty-property case), wired
into x test (now 63 passed). Docs: a Query section + 4 per-symbol pages,
inventory/coverage green. Seed reseeded; the C-free bootstrap fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
576 lines
25 KiB
Text
576 lines
25 KiB
Text
# 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(); if is_op("{") { n.a = record() }; 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
|
|
if e.a.kind == E_ID and e.a.s == "Regex" { g_uses_regex = true } # splice the regex runtime on demand
|
|
if e.a.kind == E_ID and e.a.s == "Query" { g_uses_query = true } # splice the ECS spatial-query runtime on demand
|
|
}
|
|
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
|
|
var g_uses_regex: bool = false # a program mentioned Regex.* -> splice the regex runtime
|
|
var g_uses_query: bool = false # a program mentioned Query.* -> splice the query runtime + reflection ABI
|
|
|
|
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 {
|
|
let saved = cur_dir
|
|
# a game (has systems/components) links the Ludic runtime.
|
|
if has_ecs() {
|
|
cur_dir = ""
|
|
do_import("runtime/native/core.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# any program that uses Regex.* gets the regex engine spliced in (it is
|
|
# self-contained — only compiler intrinsics — so it works in a plain tool too).
|
|
if g_uses_regex {
|
|
cur_dir = ""
|
|
do_import("runtime/native/regex.ludic")
|
|
do_import("runtime/native/regex_vm.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# any program that uses Query.* gets the ECS spatial-query helpers spliced in;
|
|
# they read entity state through the reflection ABI (emit_decl force-emits it
|
|
# for a Query program even when it declares no events).
|
|
if g_uses_query {
|
|
cur_dir = ""
|
|
do_import("runtime/native/query.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
|
|
g_uses_regex = false
|
|
g_uses_query = false
|
|
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()
|
|
}
|
|
}
|