Types phase 5 (polish). A splice-on-demand numeric runtime (runtime/native/numeric.ludic, built on the inline Math.* trig) behind three namespaces: - Huge.* — idle big numbers (normalized mantissa x 10^exponent): from/add/ sub/mul/neg/cmp/sign/mantissa/exp/str (scientific 1.23e45). Display-scale, not lockstep-exact (BigInt/Decimal for exactness). - Angle.* — auto-wrapping radians: from_degrees/to_degrees/wrap/sin/cos/add/ diff (shortest signed rotation)/lerp (shortest arc). - Percent.* — clamped [0,1]: clamp/of/lerp/apply. Remaining phase-5 items are already covered (duration=Duration.*, rune=Unicode.*, i64=long) or need a type-checking pass (handle, typed name, other sized ints) — tracked for later. Wired: parser splice trigger (g_uses_numeric), emit_call dispatch, reseeded seed, a self-asserting example (examples/library/numeric.ludic + feat_case), per-symbol docs + inventory. All suites green incl. golden renders byte- identical and the bootstrap fixpoint. NOTE: fixed `const`s lower to raw-int-typed values (emit_call N_CONST), which breaks fixed comparisons — the runtime uses inline fixed literals instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
773 lines
36 KiB
Text
773 lines
36 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 }
|
|
# try EXPR else { ... } — evaluate a fallible (result-typed) expression; on
|
|
# `ok` the whole expression is its payload, on `err` the else block runs (with
|
|
# the message bound to `error`) and its trailing expression is the fallback.
|
|
if (t.text == "try") {
|
|
pi = pi + 1
|
|
let n = node(E_TRY); n.line = t.line
|
|
n.a = expr()
|
|
skipnl()
|
|
if not is_id("else") { perr("try needs an `else { ... }` fallback") }
|
|
pi = pi + 1; skipnl()
|
|
n.b = block()
|
|
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 == "BigInt" or e.a.s == "Decimal") { g_uses_bignum = true } # splice the bignum runtime on demand
|
|
if e.a.kind == E_ID and (e.a.s == "Dict" or e.a.s == "Set") { g_uses_dict = true } # splice the hash-table runtime on demand
|
|
if e.a.kind == E_ID and (e.a.s == "Huge" or e.a.s == "Angle" or e.a.s == "Percent") { g_uses_numeric = true } # splice the huge/angle/percent 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
|
|
if e.a.kind == E_ID and e.a.s == "Reflect" { g_uses_reflect = true } # force-emit the reflection ABI (Reflect.* reads the world schema)
|
|
if e.a.kind == E_ID and e.a.s == "Light" { g_uses_light = true } # splice the 2D light-accumulation pass on demand
|
|
if e.a.kind == E_ID and (e.a.s == "Value" or e.a.s == "Json") { g_uses_value = true } # splice the value tree + JSON on demand (#44)
|
|
if e.a.kind == E_ID and e.a.s == "Reflect" and (e.s == "serialize" or e.s == "apply") { g_uses_value = true; g_uses_reflect_io = true } # Reflect.serialize/apply -> value tree + world table
|
|
# Input.* action-map / record-replay methods (#7) -> splice input.ludic.
|
|
# Input.key stays bare (no runtime), so gate on the new methods only.
|
|
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "bind" or e.s == "rebind" or e.s == "poll" or e.s == "down" or e.s == "pressed" or e.s == "record" or e.s == "replay") { g_uses_input = true }
|
|
# #50 device layer — any of the multi-key / analog / mouse / gamepad / touch
|
|
# methods also splices input.ludic (Input.key stays bare, no runtime).
|
|
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "key_down" or e.s == "key_pressed" or e.s == "key_released" or e.s == "press" or e.s == "release" or e.s == "axis" or e.s == "vector" or e.s == "strength" or e.s == "mouse_x" or e.s == "mouse_y" or e.s == "mouse_dx" or e.s == "mouse_dy" or e.s == "mouse_down" or e.s == "wheel" or e.s == "set_mouse" or e.s == "pad_connected" or e.s == "pad_button" or e.s == "pad_axis" or e.s == "set_pad" or e.s == "touch_count" or e.s == "touch_x" or e.s == "touch_y" or e.s == "set_touch") { g_uses_input = true }
|
|
# Anim.play/clip/on_frame/fired + Motion.to (#48): the ergonomic writes over
|
|
# the SpriteAnim/Motion components live in systems.ludic and use the world
|
|
# table, so splice it and force the reflection ABI even if the game leaves
|
|
# the engine auto-advance to do the ticking.
|
|
if e.a.kind == E_ID and e.a.s == "Anim" and (e.s == "play" or e.s == "clip" or e.s == "on_frame" or e.s == "fired") { g_uses_anim_rt = true }
|
|
if e.a.kind == E_ID and e.a.s == "Motion" and e.s == "to" { g_uses_anim_rt = true }
|
|
# Audio.* (#22) — any Audio method splices the audio runtime.
|
|
if e.a.kind == E_ID and e.a.s == "Audio" { g_uses_audio = true }
|
|
# Http.* (#6) — any Http method splices the HTTP client runtime.
|
|
if e.a.kind == E_ID and e.a.s == "Http" { g_uses_http = true }
|
|
# Tween.to/chain/delay/value/stop/parallel (#48): the fluent stateful handles
|
|
# live in tween.ludic, advanced by an engine-owned system each Update tick.
|
|
if e.a.kind == E_ID and e.a.s == "Tween" and (e.s == "to" or e.s == "chain" or e.s == "delay" or e.s == "value" or e.s == "stop" or e.s == "parallel") { g_uses_tween_rt = true }
|
|
}
|
|
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; c.line = toks[pi].line; 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
|
|
}
|
|
|
|
# parse a statement and stamp it with its source line (the first token's line),
|
|
# unless the specific rule already set one. The line drives --coverage and the
|
|
# panic/expect file:line messages.
|
|
function stmt() -> Node {
|
|
let ln = toks[pi].line
|
|
let n = stmt_body()
|
|
if n.line == 0 { n.line = ln }
|
|
return n
|
|
}
|
|
|
|
function stmt_body() -> 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_bignum: bool = false # a program mentioned BigInt.*/Decimal.* -> splice the bignum runtime
|
|
var g_uses_dict: bool = false # a program mentioned Dict.*/Set.* -> splice the hash-table runtime
|
|
var g_uses_numeric: bool = false # a program mentioned Huge.*/Angle.*/Percent.* -> splice the numeric runtime
|
|
var g_uses_query: bool = false # a program mentioned Query.* -> splice the query runtime + reflection ABI
|
|
var g_uses_reflect: bool = false # a program mentioned Reflect.* -> force-emit the reflection ABI
|
|
var g_uses_light: bool = false # a program mentioned Light.* -> splice the 2D light pass
|
|
var g_uses_value: bool = false # Value.*/Json.*/Reflect.serialize -> splice the value tree + JSON (#44)
|
|
var g_uses_reflect_io: bool = false # Reflect.serialize/apply -> splice the reflection serializer
|
|
var g_uses_esys: bool = false # an engine-owned system component (SpriteAnim/Motion/Light2D) is declared -> splice systems.ludic + force the reflection ABI
|
|
var g_uses_input: bool = false # a program used Input.bind/down/poll/… (action maps + record/replay) -> splice input.ludic
|
|
var g_uses_anim_rt: bool = false # Anim.play/clip/on_frame/fired or Motion.to (#48) -> splice systems.ludic + force the reflection ABI
|
|
var g_uses_tween_rt: bool = false # Tween.to/chain/delay/… (#48) -> splice tween.ludic + run esys_tween each Update
|
|
var g_uses_audio: bool = false # Audio.* (#22) -> splice audio.ludic; a windowed build also links audio.ll + AVFoundation
|
|
var g_uses_http: bool = false # Http.* (#6) -> splice http.ludic; links http.ll + Foundation (macOS)
|
|
|
|
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 }
|
|
if is_id("test") { push(g_tests, parse_test()); return }
|
|
perr("expected declaration")
|
|
}
|
|
|
|
# test "name" { ... } — a named test block, collected into g_tests. The block
|
|
# runs under a synthetic runner main (see emit_test_runner); `expect` assertions
|
|
# inside it record failures. Kept out of `prog` so it never emits as a plain fn.
|
|
function parse_test() -> Node {
|
|
let ln = toks[pi].line
|
|
pi = pi + 1 # past `test`
|
|
if toks[pi].kind != TK_STR { perr("expected a \"name\" string after test") }
|
|
let n = node(N_TEST)
|
|
n.s = toks[pi].text
|
|
n.line = ln
|
|
pi = pi + 1 # past the name
|
|
n.a = block()
|
|
return n
|
|
}
|
|
|
|
# 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 BigInt.*/Decimal.* gets the bignum engine spliced in
|
|
# (self-contained — only compiler intrinsics — so a plain tool works too).
|
|
if g_uses_bignum {
|
|
cur_dir = ""
|
|
do_import("runtime/native/bignum.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# any program that uses Dict.*/Set.* gets the hash-table engine spliced in
|
|
# (self-contained — only compiler intrinsics — so a plain tool works too).
|
|
if g_uses_dict {
|
|
cur_dir = ""
|
|
do_import("runtime/native/dict.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# any program that uses Huge.*/Angle.*/Percent.* gets the numeric runtime
|
|
# spliced in (it builds on Math.*, which lowers inline, so a plain tool works).
|
|
if g_uses_numeric {
|
|
cur_dir = ""
|
|
do_import("runtime/native/numeric.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
|
|
}
|
|
# any program that uses Light.* gets the 2D light-accumulation pass spliced in;
|
|
# it reads and writes the framebuffer (rt_fb), so it links with core.ludic.
|
|
if g_uses_light {
|
|
cur_dir = ""
|
|
do_import("runtime/native/light.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# Value.*/Json.* (#44) get the generic value tree + JSON bridge spliced in; it
|
|
# is self-contained (only string/slice ops), so it works in a plain tool too.
|
|
if g_uses_value {
|
|
cur_dir = ""
|
|
do_import("runtime/native/value.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# Reflect.serialize/apply add the reflection serializer, which stands on both
|
|
# the value tree (above) and the world table (force-emitted for Reflect.*).
|
|
if g_uses_reflect_io {
|
|
cur_dir = ""
|
|
do_import("runtime/native/reflect_io.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# engine-owned systems (#43/#47): a game that declares a well-known engine
|
|
# component (SpriteAnim / Motion / Light2D / Occluder) gets systems.ludic
|
|
# spliced, and the compiler inserts a call to each esys_* at its frame phase
|
|
# (emit_engine_systems_for_phase). The systems read/write components through the
|
|
# reflection ABI, so g_uses_esys also force-emits the world table (emit_decl).
|
|
# Light2D/Occluder additionally consume the 2D light pass, so pull it in too.
|
|
# Input.* action maps + record/replay (#7): splice input.ludic. It reads the
|
|
# live key through rt_poll (core.ludic), so pull the runtime in even for a
|
|
# program with no ECS (do_import dedupes when a game already linked core).
|
|
if g_uses_input {
|
|
cur_dir = ""
|
|
do_import("runtime/native/core.ludic")
|
|
do_import("runtime/native/input.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# Tween.* fluent handles (#48): splice the stateful tween runtime; esys_tween is
|
|
# inserted into the Update phase (emit_game.ludic) to advance handles each tick.
|
|
# It reads the live frame clock via the standard game loop, so pull core in too.
|
|
if g_uses_tween_rt {
|
|
cur_dir = ""
|
|
do_import("runtime/native/core.ludic")
|
|
do_import("runtime/native/tween.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# Audio.* (#22): splice the Audio runtime. It is self-contained — its native
|
|
# calls (snd_*) are is_windowed()-guarded, so a headless build carries the API
|
|
# as no-ops and needs no audio backend — so it works in a plain program too.
|
|
if g_uses_audio {
|
|
cur_dir = ""
|
|
do_import("runtime/native/audio.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# Http.* (#6): splice the HTTP client. The transport (hs_* intrinsics) is native
|
|
# (linked from http.ll), but the response parser is pure Ludic; self-contained,
|
|
# so it works in a plain program too.
|
|
if g_uses_http {
|
|
cur_dir = ""
|
|
do_import("runtime/native/http.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# Anim.play/Motion.to sugar (#48): the writes live in systems.ludic and use the
|
|
# reflection ABI, so splice it and force the world table even when the game does
|
|
# not otherwise trip uses_engine_systems.
|
|
if g_uses_anim_rt {
|
|
g_uses_esys = true
|
|
cur_dir = ""
|
|
do_import("runtime/native/core.ludic")
|
|
do_import("runtime/native/systems.ludic")
|
|
cur_dir = saved
|
|
}
|
|
if uses_engine_systems() {
|
|
g_uses_esys = true
|
|
cur_dir = ""
|
|
# SpriteAnim / Motion -> the animation systems (self-contained, reflection only)
|
|
if (find_comp("SpriteAnim") != null) or (find_comp("Motion") != null) {
|
|
do_import("runtime/native/systems.ludic")
|
|
}
|
|
# Light2D / Occluder -> the lighting render system, which links against the
|
|
# 2D light pass (light.ludic). do_import dedupes, so this is a no-op when the
|
|
# game also uses Light.* directly (g_uses_light already pulled it in).
|
|
if (find_comp("Light2D") != null) or (find_comp("Occluder") != null) {
|
|
do_import("runtime/native/light.ludic")
|
|
do_import("runtime/native/systems_light.ludic")
|
|
g_uses_light = true
|
|
}
|
|
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_bignum = false
|
|
g_uses_dict = false
|
|
g_uses_numeric = false
|
|
g_uses_query = false
|
|
g_uses_reflect = false
|
|
g_uses_esys = false
|
|
g_uses_input = false
|
|
g_uses_light = false
|
|
g_uses_value = false
|
|
g_uses_reflect_io = false
|
|
g_tests = new []Node
|
|
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()
|
|
}
|
|
}
|