# 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: ptr # the `game`/`module` name fn cur() -> Tok { return toks[pi] } fn pk(o: int) -> Tok { return toks[pi + o] } fn is_op(v: ptr) -> bool { let t = toks[pi]; return t.kind == TK_OP and (t.text == v) } fn is_id(v: ptr) -> bool { let t = toks[pi]; return t.kind == TK_ID and (t.text == v) } fn is_kw(v: ptr) -> bool { return is_id(v) } fn perr(msg: ptr) -> void { let e = file_stderr() file_write(e, "ludicc(self): parse error: ", 27) file_write(e, msg, len(msg)) file_write(e, "\n", 1) exit(1) } fn eat_op(v: ptr) -> void { if not is_op(v) { perr(v) }; pi = pi + 1 } fn eat_id() -> ptr { let t = toks[pi] if t.kind != TK_ID { perr("expected identifier") } pi = pi + 1 return t.text } fn skipnl() -> void { while toks[pi].kind == TK_NL { pi = pi + 1 } } # a type: `[]T` slice, or a plain name (int/ptr/str/bool/struct) fn ptype() -> ptr { if is_op("[") { pi = pi + 1 eat_op("]") let el = ptype() let out = bytes(len(el) + 3) out[0] = 91; out[1] = 93 # "[]" var i = 0 while el[i] != 0 { out[2 + i] = el[i]; i = i + 1 } out[2 + i] = 0 return out } return eat_id() } # ---- expressions ----------------------------------------------------------- fn 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. fn args_call(call: Node) -> void { eat_op("("); skipnl() while not is_op(")") { let t = toks[pi] let nx = toks[pi + 1] if t.kind == TK_ID and nx.kind == TK_OP and (nx.text == ":") { let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); skipnl(); fi.a = expr(); push(call.kids, fi) } else { push(call.kids, expr()) } skipnl(); if is_op(",") { pi = pi + 1; skipnl() } } eat_op(")") } # ---- string interpolation -------------------------------------------------- # `text {expr} text` desugars to a `+` chain of string literals and `str(expr)` # holes, so it reuses the string-concat operator and needs no new runtime. fn interp_lit(buf: ptr, len: int) -> Node { let n = node(E_STR); n.s = buf[0..0 + len]; return n } fn interp_add(acc: Node, part: Node) -> Node { if acc == null { return part } return mkbin("+", acc, part) } fn interp_str(e: Node) -> Node { # wrap a hole in str(...) let c = node(E_CALL); let id = node(E_ID); id.s = "str"; c.a = id; push(c.kids, e); return c } fn parse_hole(inner: ptr) -> Node { # re-lex+parse an embedded expression let saved_toks = toks; let saved_pi = pi lex(inner); pi = 0; skipnl() let e = expr() toks = saved_toks; pi = saved_pi return e } fn parse_interp(raw: ptr) -> Node { let n = len(raw) var acc: Node = null let lit = bytes(n + 1) var lj = 0 var i = 0 while i < n { let c = raw[i] if c == 123 { # '{' if raw[i + 1] == 123 { lit[lj] = 123; lj = lj + 1; i = i + 2; continue } # {{ -> { if lj > 0 { acc = interp_add(acc, interp_lit(lit, lj)); lj = 0 } i = i + 1 let hs = i var depth = 1 while i < n and depth > 0 { let d = raw[i] if d == 123 { depth = depth + 1 } else { if d == 125 { depth = depth - 1; if depth == 0 { break } } } i = i + 1 } acc = interp_add(acc, interp_str(parse_hole(raw[hs..i]))) i = i + 1 # skip the closing '}' } else { if c == 125 and raw[i + 1] == 125 { lit[lj] = 125; lj = lj + 1; i = i + 2; continue } # }} -> } if c == 92 { # backslash escape in literal text let e = raw[i + 1]; var r = e if e == 110 { r = 10 } if e == 116 { r = 9 } lit[lj] = r; lj = lj + 1; i = i + 2; continue } lit[lj] = c; lj = lj + 1; i = i + 1 } } if lj > 0 or (acc == null) { acc = interp_add(acc, interp_lit(lit, lj)) } return acc } # emit E(field: v, ...) — shared by the statement form and the expression form. # As an expression it yields a cancellable event's cancelled flag (0/1); a # non-cancellable event yields 0. fn parse_emit() -> Node { pi = pi + 1; let n = node(S_EMIT); n.s = eat_id() let r = node(E_REC) eat_op("("); skipnl() while not is_op(")") { let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); fi.a = expr(); push(r.kids, fi) skipnl(); if is_op(",") { pi = pi + 1; skipnl() } } eat_op(")") n.a = r return n } fn p_primary() -> Node { let t = toks[pi] if t.kind == TK_INTERP { pi = pi + 1; return parse_interp(t.text) } if t.kind == TK_ID and (t.text == "emit") and (toks[pi + 1].kind == TK_ID) { return parse_emit() } if t.kind == TK_INT { let n = node(E_INT); n.ival = t.ival; pi = pi + 1; return n } if t.kind == TK_FLOAT { let n = node(E_FLOAT); n.ival = t.ival; pi = pi + 1; return n } if t.kind == TK_STR { let n = node(E_STR); n.s = t.text; pi = pi + 1; return n } if t.kind == TK_ID { if (t.text == "true") { let n = node(E_BOOL); n.ival = 1; pi = pi + 1; return n } if (t.text == "false") { let n = node(E_BOOL); n.ival = 0; pi = pi + 1; return n } if (t.text == "null") { pi = pi + 1; return node(E_NULL) } if (t.text == "new") { pi = pi + 1; let n = node(E_NEW); n.s = ptype(); return n } let n = node(E_ID); n.s = t.text; pi = pi + 1; return n } if is_op("(") { pi = pi + 1; skipnl(); let e = expr(); skipnl(); eat_op(")"); return e } perr("expected expression") return node(E_INT) } fn p_postfix() -> Node { var e = p_primary() while true { if is_op(".") { pi = pi + 1; let m = node(E_MEMBER); m.a = e; m.s = eat_id(); e = m } else { if is_op("[") { pi = pi + 1; let lo = expr() if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring else { let ix = node(E_INDEX); ix.a = e; ix.b = lo; eat_op("]"); e = ix } } else { if is_op("(") { let c = node(E_CALL); c.a = e; args_call(c); e = c } else { break } } } } return e } fn p_unary() -> Node { if is_op("-") { pi = pi + 1; let n = node(E_UN); n.s = "-"; n.a = p_unary(); return n } if is_op("~") { pi = pi + 1; let n = node(E_UN); n.s = "~"; n.a = p_unary(); return n } # bitwise not if is_id("not") { pi = pi + 1; let n = node(E_UN); n.s = "not"; n.a = p_unary(); return n } return p_postfix() } fn mkbin(op: ptr, 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. fn p_mul() -> Node { var l = p_unary() while is_op("*") or is_op("/") or is_op("%") or is_op("<<") or is_op(">>") or is_op("&") { let op = toks[pi].text; pi = pi + 1; l = mkbin(op, l, p_unary()) } return l } fn p_add() -> Node { var l = p_mul() while is_op("+") or is_op("-") or is_op("|") or is_op("^") { let op = toks[pi].text; pi = pi + 1; l = mkbin(op, l, p_mul()) } return l } fn p_cmp() -> Node { var l = p_add() while is_op("<") or is_op("<=") or is_op(">") or is_op(">=") or is_op("==") or is_op("!=") { let op = toks[pi].text; pi = pi + 1; l = mkbin(op, l, p_add()) } return l } fn p_and() -> Node { var l = p_cmp() while is_id("and") { pi = pi + 1; l = mkbin("and", l, p_cmp()) } return l } fn p_or() -> Node { var l = p_and() while is_id("or") { pi = pi + 1; l = mkbin("or", l, p_and()) } return l } # a record literal `{ field: value, ... }` — used by spawn component inits. # Rule A: a named part uses `:` (`=` is assignment/binding only). fn record() -> Node { eat_op("{") let r = node(E_REC) while true { skipnl(); if is_op("}") { break } let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); fi.a = expr(); push(r.kids, fi) if is_op(",") { pi = pi + 1 } } eat_op("}"); return r } # ---- statements ------------------------------------------------------------ fn 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 } fn stmt() -> Node { let t = toks[pi] if t.kind == TK_ID { if (t.text == "let") or (t.text == "var") { var mut = 0; if (t.text == "var") { mut = 1 } # let = immutable, var = mutable pi = pi + 1; let n = node(S_LET); n.ival = mut; n.line = toks[pi].line; n.s = eat_id() if is_op(":") { pi = pi + 1; n.ty = ptype() } if is_op("=") { pi = pi + 1; n.a = expr() } return n } if (t.text == "return") { pi = pi + 1; let n = node(S_RETURN) if toks[pi].kind != TK_NL and not is_op("}") { n.a = expr() } return n } if (t.text == "if") { pi = pi + 1; let n = node(S_IF); n.a = expr(); n.b = block() let save = pi; skipnl() # peek past newlines for a trailing `else` if is_id("else") { pi = pi + 1; skipnl() if is_id("if") { n.c = stmt() } else { n.c = block() } } else { pi = save } # no else: keep the separator for block()'s Rule-B check return n } if (t.text == "while") { pi = pi + 1; let n = node(S_WHILE); n.a = expr(); n.b = block(); return n } if (t.text == "for") { pi = pi + 1 if is_op("(") { return parse_query_for() } let n = node(S_FOR); n.s = eat_id() let inkw = eat_id() # 'in' n.a = expr(); eat_op(".."); n.b = expr(); n.c = block() return n } if (t.text == "spawn") { return parse_spawn() } if (t.text == "machine") { pi = pi + 1; let n = node(S_MACHINE); n.a = expr(); skipnl(); eat_op("{") var sidx = 0 # states auto-number by declaration order while true { skipnl(); if is_op("}") { break } let stkw = eat_id() # 'state' let s = node(S_STATE); s.s = eat_id() if is_op("=") { pi = pi + 1; s.b = expr() } # explicit value (still allowed) else { let iv = node(E_INT); iv.ival = sidx; s.b = iv } # else its ordinal skipnl(); s.a = block() push(n.kids, s); sidx = sidx + 1 } eat_op("}"); return n } # `emit E(...)` fires an event, but a bare `emit(...)` is an ordinary call # (the compiler dogfoods a function named `emit`), so require an event name. if (t.text == "emit") and (toks[pi + 1].kind == TK_ID) { return parse_emit() } if (t.text == "become") { pi = pi + 1; let n = node(S_BECOME); n.s = eat_id(); return n } if (t.text == "despawn") { pi = pi + 1; let n = node(S_DESPAWN); n.a = expr(); return n } if (t.text == "enable") or (t.text == "disable") { var en = 0; if (t.text == "enable") { en = 1 } pi = pi + 1; let n = node(S_TOGGLE); n.ival = en if is_id("layer") { pi = pi + 1; n.ty = "layer"; n.s = eat_id(); note_toggled_layer(n.s); return n } # enable/disable layer L n.s = eat_id() # `enable P on e` / `disable Model` / `disable Handler` if is_id("on") { pi = pi + 1; n.a = expr() } # property on an entity return n } if (t.text == "attach") { # attach P on e [{ field: val, ... }] pi = pi + 1; let n = node(S_ATTACH); n.s = eat_id() if not is_id("on") { perr("attach needs 'on '") } pi = pi + 1; n.a = expr() if is_op("{") { n.b = record() } # optional field overrides (same-line) return n } if (t.text == "detach") { # detach P on e pi = pi + 1; let n = node(S_DETACH); n.s = eat_id() if not is_id("on") { perr("detach needs 'on '") } pi = pi + 1; n.a = expr() return n } if (t.text == "break") { pi = pi + 1; return node(S_BREAK) } if (t.text == "continue") { pi = pi + 1; return node(S_CONTINUE) } if (t.text == "cancel") { pi = pi + 1; return node(S_CANCEL) } # veto a cancellable event if (t.text == "match") { pi = pi + 1; let n = node(S_MATCH); n.a = expr(); skipnl(); eat_op("{") while true { skipnl(); if is_op("}") { break } let arm = node(S_MARM) while true { push(arm.kids, expr()); if is_op(",") { pi = pi + 1; skipnl(); continue }; break } eat_op("=>"); skipnl() if is_op("{") { arm.a = block() } else { let b = node(N_BLOCK); push(b.kids, stmt()); arm.a = b } push(n.kids, arm) } eat_op("}"); return n } } let e = expr() if is_op("=") or is_op("+=") or is_op("-=") or is_op("*=") or is_op("/=") { let n = node(S_ASSIGN); n.line = toks[pi].line; n.s = toks[pi].text; pi = pi + 1; n.a = e; n.b = expr(); return n } let n = node(S_EXPR); n.a = e; return n } # ---- declarations ---------------------------------------------------------- fn parse_var() -> Node { pi = pi + 1; let n = node(N_VAR); n.s = eat_id(); eat_op(":"); n.ty = ptype() if is_op("=") { pi = pi + 1; n.a = expr() } return n } fn parse_const() -> Node { pi = pi + 1; let n = node(N_CONST); n.s = eat_id(); eat_op(":"); n.ty = ptype(); eat_op("="); n.a = expr() return n } fn parse_fn() -> Node { pi = pi + 1; let n = node(N_FN); n.s = eat_id(); eat_op("(") while not is_op(")") { let p = node(N_PARAM); p.s = eat_id(); eat_op(":"); p.ty = ptype(); push(n.kids, p) if is_op(",") { pi = pi + 1 } } eat_op(")") n.ty = "void" if is_op("->") { pi = pi + 1; n.ty = ptype() } n.a = block() return n } fn parse_main() -> Node { pi = pi + 1; let n = node(N_MAIN); n.a = block(); return n } # directory part of a path, including the trailing '/', or "" if none fn dir_of(path: ptr) -> ptr { var last = 0 - 1 var i = 0 while path[i] != 0 { if path[i] == 47 { last = i }; i = i + 1 } if last < 0 { return "" } return path[0..0 + (last + 1)] } fn path_join(dir: ptr, rel: ptr) -> ptr { if rel[0] == 47 { return rel } # absolute return (dir + rel) } var loaded_paths: []ptr var cur_dir: ptr fn already_loaded(full: ptr) -> bool { var i = 0 while i < len(loaded_paths) { if (loaded_paths[i] == full) { return true }; i = i + 1 } return false } # parse one top-level declaration (or resolve an import) into `prog`. # Modifiers are `@annotations` in front of the declaration: `@export`, `@edge`, # `@pure`, `@deterministic`, … — one channel, not a zoo of prefix keywords. fn parse_one_decl() -> void { var is_export = false var qspec: Node = null var onspawn_model: ptr = null var ondespawn_model: ptr = null var ondespawn_reason: ptr = null # @OnDespawn(M, reason: r) — LC1 teardown reason binding var onattach_prop: ptr = null var ondetach_prop: ptr = null var onenable_prop: ptr = null var ondisable_prop: ptr = null var on_event: ptr = null # @On(Event) — a compile-time event listener var is_public = false # @Public — promote a lifecycle hook to an event var hook_phase: ptr = 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: ptr = null # @Server / @Predicted — a handler's network role (N5) var remote_dir: ptr = null # @ToServer / @ToClients — a remote event's direction (N4) while is_op("@") { pi = pi + 1; let a = eat_id() # collect a leading @annotation if (a == "export") { is_export = true } else { if (a == "Public") { is_public = true } # @Public hook promotion else { if (a == "On") { eat_op("("); on_event = eat_id(); eat_op(")") } # @On(Event) listener else { if (a == "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...) else { if (a == "OnSpawn") { eat_op("("); onspawn_model = eat_id(); eat_op(")") } else { if (a == "OnDespawn") { eat_op("("); ondespawn_model = eat_id() if is_op(",") { pi = pi + 1; eat_id(); eat_op(":"); ondespawn_reason = eat_id() } # , reason: r eat_op(")") } else { if (a == "OnAttach") { eat_op("("); onattach_prop = eat_id(); eat_op(")") } else { if (a == "OnDetach") { eat_op("("); ondetach_prop = eat_id(); eat_op(")") } else { if (a == "OnEnable") { eat_op("("); onenable_prop = eat_id(); eat_op(")") } else { if (a == "OnDisable") { eat_op("("); ondisable_prop = eat_id(); eat_op(")") } else { if (a == "OnStart") { hook_phase = "Start" } # boot else { if (a == "OnQuit") { hook_phase = "OnQuit" } # shutdown else { if (a == "Sync") { is_sync_prop = true } # @Sync property (N2) else { if (a == "Owned") { is_owned = true } # @Owned model (N3) else { if (a == "Server") { role = "server" } # @Server handler (N5) else { if (a == "Predicted") { role = "predicted" } # @Predicted handler (N5) else { if (a == "ToServer") { remote_dir = "toserver" } # @ToServer event (N4) else { if (a == "ToClients") { remote_dir = "toclients" } # @ToClients event (N4) else { if is_op("(") { var d = 0 # any other @anno(args) — parsed and skipped while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } } } } } } } } } } } } } } } } } } } skipnl() } if is_id("import") { pi = pi + 1 let t = toks[pi] if t.kind != TK_STR { perr("expected \"path\" after import") } let rel = t.text; pi = pi + 1 do_import(rel) return } if is_id("enum") { push(prog, parse_enum()); return } if is_id("event") { let ev = parse_event() if (remote_dir != null) { ev.ty = remote_dir } # N4: a directional remote event (RPC) register_event(ev); return } if is_id("property") { let c = parse_component() if is_sync_prop { var fi = 0; while fi < len(c.kids) { c.kids[fi].ival = 1; fi = fi + 1 } } # N2: mark every field replicable push(prog, c); return } if is_id("model") { let m = parse_archetype() if is_owned { m.ival = 1 } # N3: this model's entities carry a network owner push(prog, m); return } if is_id("scene") { parse_scene(); return } # layers push handlers into prog; scene -> g_scenes if is_id("handler") { let h = parse_system() if (role != null) { if (role == "server") { h.ival = 1 } else { h.ival = 2 } } # N5: @Server=1 / @Predicted=2 if (on_event != null) { register_onlisten(on_event, h.a); return } # @On(Event) listener if (onspawn_model != null) { register_onspawn(onspawn_model, h.a) # spawn hook if is_public { ensure_event(`model_{onspawn_model}_spawn`, false) } # @Public -> model__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__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_

