ludic/selfhost/parse.ludic
Orkuncakilkaya 86948d2b19 Phase 7k: bytes(n) / words(n) allocators (retire mem_alloc)
Allocation reads as intent, not malloc:

  mem_alloc(64)          -> bytes(64)              (64 bytes -> a byte buffer)
  mem_alloc(w * h * 4)   -> words(w * h)           (w*h 32-bit words)

bytes(n) mallocs n bytes and returns a plain pointer (byte-indexed); words(n)
mallocs n*4 bytes and returns a `words` pointer (int-indexed). Since words(X) and
mem_alloc(X*4) allocate the identical number of bytes, the migration cannot change
any allocation size — the `* 4` factor just moves from the argument into the
allocator name, pairing naturally with the Phase-7j `words` retyping
(`var fb: words = words(w * h)`).

Migrated 102 sites (mem_alloc(E*4) -> words(E), else bytes(E)); deleted the
mem_alloc intrinsic. mem_realloc/free/copy/set stay as the low-level
reallocation/free family. Reseeded (22565 lines); C-free fixpoint holds; goldens
byte-identical; 18/18; vocab + doc-fences clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 02:26:15 +03:00

445 lines
18 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: 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)
os_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() }
fn args_call(call: Node) -> void {
eat_op("("); skipnl()
while not is_op(")") { 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
}
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_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
}
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; 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 == "break") { pi = pi + 1; return node(S_BREAK) }
if (t.text == "continue") { pi = pi + 1; return node(S_CONTINUE) }
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 onattach_prop: ptr = null
var onenable_prop: ptr = null
var ondisable_prop: ptr = null
var hook_phase: ptr = null # @OnStart / @OnQuit override the phase
while is_op("@") {
pi = pi + 1; let a = eat_id() # collect a leading @annotation
if (a == "export") { is_export = true }
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(); eat_op(")") }
else { if (a == "OnAttach") { eat_op("("); onattach_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 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("property") { push(prog, parse_component()); return }
if is_id("model") { push(prog, parse_archetype()); return }
if is_id("handler") {
let h = parse_system()
if (onspawn_model != null) { register_onspawn(onspawn_model, h.a); return } # spawn hook
if (ondespawn_model != null) { register_ondespawn(ondespawn_model, h.a); return } # despawn hook
if (onattach_prop != null) { register_onattach(onattach_prop, h.a); return } # attach hook
if (onenable_prop != null) { register_onenable(onenable_prop, h.a); return } # enable hook
if (ondisable_prop != null) { register_ondisable(ondisable_prop, h.a); return } # disable hook
if (hook_phase != null) { h.ty = hook_phase } # @OnStart/@OnQuit
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_onenable = new []Node
g_ondisable = new []Node
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()
}
}