# 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. # L7: the files that may write `unsafe` - the runtime, a package from the toolchain or # ludic_modules, and what those import from beside them. A project's own files may only # with --unsafe (g_unsafe_all). var g_uses_bytes: bool = false # text_of / Fs.read_bytes / Fs.write_bytes: splice bytes.ludic var g_trusted_files: []pointer = new []pointer var g_unsafe_all: bool = false function unsafe_trusted(f: pointer) -> bool { if g_unsafe_all { return true } var i = 0 while i < len(g_trusted_files) { if (g_trusted_files[i] == f) { return true } i += 1 } return false } 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 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) # L7: the typed buffers are slices - a length, a bounds check on every index, and a place in the # checker - so `floats` is `[]float`, `words` is `[]int`, and so on. `bytes()` stays raw. function buffer_slice_ty(t: pointer) -> pointer { if (t == "floats") { return "[]float" } if (t == "words") { return "[]int" } if (t == "fixeds") { return "[]fixed" } if (t == "doubles") { return "[]double" } if (t == "pointers") { return "[]pointer" } return t } 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 buffer_slice_ty(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", a 'char' or a # `template` in the hole is text, not structure (lex.ludic, hole_end) i = hole_end(raw, i, n) 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; n.s = t.text; pi += 1; return n } # s: a long literal's digits 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 } if (t.text == "text_of") { g_uses_bytes = true } 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 } if g_res_mode and is_op("{") { return record() } # L9: a resource's nested record, typed by its field 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 == "Random" { g_uses_rng = true } # splice the seeded random numbers on demand if e.a.kind == E_ID and e.a.s == "Fs" and (e.s == "read_bytes" or e.s == "write_bytes") { g_uses_bytes = true } 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 rt_wanted_name(c.a.s) { push(g_rt_wants, c.a.s) } # a runtime called by its own name 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 (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 '") } 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 '") } pi += 1; n.a = expr() return n } if (t.text == "unsafe") and (toks[pi + 1].text == "{") { # L7: raw memory allowed inside let un = node(S_UNSAFE) pi += 1 un.a = block() return un } 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(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() if is_op("=") { pi += 1; p.a = expr() } # L11: a default, for a call that leaves it out 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 `_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_rng: bool = false # Random.* -> splice the seeded random numbers (runtime/native/rng.ludic) var g_rt_wants: []pointer = new []pointer # bare names a spliced runtime answers (rng_*, seed, value_*, json_*) 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 ` # 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") { mod_parse_friend() # frontend/modules.ludic return } if is_id("module") and toks[pi + 1].kind == TK_ID { mod_parse_line() # module NAME [uses A, B] 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__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("namespace") { parse_namespace(); return } # #76 namespace block if is_id("port") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_port(); return } # L3 ports if is_id("bind") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_bind(); return } if is_id("open") and (toks[pi + 1].text == "registry") { # L8: open to other modules' defs pi += 1 parse_registry() g_rg_open[len(g_rg_open) - 1] = 1 return } if is_id("registry") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "of") { parse_registry(); return } # L8 if is_id("def") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].kind == TK_ID) { parse_def(); return } # L8 if is_id("view") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_view(); return } # L11 if is_id("component") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_ui_component(); return } # L11 if is_id("var") { push(prog, parse_var()); return } if is_id("const") { push(prog, parse_const()); return } var is_unsafe = false if is_id("unsafe") and (toks[pi + 1].text == "function") { # L7: an unsafe function pi += 1 is_unsafe = true } if is_id("function") { let f = parse_fn() if is_unsafe { f.uns = 1 } 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 is_runtime_path(rel) or not beside or (beside and unsafe_trusted(g_parse_file)) { push(g_trusted_files, full) } if beside and not is_runtime_path(rel) and not (module_of(g_parse_file) == "") { module_set(full, module_of(g_parse_file)) } if not beside and not is_runtime_path(rel) { pkg_file_set(full, pkg_name_of(rel)) } # modules.ludic if beside and not is_runtime_path(rel) and not (pkg_of_file(g_parse_file) == "") { pkg_file_set(full, pkg_of_file(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. # a bare name that only a spliced runtime answers: the runtime comes in when a program calls it # directly (a package calling value_get, a helper calling rng_range), not only through Value.* function rt_wanted_name(n: pointer) -> bool { if (n == "seed") { return true } return str_starts(n, "rng_") or str_starts(n, "value_") or str_starts(n, "json_") } # a wanted name with this prefix that nothing in the program defines function rt_wants_unmet(prefix: pointer) -> bool { var i = 0 while i < len(g_rt_wants) { let n = g_rt_wants[i] if str_starts(n, prefix) and find_fn(n) == null and find_global(n) == null { return true } i += 1 } return false } 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") # L7: the byte buffer's API, when a program reads or writes bytes (it opens files through # the asset pack, and a program that does not should not carry the pack's machinery) if g_uses_bytes { do_import("runtime/native/bytes.ludic") } cur_dir = saved # Random.* (or a bare rng_* / seed nobody else defines) in a program with no ECS: the seeded # random numbers on their own; a game has them through core.ludic if not has_ecs() and (g_uses_rng or rt_wants_unmet("rng_") or rt_wants_unmet("seed")) { cur_dir = "" do_import("runtime/native/rng.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 rt_wants_unmet("value_") or rt_wants_unmet("json_") { g_uses_value = true } # value_get called directly 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_rng = false g_rt_wants = new []pointer 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() } modules_finish() # L3: the declared uses have no cycle registries_finish() # L8: each registry gets its defs ports_finish() # L3: each port gets its one bind views_finish() # L11: each view gets its model and call components_finish() # L11: each component gets its class }