ludic/selfhost/frontend/parse.ludic
Orkuncakilkaya 9259808f80 feat(lang): L6 namespaces declared in Ludic - alias
`alias meth(labels) = target` in a namespace block makes Ns.meth a call to
target with those labels (the target's own parameter names without a list). The
engine's 41 table-driven namespaces - 438 methods: Http, Udp, Process, Json,
Value, Screen, Input, Audio, World, Tiled, ... - leave emit_ns_call for
runtime/native/namespaces.ludic, spliced into every program; 532 lines of
compiler go and the seed shrinks by 23k lines of IR. The game's IR is byte
identical. The checker checks an alias call's arguments against its target.
Still built in: the inline namespaces (Math, Text, List, Vector, Color, Time,
Date, ...) and the methods that pick a target by argument type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 02:40:57 +03:00

1466 lines
68 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
# `numbers float`: files whose bare decimal literals are float, not fixed. A file that says
# so is listed, and so is every non-runtime file it imports (a barrel passes it on).
var g_float_files: []pointer = new []pointer
# L3 modules: `module NAME` at the top of a file names the module it and everything it imports
# belong to, until an import names its own; `friend module NAME` may see every module's private
# names (a test harness). A file in no module - the runtime, a program's root - is public.
var g_mod_file: []pointer = new []pointer
var g_mod_name: []pointer = new []pointer
var g_mod_friends: []pointer = new []pointer
function module_of(f: pointer) -> pointer {
var i = len(g_mod_file) - 1
while i >= 0 {
if (g_mod_file[i] == f) { return g_mod_name[i] }
i -= 1
}
return ""
}
function module_set(f: pointer, name: pointer) -> void {
push(g_mod_file, f)
push(g_mod_name, name)
}
function module_is_friend(name: pointer) -> bool {
var i = 0
while i < len(g_mod_friends) {
if (g_mod_friends[i] == name) { return true }
i += 1
}
return false
}
function is_float_file(f: pointer) -> bool {
if (f == null) { return false }
var i = 0
while i < len(g_float_files) {
if (g_float_files[i] == f) { return true }
i += 1
}
return false
}
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, `fn(T, U) -> R` function, or a plain name (int/ptr/str/bool/struct)
function ptype() -> pointer {
if (toks[pi].text == "fn") and (toks[pi + 1].text == "(") {
pi += 1
eat_op("(")
var out = "fn("
var first = true
while not is_op(")") {
if not first { out = out + "," }
out = out + ptype()
first = false
if is_op(",") { pi += 1 }
}
eat_op(")")
var r: pointer = "void"
if is_op("->") {
pi += 1
r = ptype()
}
return out + ")->" + r
}
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
}
let tn = eat_id()
if is_op("<") { return gen_type_args(tn) }
return tn
}
# ---- 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; n.s = t.text; 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
# fn name — a top-level function as a value, for a worker entry point (Job.parallel_for)
# `fn` stays an ordinary name before an operator word (`p < fn and ...`) or at the end of a line
if (t.text == "fn") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 1].line == t.line) and (toks[pi + 1].text != "and") and (toks[pi + 1].text != "or") and (toks[pi + 1].text != "not") and (toks[pi + 1].text != "in") and (toks[pi + 1].text != "is") { pi += 1; let n = node(E_FNREF); n.line = t.line; n.s = eat_id(); 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 += 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 == "key_label" 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 }
# Udp.* — any Udp method splices the datagram runtime (and links udp.ll / udp_win.ll).
if e.a.kind == E_ID and e.a.s == "Udp" { g_uses_udp = true }
# Process.* — any Process method splices the child-process runtime (process.ll / process_win.ll).
if e.a.kind == E_ID and e.a.s == "Process" { g_uses_process = true }
# Gl.* — any Gl method splices the OpenGL runtime (and links the GL backend).
if e.a.kind == E_ID and e.a.s == "Gl" { g_uses_gl = true }
# Vk.* — any Vk method splices the Vulkan runtime (and links its loader)
if e.a.kind == E_ID and e.a.s == "Vk" { g_uses_vk = 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()
if is_op("<") { n.tps = gen_params() } # L5: function first<T>(xs: []T) -> T
gen_enter(n.tps)
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()
gen_enter(null)
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 is_id("alias") {
ns_parse_alias(nsname, is_exp)
skipnl()
continue
}
if not is_id("function") { perr("a namespace body holds functions or aliases: expected `function` or `alias`") }
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
}
}
# Is the compiler itself running on Windows? A property of the HOST, asked at run
# time - every Windows process inherits OS=Windows_NT - so one compiler source
# answers correctly whichever platform its binary was built for.
function host_is_windows() -> bool {
let os = getenv("OS")
if (os == null) { return false }
return os == "Windows_NT"
}
# a path separator: '/' everywhere, '\' too on Windows (argv[0] and %PATH% use it)
function is_sep(c: int) -> bool { return (c == '/') or (c == 92) }
# a copy of `s` with every '\' as '/', which Windows accepts wherever it takes a path
function fwd_slashes(s: pointer) -> pointer {
let n = len(s)
let out = bytes(n + 1)
var i = 0
while i < n { if s[i] == 92 { out[i] = '/' } else { out[i] = s[i] }; i += 1 }
out[n] = 0
return out
}
# directory part of a path, including the trailing separator, or "" if none
function dir_of(path: pointer) -> pointer {
var last = -1
var i = 0
while path[i] != 0 { if is_sep(path[i]) { last = i }; i += 1 }
if last < 0 { return "" }
return path[0..last + 1]
}
function join_path(dir: pointer, rel: pointer) -> pointer {
if is_sep(rel[0]) { return rel } # absolute
if rel[0] != 0 { if rel[1] == ':' { return rel } } # absolute: a Windows drive
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.
#
# On Windows argv[0] and $PATH carry backslashes, $PATH is split on ';', and a
# binary found through it is `ludicc.exe` while argv[0] may say `ludicc`, so the
# directory is read with forward slashes and the lookup tries both names.
function ludic_home() -> pointer {
let env = getenv("LUDIC_HOME")
if (env != null) { return ensure_slash(fwd_slashes(env)) }
var d = fwd_slashes(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` (or `name`.exe on
# Windows), 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 win = host_is_windows()
var sep = ':'
if win { sep = ';' }
let n = len(p)
var start = 0
var i = 0
while i <= n {
if (i == n) or (p[i] == sep) {
if i > start {
let dir = ensure_slash(fwd_slashes(p[start..i]))
let f = file_open(dir + name, "rb")
if (f != null) { file_close(f); return dir }
if win {
let fx = file_open(dir + name + ".exe", "rb")
if (fx != null) { file_close(fx); 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)
var g_uses_udp: bool = false # Udp.* -> splice udp.ludic; links udp.ll (macOS) / udp_win.ll + ws2_32
var g_uses_process: bool = false # Process.* -> splice process.ludic; links process.ll (macOS) / process_win.ll
var g_uses_gl: bool = false # Gl.* -> splice gl.ludic (+ generated gl_api.ludic); links gl.ll + gl_thunks.ll + OpenGL
var g_uses_vk: bool = false # Vk.* -> splice vk.ludic (+ generated vk_api.ludic); links vk_thunks.ll + the platform loader
# 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)
var is_det = false # @deterministic — no floating point inside (emit_float.ludic)
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 == "deterministic" { is_det = true }
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("friend") and (toks[pi + 1].text == "module") {
pi += 2
let fm = eat_id()
module_set(g_parse_file, fm)
push(g_mod_friends, fm)
return
}
if is_id("module") and toks[pi + 1].kind == TK_ID {
pi += 1
module_set(g_parse_file, eat_id())
return
}
# `export function f`, `export var v`, ...: visible from other modules (L3)
if is_id("export") and (toks[pi + 1].kind == TK_ID) {
pi += 1
let p0 = len(prog)
let e0 = len(g_events)
parse_one_decl()
var k = p0
while k < len(prog) {
prog[k].vis = 1
k += 1
}
k = e0
while k < len(g_events) {
g_events[k].vis = 1
k += 1
}
return
}
if is_id("numbers") and (toks[pi + 1].text == "float") {
pi += 2
if not is_float_file(g_parse_file) { push(g_float_files, g_parse_file) }
return
}
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 is_det { push(g_det_names, h.s) }
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_det { push(g_det_names, f.s) }
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
}
}
# `import "camp"` names a directory: its index.ludic is the barrel that lists
# what the directory exports (its own imports, relative to itself).
function barrel_of(rel: pointer) -> pointer {
let n = len(rel)
if (n > 6) and (rel[n - 6..n] == ".ludic") { return "" }
if rel[n - 1] == '/' { return rel + "index.ludic" }
return rel + "/index.ludic"
}
function do_import(rel: pointer) -> void {
if (len(rel) > 8) and (rel[len(rel) - 8..len(rel)] == "/*.ludic") { do_import_dir(rel); return }
let barrel = barrel_of(rel)
if barrel != "" {
if (read_file(join_path(cur_dir, barrel)) == null) and Fs.is_dir(join_path(cur_dir, rel)) {
perr(`import: {rel} is a directory with no index.ludic (add {barrel} listing its imports)`)
}
do_import(barrel)
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 is_float_file(g_parse_file) and not is_runtime_path(rel) and not is_float_file(full) { push(g_float_files, full) }
# a module reaches as far as its own files: a package found through $LUDIC_HOME or
# ludic_modules is not beside its importer and keeps its own module (or none)
let beside = full == join_path(cur_dir, rel)
if beside and not is_runtime_path(rel) and not (module_of(g_parse_file) == "") { module_set(full, module_of(g_parse_file)) }
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
# L6: the engine's namespaces that are aliases of runtime functions, declared in Ludic
cur_dir = ""
do_import("runtime/native/namespaces.ludic")
cur_dir = saved
# 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
}
# Udp.*: splice the datagram library. The sockets are native (udp.ll / udp_win.ll, bound
# by `extern function` in udp.ludic); the address helpers are pure Ludic.
if g_uses_udp {
cur_dir = ""
do_import("runtime/native/udp.ludic")
cur_dir = saved
}
# Process.*: splice the child-process library. Spawning is native (process.ll /
# process_win.ll); the argv array and the Windows command line are built in Ludic.
if g_uses_process {
cur_dir = ""
do_import("runtime/native/process.ludic")
cur_dir = saved
}
# Gl.*: splice the OpenGL surface (gl.ludic + the generated gl_api.ludic). The
# native calls are the linked GL entry points themselves; the window attach is
# is_windowed()-guarded, so a headless build renders into an offscreen context.
if g_uses_gl {
cur_dir = ""
do_import("runtime/native/gl.ludic")
cur_dir = saved
}
# Vk.*: splice the Vulkan surface (vk.ludic + the generated vk_api.ludic). The
# loader is opened at run time, so a program that never calls Vk.open needs none.
if g_uses_vk {
cur_dir = ""
do_import("runtime/native/vk.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_gl = false
g_uses_vk = 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()
}
}