_attach if (ondetach_prop != null) { register_ondetach(ondetach_prop, h.a); if is_public { ensure_event(`prop_{ondetach_prop}_detach`, false) }; return } # -> prop_

_detach if (onenable_prop != null) { register_onenable(onenable_prop, h.a); if is_public { ensure_event(`prop_{onenable_prop}_enable`, false) }; return } # -> prop_

_enable if (ondisable_prop != null) { register_ondisable(ondisable_prop, h.a); if is_public { ensure_event(`prop_{ondisable_prop}_disable`, false) }; return } # -> prop_

_disable if (hook_phase != null) { # @OnStart/@OnQuit h.ty = hook_phase if is_public { # -> program_start / program_quit if (hook_phase == "Start") { ensure_event_empty("program_start") } else { ensure_event_empty("program_quit") } } } if (qspec != null) { # @Queries wraps the body in its S_QUERY qspec.a = h.a let wrap = node(N_BLOCK); push(wrap.kids, qspec); h.a = wrap } push(prog, h); return } if is_id("ui") { push(prog, parse_ui()); return } if is_id("var") { push(prog, parse_var()); return } if is_id("const") { push(prog, parse_const()); return } if is_id("fn") { let f = parse_fn(); if is_export { f.ival = 1 }; push(prog, f); return } if is_id("extern") { push(prog, parse_extern()); return } if is_id("entry") { push(prog, parse_main()); return } perr("expected declaration") } # lex and parse an imported fragment into `prog`, saving/restoring lexer state fn do_import(rel: ptr) -> void { let full = path_join(cur_dir, rel) if already_loaded(full) { return } push(loaded_paths, full) let src = read_file(full) if (src == null) { perr(`cannot open import {full}`) } let saved_toks = toks; let saved_pi = pi; let saved_dir = cur_dir cur_dir = dir_of(full) lex(src) # resets the global token stream pi = 0 skipnl() while toks[pi].kind != TK_EOF { parse_one_decl(); skipnl() } toks = saved_toks; pi = saved_pi; cur_dir = saved_dir } # a game (has systems/components) links the Ludic runtime; auto-splice it the # way the C compiler does. Tools (a `main` block, no ECS) get nothing. fn maybe_splice_runtime() -> void { if not has_ecs() { return } let saved = cur_dir cur_dir = "" do_import("runtime/native/core.ludic") cur_dir = saved } fn 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 []ptr loaded_paths = new []ptr skipnl() g_game_name = "Ludic" # imports may precede the program block while is_id("import") { pi = pi + 1; let t = toks[pi]; let rel = t.text; pi = pi + 1; do_import(rel); skipnl() } # @annotations on the program itself (e.g. @Handles(Movement)) — parsed, skipped while is_op("@") { pi = pi + 1; let a = eat_id() if is_op("(") { var d = 0 while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } skipnl() } if is_id("program") { pi = pi + 1; g_game_name = eat_id(); skipnl(); eat_op("{") } while true { skipnl() if toks[pi].kind == TK_EOF { break } if is_op("}") { break } parse_one_decl() } }