The engine runtime and the bundled ludic.* packages belong to the toolchain,
not to the project, so the compiler has to know where the toolchain lives. It
derived that from the directory the binary sits in, which is `bin` — so an
in-repo build only found runtime/native/cocoa.ll when the caller set
LUDIC_HOME=., and an installed compiler had no way to find it at all.
ludic_home() derives it properly instead: $LUDIC_HOME when set, else the
binary's directory with a trailing `bin/` stripped, else a $PATH scan for
argv[0] (an install is invoked by bare name, which carries no directory). Both
layouts that exist then have the same shape — ~/.ludic/{bin,runtime,packages}
and a repo checkout — so the same rule serves both and LUDIC_HOME becomes an
override rather than a requirement.
`import "ludic.core/…"` also falls back to $LUDIC_HOME/packages, after the
project's own ludic_modules/, so a project that has not fetched its own copy
gets the packages that shipped with the toolchain instead of a symlink farm.
The `ludic` multi-call name is dropped from the compiler: that name now belongs
to the CLI, and --run is the flag it drives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1253 lines
60 KiB
Text
1253 lines
60 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 `program` name
|
|
|
|
# ---- diagnostics -----------------------------------------------------------
|
|
# Every compiler error is `file:line: error: message` — the form editors and
|
|
# build tools parse. While parsing, the location is the current token's; while
|
|
# lowering, it is the statement being emitted (emit_stmt records it).
|
|
var g_parse_file: pointer = "" # the file whose tokens are being parsed
|
|
var g_parsing: bool = true # false once lowering starts
|
|
var g_err_file: pointer = "" # the statement being lowered
|
|
var g_err_line: int = 0
|
|
|
|
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 {
|
|
var file = g_err_file
|
|
var line = g_err_line
|
|
if g_parsing {
|
|
file = g_parse_file
|
|
line = 0
|
|
if pi < len(toks) { line = toks[pi].line }
|
|
}
|
|
if file == null { file = "" }
|
|
let m = `{file}:{line}: error: {msg}\n`
|
|
file_write(file_stderr(), m, len(m))
|
|
exit(1)
|
|
}
|
|
|
|
# the current token, described for a diagnostic
|
|
function tok_desc() -> pointer {
|
|
let t = toks[pi]
|
|
if t.kind == TK_EOF { return "end of file" }
|
|
if t.kind == TK_NL { return "end of line" }
|
|
if t.kind == TK_INT or t.kind == TK_FLOAT { return `number {itoa(t.ival)}` }
|
|
if t.kind == TK_STR { return `string \"{t.text}\"` }
|
|
return `'{t.text}'`
|
|
}
|
|
function eat_op(v: pointer) -> void { if not is_op(v) { perr(`expected '{v}', got {tok_desc()}`) }; pi += 1 }
|
|
function eat_id() -> pointer {
|
|
let t = toks[pi]
|
|
if t.kind != TK_ID { perr(`expected an identifier, got {tok_desc()}`) }
|
|
pi += 1
|
|
return t.text
|
|
}
|
|
function skipnl() -> void { while toks[pi].kind == TK_NL { pi += 1 } }
|
|
|
|
# a type: `[]T` slice, or a plain name (int/ptr/str/bool/struct)
|
|
function ptype() -> pointer {
|
|
if is_op("[") {
|
|
pi += 1
|
|
eat_op("]")
|
|
let el = ptype()
|
|
let out = bytes(len(el) + 3)
|
|
out[0] = '['; out[1] = ']' # "[]"
|
|
var i = 0
|
|
while el[i] != 0 { out[2 + i] = el[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 += 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
|
|
}
|
|
var g_interp_line: int = 1 # the line the interpolated string sits on
|
|
function parse_hole(inner: pointer) -> Node { # re-lex+parse an embedded expression
|
|
let saved_toks = toks; let saved_pi = pi
|
|
lex_at(inner, g_interp_line); 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 == '{' { # '{'
|
|
if raw[i + 1] == '{' { lit[lj] = '{'; lj += 1; i += 2; continue } # {{ -> {
|
|
if lj > 0 { acc = interp_add(acc, interp_lit(lit, lj)); lj = 0 }
|
|
i += 1
|
|
let hs = i
|
|
# find the hole's closing brace: braces nest, and a brace inside a
|
|
# "string" or 'char' literal in the hole is text, not structure
|
|
var depth = 1
|
|
while i < n and depth > 0 {
|
|
let d = raw[i]
|
|
if d == '"' or d == CH_SQUOTE {
|
|
i += 1
|
|
while i < n and raw[i] != d { if raw[i] == CH_BACKSLASH { i += 1 }; i += 1 }
|
|
}
|
|
else if d == '{' { depth += 1 }
|
|
else if d == '}' { depth -= 1; if depth == 0 { break } }
|
|
i += 1
|
|
}
|
|
acc = interp_add(acc, interp_str(parse_hole(raw[hs..i])))
|
|
i += 1 # skip the closing '}'
|
|
} else {
|
|
if c == '}' and raw[i + 1] == '}' { lit[lj] = '}'; lj += 1; i += 2; continue } # }} -> }
|
|
if c == CH_BACKSLASH { # backslash escape, same table as "strings"
|
|
lit[lj] = unescape(raw[i + 1]); lj += 1; i += 2; continue
|
|
}
|
|
lit[lj] = c; lj += 1; 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 += 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 += 1; skipnl() }
|
|
}
|
|
eat_op(")")
|
|
n.a = r
|
|
return n
|
|
}
|
|
|
|
# `[a, b, c]` — a slice literal (an empty `[]` needs a type: `new []T`)
|
|
function parse_list() -> Node {
|
|
let n = node(E_LIST); n.line = toks[pi].line
|
|
eat_op("["); skipnl()
|
|
while not is_op("]") {
|
|
push(n.kids, expr())
|
|
skipnl(); if is_op(",") { pi += 1; skipnl() }
|
|
}
|
|
eat_op("]")
|
|
return n
|
|
}
|
|
|
|
function p_primary() -> Node {
|
|
let t = toks[pi]
|
|
if t.kind == TK_INTERP { pi += 1; g_interp_line = t.line; return parse_interp(t.text) }
|
|
if is_op("[") { return parse_list() }
|
|
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 += 1; return n }
|
|
if t.kind == TK_FLOAT { let n = node(E_FLOAT); n.ival = t.ival; pi += 1; return n }
|
|
if t.kind == TK_STR { let n = node(E_STR); n.s = t.text; pi += 1; return n }
|
|
if t.kind == TK_ID {
|
|
if (t.text == "true") { let n = node(E_BOOL); n.ival = 1; pi += 1; return n }
|
|
if (t.text == "false") { let n = node(E_BOOL); n.ival = 0; pi += 1; return n }
|
|
if (t.text == "null") { pi += 1; return node(E_NULL) }
|
|
if (t.text == "new") { pi += 1; let n = node(E_NEW); n.s = ptype(); if is_op("{") { n.a = record() }; return n }
|
|
if (t.text == "spawn") { return parse_spawn() } # spawn as an expression: the new entity
|
|
# 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 += 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 += 1; skipnl()
|
|
n.b = block()
|
|
return n
|
|
}
|
|
let n = node(E_ID); n.s = t.text; pi += 1; return n
|
|
}
|
|
if is_op("(") { 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 += 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 == "Job" or e.a.s == "Promise" or e.a.s == "Sync") { g_uses_jobs = true } # splice the jobs/promise/sync 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 == "Xml" { g_uses_xml = true } # splice the XML reader on demand (Tiled #67)
|
|
if e.a.kind == E_ID and e.a.s == "Base64" { g_uses_base64 = true } # splice base64 codec + inflate on demand (Tiled #67)
|
|
if e.a.kind == E_ID and e.a.s == "Tiled" { g_uses_tiled = true } # splice the Tiled map runtime on demand (#69)
|
|
# #81 — Sprite.sheet/cell/… or Assets.* splice the spritesheet/atlas runtime
|
|
# (it reads the framebuffer + the image loader, so it links against core).
|
|
if e.a.kind == E_ID and e.a.s == "Sprite" and (e.s == "sheet" or e.s == "cell" or e.s == "cell_span" or e.s == "define" or e.s == "named" or e.s == "draw" or e.s == "draw_scaled" or e.s == "width" or e.s == "height") { g_uses_atlas = true }
|
|
if e.a.kind == E_ID and e.a.s == "Assets" { g_uses_atlas = true }
|
|
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" or e.s == "action" or e.s == "bind_pad" or e.s == "active" or e.s == "just_pressed" or e.s == "just_released") { 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 == "axis_i" 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 == "move_i" 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" or e.s == "cursor_mode") { 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 }
|
|
if e.a.kind == E_ID and e.a.s == "Fx" { g_uses_fx = true } # Fx.* -> splice fx.ludic + tick/draw it each frame
|
|
}
|
|
else if is_op("[") { pi += 1; let lo = expr()
|
|
if is_op("..") { 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
|
|
if (c.a.kind == E_ID) and ((c.a.s == "draw_sprite") or (c.a.s == "draw_sprite_scaled")) { warn_draw_sprite(c.a.s) } # #85 deprecate the bare aliases
|
|
}
|
|
else { break }
|
|
}
|
|
return e
|
|
}
|
|
|
|
function p_unary() -> Node {
|
|
if is_op("-") { pi += 1; let n = node(E_UN); n.s = "-"; n.a = p_unary(); return n }
|
|
if is_op("~") { pi += 1; let n = node(E_UN); n.s = "~"; n.a = p_unary(); return n } # bitwise not
|
|
if is_id("not") { 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 += 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 += 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 += 1; l = mkbin(op, l, p_add())
|
|
}
|
|
return l
|
|
}
|
|
function p_and() -> Node {
|
|
var l = p_cmp()
|
|
while is_id("and") { pi += 1; l = mkbin("and", l, p_cmp()) }
|
|
return l
|
|
}
|
|
function p_or() -> Node {
|
|
var l = p_and()
|
|
while is_id("or") { 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 += 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 += 1; let n = node(S_LET); n.ival = mut; n.line = toks[pi].line; n.s = eat_id()
|
|
if is_op(":") { pi += 1; n.ty = ptype() }
|
|
if is_op("=") { pi += 1; n.a = expr() }
|
|
return n
|
|
}
|
|
if (t.text == "return") {
|
|
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 += 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 += 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 += 1; let n = node(S_WHILE); n.a = expr(); n.b = block(); return n }
|
|
if (t.text == "for") {
|
|
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 += 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 += 1; s.b = expr() } # explicit value (still allowed)
|
|
s.ival = sidx # else: the enum variant of the same name
|
|
# when the store is enum-typed, or this ordinal
|
|
skipnl(); s.a = block()
|
|
push(n.kids, s); 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 += 1; let n = node(S_BECOME); n.s = eat_id(); return n }
|
|
if (t.text == "despawn") { 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 += 1; let n = node(S_TOGGLE); n.ival = en
|
|
if is_id("layer") { pi += 1; n.ty = "layer"; n.s = eat_id(); note_toggled_layer(n.s); return n } # enable/disable layer L
|
|
if is_id("system") { pi += 1; n.ty = "system"; n.s = eat_id(); if en == 0 { push(g_disabled_sys, n.s) }; return n } # disable system <esys_fn> (lever 5, compile-time)
|
|
n.s = eat_id() # `enable P on e` / `disable Model` / `disable Handler`
|
|
if is_id("on") { pi += 1; n.a = expr() } # property on an entity
|
|
return n
|
|
}
|
|
if (t.text == "attach") { # attach P on e [{ field: val, ... }]
|
|
pi += 1; let n = node(S_ATTACH); n.s = eat_id()
|
|
if not is_id("on") { perr("attach needs 'on <entity>'") }
|
|
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 += 1; let n = node(S_DETACH); n.s = eat_id()
|
|
if not is_id("on") { perr("detach needs 'on <entity>'") }
|
|
pi += 1; n.a = expr()
|
|
return n
|
|
}
|
|
if (t.text == "break") { pi += 1; return node(S_BREAK) }
|
|
if (t.text == "continue") { pi += 1; return node(S_CONTINUE) }
|
|
if (t.text == "cancel") { pi += 1; return node(S_CANCEL) } # veto a cancellable event
|
|
if (t.text == "match") {
|
|
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 += 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 += 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 += 1; let n = node(N_VAR); n.s = eat_id(); eat_op(":"); n.ty = ptype()
|
|
if is_op("=") { pi += 1; n.a = expr() }
|
|
return n
|
|
}
|
|
function parse_const() -> Node {
|
|
pi += 1; let n = node(N_CONST); n.s = eat_id(); eat_op(":"); n.ty = ptype(); eat_op("="); n.a = expr()
|
|
return n
|
|
}
|
|
# the language's statement / declaration words cannot name a function (`new`,
|
|
# `match`, `spawn` ...). Contextual words the parser only recognises in position
|
|
# (`emit`, `on`, `phase`, `start`, `export`, ...) stay usable — the compiler itself
|
|
# has a function called `emit`.
|
|
function is_reserved_word(w: pointer) -> bool {
|
|
if (w == "new") or (w == "match") or (w == "machine") or (w == "become") or (w == "spawn") or (w == "despawn") { return true }
|
|
if (w == "query") or (w == "cancel") or (w == "scene") or (w == "layer") or (w == "handler") or (w == "property") { return true }
|
|
if (w == "model") or (w == "enum") or (w == "event") or (w == "namespace") or (w == "function") or (w == "let") { return true }
|
|
if (w == "prefab") { return true }
|
|
if (w == "var") or (w == "const") or (w == "if") or (w == "else") or (w == "while") or (w == "for") or (w == "return") { return true }
|
|
if (w == "import") or (w == "program") or (w == "entry") or (w == "ui") or (w == "break") or (w == "continue") { return true }
|
|
if (w == "and") or (w == "or") or (w == "not") or (w == "true") or (w == "false") or (w == "null") { return true }
|
|
return false
|
|
}
|
|
|
|
function parse_fn() -> Node {
|
|
pi += 1; let n = node(N_FN); n.s = eat_id(); eat_op("(")
|
|
if is_reserved_word(n.s) { perr(`'{n.s}' is a reserved word and cannot name a function`) }
|
|
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 += 1 }
|
|
}
|
|
eat_op(")")
|
|
n.ty = "void"
|
|
if is_op("->") { pi += 1; n.ty = ptype() }
|
|
n.a = block()
|
|
return n
|
|
}
|
|
function parse_main() -> Node { pi += 1; let n = node(N_MAIN); n.a = block(); return n }
|
|
|
|
# issue #76 — rewrite calls to a namespace-block sibling (a bare short-name call)
|
|
# to the prefixed function name, so a body can call `pending()` where the function
|
|
# is emitted as `combat_pending`. Only E_CALL callees that are bare identifiers are
|
|
# rewritten (Ludic has no function values, and Name.method calls are E_MEMBER).
|
|
function ns_short_in(names: []pointer, s: pointer) -> bool {
|
|
var i = 0
|
|
while i < len(names) { if (names[i] == s) { return true }; i += 1 }
|
|
return false
|
|
}
|
|
function ns_rewrite_calls(n: Node, shorts: []pointer, prefix: pointer) -> void {
|
|
if (n == null) { return }
|
|
if (n.kind == E_CALL) and (n.a != null) and (n.a.kind == E_ID) {
|
|
if ns_short_in(shorts, n.a.s) { n.a.s = prefix + n.a.s }
|
|
}
|
|
ns_rewrite_calls(n.a, shorts, prefix)
|
|
ns_rewrite_calls(n.b, shorts, prefix)
|
|
ns_rewrite_calls(n.c, shorts, prefix)
|
|
var i = 0
|
|
while i < len(n.kids) { ns_rewrite_calls(n.kids[i], shorts, prefix); i += 1 }
|
|
}
|
|
|
|
# issue #76 — `namespace Name { [export|internal] function short(…) … }`. Declares
|
|
# the namespace once; each function is emitted as `<namelower>_short` and, when
|
|
# exported (the default; `internal` opts out), registered so `Name.short(…)`
|
|
# dispatches to it — the same generic path @Namespace(Name) uses, so it is the
|
|
# block sugar for it. Sibling calls inside the block are rewritten to the prefixed
|
|
# name. `internal` keeps a function as a private helper: it is emitted but not part
|
|
# of the Name.* surface.
|
|
function parse_namespace() -> void {
|
|
pi += 1 # eat "namespace"
|
|
let nsname = eat_id()
|
|
eat_op("{"); skipnl()
|
|
let fns = new []Node
|
|
let shorts = new []pointer
|
|
let exps = new []int
|
|
while not is_op("}") {
|
|
var is_exp = 1 # default: exported (public)
|
|
if is_id("export") { pi += 1 }
|
|
else { if is_id("internal") { pi += 1; is_exp = 0 } }
|
|
if not is_id("function") { perr("a namespace body holds functions: expected `function`") }
|
|
let f = parse_fn()
|
|
push(fns, f); push(shorts, f.s); push(exps, is_exp)
|
|
skipnl()
|
|
}
|
|
eat_op("}")
|
|
let prefix = ns_lower(nsname) + ("_")
|
|
var i = 0 # prefix every function name
|
|
while i < len(fns) { fns[i].s = prefix + shorts[i]; i += 1 }
|
|
i = 0 # rewrite sibling calls to the prefixed name
|
|
while i < len(fns) { ns_rewrite_calls(fns[i].a, shorts, prefix); i += 1 }
|
|
register_namespace(nsname) # Name.method dispatch (generic path)
|
|
push(g_ns_blocks, nsname)
|
|
i = 0
|
|
while i < len(fns) {
|
|
if (exps[i] == 1) { push(g_ns_exports, fns[i].s) } # record the public surface
|
|
push(prog, fns[i])
|
|
i += 1
|
|
}
|
|
}
|
|
|
|
# directory part of a path, including the trailing '/', or "" if none
|
|
function dir_of(path: pointer) -> pointer {
|
|
var last = -1
|
|
var i = 0
|
|
while path[i] != 0 { if path[i] == '/' { last = i }; i += 1 }
|
|
if last < 0 { return "" }
|
|
return path[0..last + 1]
|
|
}
|
|
function join_path(dir: pointer, rel: pointer) -> pointer {
|
|
if rel[0] == '/' { return rel } # absolute
|
|
return dir + rel
|
|
}
|
|
|
|
# The toolchain install root, with a trailing '/' ("" meaning the current
|
|
# directory). Everything the toolchain owns rather than the project — the engine
|
|
# runtime under runtime/native, the bundled ludic.* packages, the VERSION file,
|
|
# cocoa.ll — is resolved against it.
|
|
#
|
|
# $LUDIC_HOME wins when it is set. Otherwise it is derived from the compiler
|
|
# binary, whose directory is `bin` in both shapes that exist: an install
|
|
# (~/.ludic/bin/ludicc, root ~/.ludic) and a repo checkout (bin/ludicc, root the
|
|
# repo). So a directory named bin means the root is its parent; anything else is
|
|
# taken as the root itself, which is what a loose binary beside its runtime
|
|
# wants. Invoked through $PATH argv[0] carries no directory at all, so the PATH
|
|
# entries are searched for the binary first — an installed `ludicc mygame.ludic`
|
|
# has to find its own runtime with nothing in the environment.
|
|
function ludic_home() -> pointer {
|
|
let env = getenv("LUDIC_HOME")
|
|
if (env != null) { return ensure_slash(env) }
|
|
var d = dir_of(arg(0))
|
|
if (len(d) == 0) { d = path_lookup_dir(base_name(arg(0))) }
|
|
if (len(d) == 0) { return "" }
|
|
if (len(d) >= 4) and (d[len(d) - 4..len(d)] == "bin/") { return d[0..len(d) - 4] }
|
|
return d
|
|
}
|
|
|
|
# the first $PATH entry holding an openable file called `name`, with a trailing
|
|
# '/' — "" when $PATH is unset or nothing matches.
|
|
function path_lookup_dir(name: pointer) -> pointer {
|
|
let p = getenv("PATH")
|
|
if (p == null) { return "" }
|
|
let n = len(p)
|
|
var start = 0
|
|
var i = 0
|
|
while i <= n {
|
|
if (i == n) or (p[i] == ':') {
|
|
if i > start {
|
|
let dir = ensure_slash(p[start..i])
|
|
let f = file_open(dir + name, "rb")
|
|
if (f != null) { file_close(f); return dir }
|
|
}
|
|
start = i + 1
|
|
}
|
|
i += 1
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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_jobs: bool = false # a program mentioned Job.*/Promise.*/Sync.* -> splice the concurrency 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_xml: bool = false # Xml.* -> splice the XML reader (Tiled #67)
|
|
var g_uses_base64: bool = false # Base64.* -> splice the base64 codec + inflate (Tiled #67)
|
|
var g_uses_tiled: bool = false # Tiled.* -> splice the Tiled map runtime (#69)
|
|
var g_uses_atlas: bool = false # Sprite.sheet/cell/… or Assets.* -> splice the spritesheet/atlas runtime (#81)
|
|
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_has_clear_color: bool = false # @ClearColor(colour) declared -> the Render phase auto-clears + auto-presents (#86)
|
|
var g_clear_color: Node = null # the declared clear colour: a literal, a const name or a Color.Name
|
|
var g_warned_draw_sprite: bool = false # emit the bare-draw_sprite deprecation note once (#85)
|
|
var g_uses_world_despawn: bool = false # World.despawn / esys_bounds kill -> emit @fn_world_despawn (#84)
|
|
|
|
# #85 — steer a direct bare draw_sprite / draw_sprite_scaled call to the namespaced
|
|
# Screen.sprite (or the engine sprite-render system). Warns once, non-fatally — the
|
|
# bare form still compiles (Screen.sprite lowers to it), it is just deprecated.
|
|
function warn_draw_sprite(name: pointer) -> void {
|
|
if g_warned_draw_sprite { return }
|
|
g_warned_draw_sprite = true
|
|
let m = `ludicc: warning: bare {name}(...) is deprecated (#85) — use Screen.sprite / Screen.sprite_scaled, or declare a Sprite component for the engine sprite-render system\n`
|
|
file_write(file_stderr(), m, len(m))
|
|
}
|
|
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_fx: bool = false # Fx.sparks/number/clear -> splice fx.ludic; fx_tick each Update, fx_draw each Render
|
|
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)
|
|
# issue #64: functions marked @System(Phase) in a prebuilt binary module — the
|
|
# compiler registers each with the host at load (it supplies the fn address,
|
|
# which Ludic source cannot take). Parallel arrays: fn name -> phase name.
|
|
var g_mod_sys_fn: []pointer
|
|
var g_mod_sys_phase: []pointer
|
|
# issue #62: package-declarable registries (data-driven, additive to the core
|
|
# hardcoded paths). Engine systems: (component, esys-fn, phase) — @EngineSystem.
|
|
# Namespaces: names a package provides Foo.* dispatch for — @Namespace.
|
|
var g_esys_comp: []pointer
|
|
var g_esys_fn: []pointer
|
|
var g_esys_phase: []pointer
|
|
var g_namespaces: []pointer
|
|
|
|
# issue #76: `namespace Name { export/internal function … }` block form. A block
|
|
# declares the namespace once and controls its public surface declaratively.
|
|
# g_ns_blocks names the namespaces declared this way; g_ns_exports holds the
|
|
# prefixed function names that are *exported* (e.g. "combat_amount"), so emit_ns_call
|
|
# can reject Name.method for an `internal` helper. (A namespace declared the old
|
|
# per-function @Namespace(Name) way has no block entry, so all its methods stay
|
|
# dispatchable — back-compatible.)
|
|
var g_ns_blocks: []pointer
|
|
var g_ns_exports: []pointer
|
|
function is_ns_block(name: pointer) -> bool {
|
|
var i = 0
|
|
while i < len(g_ns_blocks) { if (g_ns_blocks[i] == name) { return true }; i += 1 }
|
|
return false
|
|
}
|
|
function ns_export_has(prefixed: pointer) -> bool {
|
|
var i = 0
|
|
while i < len(g_ns_exports) { if (g_ns_exports[i] == prefixed) { return true }; i += 1 }
|
|
return false
|
|
}
|
|
|
|
# lever 5 of the controller extensibility contract (#57): `disable system <fn>`
|
|
# switches off exactly one engine-owned system (an esys_* registered via
|
|
# @EngineSystem or the core seed) at compile time, so a game can carry a
|
|
# well-known component but tick it with its own handler instead. Recorded at
|
|
# parse time and read by emit_one_engine_system, which then skips that call
|
|
# (byte-identical for any program that disables nothing).
|
|
var g_disabled_sys: []pointer
|
|
function is_system_disabled(fn: pointer) -> bool {
|
|
var i = 0
|
|
while i < len(g_disabled_sys) { if (g_disabled_sys[i] == fn) { return true }; i += 1 }
|
|
return false
|
|
}
|
|
|
|
# #62: package namespace registry — a package marks a Foo.* provider with
|
|
# @Namespace(Foo); emit_ns_call aliases an otherwise-unknown Foo.method to the
|
|
# bare function foo_method (lowercased namespace + "_" + method).
|
|
function register_namespace(name: pointer) -> void {
|
|
var i = 0
|
|
while i < len(g_namespaces) { if (g_namespaces[i] == name) { return }; i += 1 }
|
|
push(g_namespaces, name)
|
|
}
|
|
function is_registered_namespace(name: pointer) -> bool {
|
|
var i = 0
|
|
while i < len(g_namespaces) { if (g_namespaces[i] == name) { return true }; i += 1 }
|
|
return false
|
|
}
|
|
|
|
function already_loaded(full: pointer) -> bool {
|
|
var i = 0
|
|
while i < len(loaded_paths) { if (loaded_paths[i] == full) { return true }; 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)
|
|
var sys_phase: pointer = null # @System(Phase) — a binary module's runtime-registered system (#64)
|
|
var esys_comp: pointer = null # @EngineSystem(Comp, Phase) — a package compile-time engine system (#62)
|
|
var esys_phase: pointer = null
|
|
var ns_name: pointer = null # @Namespace(Name) — a package-provided Foo.* namespace (#62)
|
|
while is_op("@") {
|
|
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 += 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 a == "System" { sys_phase = "Update"; if is_op("(") { pi += 1; sys_phase = eat_id(); eat_op(")") } } # @System(Phase) binary-module system (#64)
|
|
else if a == "EngineSystem" { eat_op("("); esys_comp = eat_id(); eat_op(","); esys_phase = eat_id(); eat_op(")") } # @EngineSystem(Comp, Phase) package engine system (#62)
|
|
else if a == "Namespace" { eat_op("("); ns_name = eat_id(); eat_op(")") } # @Namespace(Name) package Foo.* namespace (#62)
|
|
else if a == "ClearColor" { # @ClearColor(colour) — Render auto-clear + auto-present (#86)
|
|
eat_op("(")
|
|
g_clear_color = expr() # a literal, a `const`, or a Color.Name — resolved when emitted
|
|
g_has_clear_color = true
|
|
eat_op(")")
|
|
}
|
|
else if is_op("(") { # any other @anno(args) — parsed and skipped
|
|
var d = 0
|
|
while true { if is_op("(") { d += 1 }; if is_op(")") { d -= 1 }; pi += 1; if d == 0 { break } }
|
|
}
|
|
skipnl()
|
|
}
|
|
if is_id("import") { pi += 1
|
|
let t = toks[pi]
|
|
if t.kind != TK_STR { perr("expected \"path\" after import") }
|
|
let rel = t.text; 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 += 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("prefab") { push(prog, parse_prefab()); return } # a model with preset component fields
|
|
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("namespace") { parse_namespace(); return } # #76 namespace block
|
|
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 }
|
|
if (sys_phase != null) { push(g_mod_sys_fn, f.s); push(g_mod_sys_phase, sys_phase) } # #64: register at load
|
|
if (esys_comp != null) { push(g_esys_comp, esys_comp); push(g_esys_fn, f.s); push(g_esys_phase, esys_phase) } # #62: package engine system
|
|
if (ns_name != null) { register_namespace(ns_name) } # #62: package Foo.* namespace
|
|
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 a declaration (property, model, handler, function, …), got {tok_desc()}`)
|
|
}
|
|
|
|
# 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 += 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 += 1 # past the name
|
|
n.a = block()
|
|
return n
|
|
}
|
|
|
|
# lex and parse an imported fragment into `prog`, saving/restoring lexer state.
|
|
#
|
|
# An import is resolved first relative to the importing file (the historical
|
|
# behaviour). When that fails and the spec is not absolute, it is looked up
|
|
# under the package module root — $LUDIC_MODULES, default "ludic_modules" — so a
|
|
# fetched source package resolves by its import path, e.g.
|
|
# import "git.workshopsoft.io/user/pkg/foo.ludic"
|
|
# materialised by `ludic get` at ludic_modules/git.workshopsoft.io/user/pkg/foo.ludic.
|
|
# The fallback only fires when the local path does not exist, so every existing
|
|
# import resolves exactly as before (the emitted IR is byte-identical).
|
|
# true when `rel` is an engine-runtime splice path ("runtime/..."), as opposed to
|
|
# a user or package import. Those ship with the toolchain, not the project (#75).
|
|
function is_runtime_path(rel: pointer) -> bool { return str_starts(rel, "runtime/") }
|
|
|
|
# byte-wise `a < b`, for a stable import order of a directory's files
|
|
function str_less(a: pointer, b: pointer) -> bool {
|
|
var i = 0
|
|
while true {
|
|
let ca = a[i]; let cb = b[i]
|
|
if ca != cb { return ca < cb }
|
|
if ca == 0 { return false }
|
|
i += 1
|
|
}
|
|
return false
|
|
}
|
|
# `import "dir/*.ludic"` — every .ludic file of the directory, in name order, so
|
|
# a game lists its modules once instead of one import per file.
|
|
function do_import_dir(rel: pointer) -> void {
|
|
let dir = rel[0..len(rel) - 8] # strip "/*.ludic"
|
|
let names = Fs.list(join_path(cur_dir, dir))
|
|
if (names == null) { perr(`import: no directory {dir}`) }
|
|
var i = 0
|
|
while i < len(names) { # selection sort by name
|
|
var best = i
|
|
var j = i + 1
|
|
while j < len(names) { if str_less(names[j], names[best]) { best = j }; j += 1 }
|
|
let t = names[i]; names[i] = names[best]; names[best] = t
|
|
i += 1
|
|
}
|
|
i = 0
|
|
while i < len(names) {
|
|
let nm = names[i]
|
|
let n = len(nm)
|
|
if (n > 6) and (nm[n - 6..n] == ".ludic") { do_import(dir + ("/") + nm) }
|
|
i += 1
|
|
}
|
|
}
|
|
|
|
function do_import(rel: pointer) -> void {
|
|
if (len(rel) > 8) and (rel[len(rel) - 8..len(rel)] == "/*.ludic") { do_import_dir(rel); return }
|
|
var full = join_path(cur_dir, rel)
|
|
var src = read_file(full)
|
|
# #75 — the engine runtime (runtime/native/*) ships with the toolchain, not the
|
|
# project. When an auto-spliced runtime import is not found relative to the build
|
|
# (an external game whose CWD is not the toolchain repo), resolve it from the
|
|
# install root $LUDIC_HOME (default: the compiler binary's directory) — the same
|
|
# place main.ludic finds cocoa.ll / audio.ll. This is tried before the package
|
|
# module root, so $LUDIC_MODULES / ludic_modules holds only third-party packages.
|
|
if (src == null) and (rel[0] != '/') and is_runtime_path(rel) {
|
|
let hc = ludic_home() + rel
|
|
let hs = read_file(hc)
|
|
if (hs != null) { full = hc; src = hs }
|
|
}
|
|
if (src == null) and (rel[0] != '/') {
|
|
let cand = ensure_slash(getenv_or("LUDIC_MODULES", "ludic_modules")) + rel
|
|
let s2 = read_file(cand)
|
|
if (s2 != null) { full = cand; src = s2 }
|
|
}
|
|
# The ludic.* packages ship with the toolchain too, so an installed compiler
|
|
# resolves `import "ludic.core/components.ludic"` from $LUDIC_HOME/packages
|
|
# with no ludic_modules/ to set up. It is the last candidate on purpose: a
|
|
# project that fetched its own copy of a package (ludic_modules, above) keeps
|
|
# the version it pinned.
|
|
if (src == null) and (rel[0] != '/') {
|
|
let pc = ludic_home() + "packages/" + rel
|
|
let ps = read_file(pc)
|
|
if (ps != null) { full = pc; src = ps }
|
|
}
|
|
if already_loaded(full) { return }
|
|
push(loaded_paths, full)
|
|
if (src == null) { perr(`cannot open import {full}`) }
|
|
# the audio runtime can arrive through atlas.ludic's own import or the Assets
|
|
# splice, not only through an Audio.* call in the game; a windowed build must
|
|
# then link audio.ll + AVFoundation for its snd_* calls, so flag it here.
|
|
let atail = "runtime/native/audio.ludic"
|
|
let fl = len(full)
|
|
if (fl >= len(atail)) and (full[fl - len(atail)..fl] == atail) { g_uses_audio = true }
|
|
let saved_toks = toks; let saved_pi = pi; let saved_dir = cur_dir
|
|
let saved_file = g_parse_file; let saved_parsing = g_parsing
|
|
cur_dir = dir_of(full)
|
|
g_parse_file = full; g_parsing = true
|
|
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
|
|
g_parse_file = saved_file; g_parsing = saved_parsing
|
|
}
|
|
|
|
# a game (has handlers/components) links the Ludic runtime; auto-splice it.
|
|
# Tools (an `entry` 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 Job.*/Promise.*/Sync.* gets the concurrency runtime
|
|
# spliced in (it is self-contained — only compiler intrinsics — so a plain tool
|
|
# works too). A deterministic cooperative scheduler; see runtime/native/jobs.ludic.
|
|
if g_uses_jobs {
|
|
cur_dir = ""
|
|
do_import("runtime/native/jobs.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
|
|
}
|
|
# Xml.* (#67) — the minimal pure-Ludic XML reader for the TMX/TSX/TX subset.
|
|
# Self-contained (string/slice ops only), so it works in a plain tool too.
|
|
if g_uses_xml {
|
|
cur_dir = ""
|
|
do_import("runtime/native/xml.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# Sprite.sheet/cell/… or Assets.* (#81) — the spritesheet / atlas runtime. It
|
|
# reads the framebuffer and the image loader (rt_image_load / img_px in
|
|
# image.ludic), so it links against core.ludic (do_import dedupes).
|
|
if g_uses_atlas {
|
|
cur_dir = ""
|
|
do_import("runtime/native/core.ludic")
|
|
do_import("runtime/native/audio.ludic") # Assets.enqueue loads .wav/.mp3 into the sound bank
|
|
do_import("runtime/native/atlas.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# Fx.* — engine-owned sparks and floating numbers; drawn through the framebuffer,
|
|
# so it links against core.ludic (do_import dedupes).
|
|
if g_uses_fx {
|
|
cur_dir = ""
|
|
do_import("runtime/native/core.ludic")
|
|
do_import("runtime/native/fx.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# Base64.* (#67) — base64 codec; pull in the DEFLATE inflater alongside it, so
|
|
# a plain tool can run the full base64 -> zlib/gzip decode chain (inflate.ludic
|
|
# is self-contained; do_import dedupes when a game already linked it via core).
|
|
if g_uses_base64 {
|
|
cur_dir = ""
|
|
do_import("runtime/native/inflate.ludic")
|
|
do_import("runtime/native/zstd.ludic")
|
|
do_import("runtime/native/base64.ludic")
|
|
cur_dir = saved
|
|
}
|
|
# Tiled.* (#69) — the Tiled map runtime: model, GID resolver, loader, draw. It
|
|
# reads/writes the tilemap + framebuffer, so it links against core.ludic, and
|
|
# it consumes the XML/base64/inflate primitives above.
|
|
if g_uses_tiled {
|
|
cur_dir = ""
|
|
do_import("runtime/native/core.ludic")
|
|
do_import("runtime/native/xml.ludic")
|
|
do_import("runtime/native/inflate.ludic")
|
|
do_import("runtime/native/zstd.ludic")
|
|
do_import("runtime/native/base64.ludic")
|
|
do_import("runtime/native/value.ludic")
|
|
do_import("runtime/native/tiled.ludic")
|
|
# #72 opt-in spawning stands on the reflection ABI, which the compiler emits
|
|
# only for a game (emit_decl: has_ecs -> emit_world_table). So splice the
|
|
# spawn helpers only for a Tiled game; a plain map-reading tool never links
|
|
# against the world table.
|
|
if has_ecs() { do_import("runtime/native/tiled_spawn.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")
|
|
}
|
|
# Body -> the movement + collision system (#65). It reads the tilemap
|
|
# (rt_tile / rt_mapw / rt_maph) for the tile-grid broadphase, so it links
|
|
# against core.ludic; do_import dedupes when a game already pulled core in.
|
|
if find_comp("Body") != null {
|
|
do_import("runtime/native/core.ludic")
|
|
do_import("runtime/native/systems_move.ludic")
|
|
}
|
|
# Sprite -> the engine sprite-render system (#85). It draws through
|
|
# rt_draw_sprite_ex (image.ludic, part of core), so it links against core.ludic
|
|
# (do_import dedupes when a game already pulled core in).
|
|
if find_comp("Sprite") != null {
|
|
do_import("runtime/native/core.ludic")
|
|
do_import("runtime/native/systems_sprite.ludic")
|
|
}
|
|
# TileSkin -> the engine tilemap-render system: draws the Map.* grid from glyph -> sprite skins.
|
|
if find_comp("TileSkin") != null {
|
|
do_import("runtime/native/core.ludic")
|
|
do_import("runtime/native/systems_tileskin.ludic")
|
|
}
|
|
# Bounds -> the engine world-bounds system (#84). Self-contained (reflection
|
|
# ABI only); its kill policy calls world_despawn, so force the @fn_world_despawn
|
|
# helper to be emitted.
|
|
if find_comp("Bounds") != null {
|
|
do_import("runtime/native/systems_bounds.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_jobs = false
|
|
g_uses_query = false
|
|
g_uses_reflect = false
|
|
g_uses_esys = false
|
|
g_uses_input = false
|
|
g_has_clear_color = false
|
|
g_clear_color = null
|
|
g_warned_draw_sprite = false
|
|
g_uses_world_despawn = false
|
|
g_uses_light = false
|
|
g_uses_value = false
|
|
g_uses_xml = false
|
|
g_uses_base64 = false
|
|
g_uses_tiled = false
|
|
g_uses_atlas = false
|
|
g_uses_fx = false
|
|
g_uses_reflect_io = false
|
|
g_tests = new []Node
|
|
g_mod_sys_fn = new []pointer
|
|
g_mod_sys_phase = new []pointer
|
|
# #62: seed the engine-system registry with the core entries, in the historical
|
|
# emit order (SpriteAnim, Motion — Update; Light2D — Render), so a core game is
|
|
# byte-identical; packages append via @EngineSystem.
|
|
g_esys_comp = new []pointer; g_esys_fn = new []pointer; g_esys_phase = new []pointer
|
|
g_disabled_sys = new []pointer
|
|
push(g_esys_comp, "SpriteAnim"); push(g_esys_fn, "esys_spriteanim"); push(g_esys_phase, "Update")
|
|
push(g_esys_comp, "Motion"); push(g_esys_fn, "esys_motion"); push(g_esys_phase, "Update")
|
|
push(g_esys_comp, "Body"); push(g_esys_fn, "esys_move"); push(g_esys_phase, "Update")
|
|
push(g_esys_comp, "Bounds"); push(g_esys_fn, "esys_bounds"); push(g_esys_phase, "LateUpdate")
|
|
push(g_esys_comp, "TileSkin"); push(g_esys_fn, "esys_tileskin"); push(g_esys_phase, "Render")
|
|
push(g_esys_comp, "Sprite"); push(g_esys_fn, "esys_sprite"); push(g_esys_phase, "Render")
|
|
push(g_esys_comp, "Light2D"); push(g_esys_fn, "esys_light2d"); push(g_esys_phase, "Render")
|
|
g_namespaces = new []pointer
|
|
g_ns_blocks = new []pointer
|
|
g_ns_exports = new []pointer
|
|
loaded_paths = new []pointer
|
|
skipnl()
|
|
g_game_name = "Ludic"
|
|
# imports may precede the program block
|
|
while is_id("import") { pi += 1; let t = toks[pi]; let rel = t.text; pi += 1; do_import(rel); skipnl() }
|
|
# @annotations on the program itself (e.g. @Handles(Movement)) — parsed, skipped
|
|
while is_op("@") {
|
|
pi += 1; let a = eat_id()
|
|
if is_op("(") { var d = 0
|
|
while true { if is_op("(") { d += 1 }; if is_op(")") { d -= 1 }; pi += 1; if d == 0 { break } } }
|
|
skipnl()
|
|
}
|
|
if is_id("program") { 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()
|
|
}
|
|
}
|