From 9585405dc026d3092dfd23133e5eb26d8ae24ed1 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Thu, 27 Aug 2026 16:35:46 +0300 Subject: [PATCH] Phase 5: anchor the editor vocabulary to the compiler check-vocabulary.py now extracts the keywords selfhost/parse*.ludic actually dispatches on and requires the header's declaration/clause keywords to be a subset (LUDIC_KW_RESERVED exempts documented-but-unimplemented scene/layer/on/ start, itself checked so an implemented reserved word must be promoted). Closes the highlighted-but-unparsed loop against the real compiler; verified it catches an injected bogus keyword. Reconciled the drift it exposed across ludic_syntax.h, JetBrains, TextMate, and emacs: dropped scene/layer/on/start (->RESERVED), the never-implemented needs/uses/requires/ensures/invariant/effects clauses, and the retired edge/export/pure prefixes; added enum/main. @annotations highlight generically. Tooling-only (no seed change): test.sh 14/14, check-vocabulary + test-tools 28/0. Phases 1-5 complete. Co-Authored-By: Claude Opus 4.8 --- SYNTAX-REDESIGN.md | 42 ++- tools/check-vocabulary.py | 29 ++ tools/editors/emacs/ludic-mode.el | 7 +- .../main/kotlin/io/ludic/ide/LudicTokens.kt | 7 +- tools/editors/shared/ludic.tmLanguage.json | 32 +- .../vscode/syntaxes/ludic.tmLanguage.json | 32 +- tools/ludic-tools/ludic_syntax.h | 14 +- tools/ludic-tools/ludic_syntax.h.tmp | 327 ++++++++++++++++++ 8 files changed, 426 insertions(+), 64 deletions(-) create mode 100644 tools/ludic-tools/ludic_syntax.h.tmp diff --git a/SYNTAX-REDESIGN.md b/SYNTAX-REDESIGN.md index a3ea8f16..569a5739 100644 --- a/SYNTAX-REDESIGN.md +++ b/SYNTAX-REDESIGN.md @@ -4,9 +4,11 @@ A plan to make Ludic's syntax internally consistent. It fixes the drift between the spec and the compiler, then unifies the grammar around two rules. Scope: **full redesign (Phases 0–5)**. Named-field direction: **colon everywhere**. -> Status: **Phases 1–3 landed.** Phases 4→5 are proposals. The phases are ordered -> so the documentation never describes syntax the compiler rejects, and every -> phase ends with the compiler still self-hosting to a fixpoint (`./test.sh`). +> Status: **Phases 1–5 complete.** Every phase kept the compiler self-hosting to +> a fixpoint (`./test.sh` 14/14), and each syntax migration was proven +> behaviour-preserving (the migrated compiler compiles itself to byte-identical +> IR; every golden game renders byte-identically). Landed on branch +> `syntax-redesign-phase2` over a committed baseline on `main`. > > Coordinated with the toolchain agent (CLI front-end / `ludicc`+`ludic` > binaries) via serialized reseeds of `selfhost/ludicc.seed.ll`; Phase 1 rode in @@ -316,13 +318,33 @@ addressed by 4a/4b, not by collapsing `if`/`when`. stay free" choice (LANGUAGE.md §Expressions already states this). Promoting them to operators would re-introduce the symbol soup the current design avoids. -### Phase 5 — Single source of truth for keywords/grammar -- Generate every editor plugin keyword list, `ludic_syntax.h`, and the LSP's - token set from **one** canonical list so a keyword can never again be - highlighted but unparsed. -- Add a CI check (extend `tools/check-docs.py` / `check-vocabulary.py`) that - every ` ```ludic ` fence in the docs compiles, closing the doc-drift loop - permanently. +### Phase 5 — Vocabulary anchored to the compiler ✅ DONE +The editor vocabulary already stayed in sync *with itself* (`check-vocabulary.py` +compares `ludic_syntax.h`, the JetBrains lexer, and the TextMate grammar). The +missing anchor was the **compiler**: a keyword could be highlighted everywhere +and still be silently unparsed. Closed both loops: +- ✅ **Vocabulary ⇄ parser.** `check-vocabulary.py` now extracts every keyword + `selfhost/parse*.ludic` dispatches on (`is_id(...)` / `streq(t.text, ...)`) and + requires the header's declaration + clause keywords to be a subset — with a + `LUDIC_KW_RESERVED` escape hatch for documented, not-yet-implemented keywords + (`scene`/`layer`/`on`/`start`), itself checked so a reserved word that gets + implemented must be promoted. Verified it catches an injected bogus keyword. +- ✅ **Reconciled the drift it exposed.** Removed the highlighted-but-unparsed + `scene`/`layer`/`on`/`start` (→ RESERVED) and the never-implemented + `needs`/`uses`/`requires`/`ensures`/`invariant`/`effects` clause words, and the + retired `edge`/`export`/`pure` prefix modifiers, from `ludic_syntax.h`, the + JetBrains lexer, the TextMate grammar, and the emacs mode; added `enum`/`main`. + `@`-annotations already highlight generically (`@[A-Za-z_]…`). test-tools 28/0. +- ✅ **Doc-fence compilation** — the other half of "single source of truth" — was + already live: `check-docs.py` compiles every ` ```ludic ` fence through the + self-hosted `ludicc --fmt` parse gate (revived during Phase 1's coordination). + +Full generation-from-one-list (emit the editor files from a manifest) was not +needed: the bidirectional *checks* give the same guarantee — nothing can drift +without CI failing — without a code-generation step to maintain. + +**Phases 1–5 are complete.** All thirteen findings are resolved or resolved by an +explicit, documented decision. --- diff --git a/tools/check-vocabulary.py b/tools/check-vocabulary.py index 15c452e9..501449fb 100755 --- a/tools/check-vocabulary.py +++ b/tools/check-vocabulary.py @@ -142,6 +142,35 @@ kpath = ["repository", "keyword", "patterns"] compare("phases", h_phases, grammar_alternation(grammar, kpath, "FixedUpdate"), "ludic.tmLanguage.json") compare("primitive types", h_types, grammar_alternation(grammar, kpath, "fixed"), "ludic.tmLanguage.json") +# --- against the actual self-hosted parser --------------------------------- +# The compiler moved to Ludic, so the old C cross-check (above) is dark. This +# replaces it: a declaration or clause keyword the highlighter colours must be +# one the parser actually dispatches on, or nobody notices when a keyword is +# highlighted but silently unparsed (exactly what happened to `scene`, `edge`, +# `needs`, …). RESERVED is the escape hatch for documented, not-yet-implemented +# keywords — and it is checked too, so a reserved word that gets implemented +# must be promoted out of it. +def parser_keywords(): + kws = set() + for name in ("parse.ludic", "parse_game.ludic"): + text = open(os.path.join(ROOT, "selfhost", name), encoding="utf-8").read() + kws |= set(re.findall(r'is_id\("([a-z]+)"\)', text)) + kws |= set(re.findall(r'streq\(t\.text,\s*"([a-z]+)"\)', text)) + return kws + +pkw = parser_keywords() +h_reserved = c_string_list(syntax_h, "LUDIC_KW_RESERVED") +if not pkw: + problems.append("could not extract any keywords from selfhost/parse*.ludic") +else: + for label, kws in (("declaration keywords", h_decl), ("clause keywords", h_clause)): + unparsed = (kws - pkw) - h_reserved + if unparsed: + problems.append(f"ludic_syntax.h lists {label} the selfhost parser never dispatches on: {', '.join(sorted(unparsed))}") + promoted = h_reserved & pkw + if promoted: + problems.append(f"LUDIC_KW_RESERVED lists keywords the parser now accepts — promote them into the highlighted vocabulary: {', '.join(sorted(promoted))}") + if problems: print("vocabulary drift:", file=sys.stderr) for p in problems: diff --git a/tools/editors/emacs/ludic-mode.el b/tools/editors/emacs/ludic-mode.el index d3173b08..f23bcf80 100644 --- a/tools/editors/emacs/ludic-mode.el +++ b/tools/editors/emacs/ludic-mode.el @@ -11,12 +11,11 @@ ;;; Code: (defconst ludic--declaration-keywords - '("game" "module" "import" "component" "struct" "archetype" "enum" "ui" "scene" "layer" - "const" "var" "fn" "extern" "system" "start" "on")) + '("game" "module" "import" "component" "struct" "archetype" "enum" "ui" + "const" "var" "fn" "extern" "system" "main")) (defconst ludic--clause-keywords - '("phase" "query" "reads" "writes" "needs" "uses" - "requires" "ensures" "invariant" "effects")) + '("phase" "query" "reads" "writes")) (defconst ludic--statement-keywords '("let" "return" "if" "else" "when" "while" "for" "in" "spawn" "despawn" diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicTokens.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicTokens.kt index 07bbc017..71dbcf88 100644 --- a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicTokens.kt +++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/LudicTokens.kt @@ -41,12 +41,11 @@ object LudicTokens { */ object LudicVocabulary { val DECL = setOf( - "game", "module", "import", "component", "struct", "archetype", "enum", "ui", "scene", "layer", - "const", "var", "fn", "extern", "system", "start", "on" + "game", "module", "import", "component", "struct", "archetype", "enum", "ui", + "const", "var", "fn", "extern", "system", "main" ) val CLAUSE = setOf( - "phase", "query", "reads", "writes", "needs", "uses", - "requires", "ensures", "invariant", "effects" + "phase", "query", "reads", "writes" ) val STMT = setOf( "let", "return", "if", "else", "when", "while", "for", "in", "spawn", "despawn", diff --git a/tools/editors/shared/ludic.tmLanguage.json b/tools/editors/shared/ludic.tmLanguage.json index b608bfce..22b20a5f 100644 --- a/tools/editors/shared/ludic.tmLanguage.json +++ b/tools/editors/shared/ludic.tmLanguage.json @@ -75,35 +75,25 @@ } }, { - "match": "\\b(scene)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s+start)?", - "captures": { - "1": { "name": "storage.type.scene.ludic" }, - "2": { "name": "entity.name.type.scene.ludic" }, - "3": { "name": "keyword.other.start.ludic" } - } - }, - { - "match": "\\b(layer|ui)\\s+([A-Za-z_][A-Za-z0-9_]*)", + "match": "\\b(ui)\\s+([A-Za-z_][A-Za-z0-9_]*)", "captures": { "1": { "name": "storage.type.ludic" }, "2": { "name": "entity.name.type.ludic" } } }, { - "match": "\\b(edge\\s+)?(system)\\s+([A-Za-z_][A-Za-z0-9_]*)", + "match": "\\b(system)\\s+([A-Za-z_][A-Za-z0-9_]*)", "captures": { - "1": { "name": "storage.modifier.edge.ludic" }, - "2": { "name": "storage.type.system.ludic" }, - "3": { "name": "entity.name.function.system.ludic" } + "1": { "name": "storage.type.system.ludic" }, + "2": { "name": "entity.name.function.system.ludic" } } }, { - "match": "\\b(export\\s+|pure\\s+)?(extern\\s+)?(fn)\\s+([A-Za-z_][A-Za-z0-9_]*)", + "match": "\\b(extern\\s+)?(fn)\\s+([A-Za-z_][A-Za-z0-9_]*)", "captures": { - "1": { "name": "storage.modifier.ludic" }, - "2": { "name": "storage.modifier.extern.ludic" }, - "3": { "name": "storage.type.function.ludic" }, - "4": { "name": "entity.name.function.ludic" } + "1": { "name": "storage.modifier.extern.ludic" }, + "2": { "name": "storage.type.function.ludic" }, + "3": { "name": "entity.name.function.ludic" } } }, { @@ -173,9 +163,9 @@ "patterns": [ { "name": "keyword.control.ludic", "match": "\\b(if|else|when|while|for|in|match|machine|become|enter|return|spawn|despawn|where|break|continue|new)\\b" }, { "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" }, - { "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes|needs|uses|requires|ensures|invariant|effects|on)\\b" }, - { "name": "keyword.other.ludic", "match": "\\b(import|extern|start)\\b" }, - { "name": "storage.type.ludic", "match": "\\b(game|module|component|struct|archetype|enum|ui|scene|layer|const|var|let|fn|system|state)\\b" }, + { "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes)\\b" }, + { "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" }, + { "name": "storage.type.ludic", "match": "\\b(game|module|component|struct|archetype|enum|ui|const|var|let|fn|system|main|state)\\b" }, { "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" }, { "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" }, { "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" } diff --git a/tools/editors/vscode/syntaxes/ludic.tmLanguage.json b/tools/editors/vscode/syntaxes/ludic.tmLanguage.json index b608bfce..22b20a5f 100644 --- a/tools/editors/vscode/syntaxes/ludic.tmLanguage.json +++ b/tools/editors/vscode/syntaxes/ludic.tmLanguage.json @@ -75,35 +75,25 @@ } }, { - "match": "\\b(scene)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s+start)?", - "captures": { - "1": { "name": "storage.type.scene.ludic" }, - "2": { "name": "entity.name.type.scene.ludic" }, - "3": { "name": "keyword.other.start.ludic" } - } - }, - { - "match": "\\b(layer|ui)\\s+([A-Za-z_][A-Za-z0-9_]*)", + "match": "\\b(ui)\\s+([A-Za-z_][A-Za-z0-9_]*)", "captures": { "1": { "name": "storage.type.ludic" }, "2": { "name": "entity.name.type.ludic" } } }, { - "match": "\\b(edge\\s+)?(system)\\s+([A-Za-z_][A-Za-z0-9_]*)", + "match": "\\b(system)\\s+([A-Za-z_][A-Za-z0-9_]*)", "captures": { - "1": { "name": "storage.modifier.edge.ludic" }, - "2": { "name": "storage.type.system.ludic" }, - "3": { "name": "entity.name.function.system.ludic" } + "1": { "name": "storage.type.system.ludic" }, + "2": { "name": "entity.name.function.system.ludic" } } }, { - "match": "\\b(export\\s+|pure\\s+)?(extern\\s+)?(fn)\\s+([A-Za-z_][A-Za-z0-9_]*)", + "match": "\\b(extern\\s+)?(fn)\\s+([A-Za-z_][A-Za-z0-9_]*)", "captures": { - "1": { "name": "storage.modifier.ludic" }, - "2": { "name": "storage.modifier.extern.ludic" }, - "3": { "name": "storage.type.function.ludic" }, - "4": { "name": "entity.name.function.ludic" } + "1": { "name": "storage.modifier.extern.ludic" }, + "2": { "name": "storage.type.function.ludic" }, + "3": { "name": "entity.name.function.ludic" } } }, { @@ -173,9 +163,9 @@ "patterns": [ { "name": "keyword.control.ludic", "match": "\\b(if|else|when|while|for|in|match|machine|become|enter|return|spawn|despawn|where|break|continue|new)\\b" }, { "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" }, - { "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes|needs|uses|requires|ensures|invariant|effects|on)\\b" }, - { "name": "keyword.other.ludic", "match": "\\b(import|extern|start)\\b" }, - { "name": "storage.type.ludic", "match": "\\b(game|module|component|struct|archetype|enum|ui|scene|layer|const|var|let|fn|system|state)\\b" }, + { "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes)\\b" }, + { "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" }, + { "name": "storage.type.ludic", "match": "\\b(game|module|component|struct|archetype|enum|ui|const|var|let|fn|system|main|state)\\b" }, { "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" }, { "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" }, { "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" } diff --git a/tools/ludic-tools/ludic_syntax.h b/tools/ludic-tools/ludic_syntax.h index 296a6988..bf2a3fc4 100644 --- a/tools/ludic-tools/ludic_syntax.h +++ b/tools/ludic-tools/ludic_syntax.h @@ -52,12 +52,18 @@ typedef struct { * mirror the compiler's parser: anything parse_decl() dispatches on is a * declaration keyword, anything stmt() dispatches on is a statement keyword. */ static const char* LUDIC_KW_DECL[] = { - "game","module","import","component","struct","archetype","enum","ui","scene","layer", - "const","var","fn","extern","system","start","on", 0 + "game","module","import","component","struct","archetype","enum","ui", + "const","var","fn","extern","system","main", 0 }; static const char* LUDIC_KW_CLAUSE[] = { - "phase","query","reads","writes","needs","uses", - "requires","ensures","invariant","effects", 0 + "phase","query","reads","writes", 0 +}; +/* Documented design targets the self-hosted parser does not accept yet. Kept + * out of the highlighted vocabulary (they would read as working keywords) until + * they are implemented; check-vocabulary.py verifies the lists above are a + * subset of what selfhost/parse*.ludic actually dispatches on. */ +static const char* LUDIC_KW_RESERVED[] = { + "scene","layer","on","start", 0 }; static const char* LUDIC_KW_STMT[] = { "let","return","if","else","when","while","for","in","spawn","despawn", diff --git a/tools/ludic-tools/ludic_syntax.h.tmp b/tools/ludic-tools/ludic_syntax.h.tmp new file mode 100644 index 00000000..bf2a3fc4 --- /dev/null +++ b/tools/ludic-tools/ludic_syntax.h.tmp @@ -0,0 +1,327 @@ +/* ============================================================================ + * ludic_syntax.h — the lexical layer shared by every Ludic editor tool. + * + * One lexer, one vocabulary. `ludic-fmt`, `ludic-lsp` and the generated + * TextMate grammar all read their keyword/builtin/type tables from here, so an + * addition to the language shows up in every editor at once instead of drifting + * across six hand-maintained copies. + * + * Unlike the compiler's lexer (compiler/ludicc.c) this one is written for + * editors: it keeps comments, keeps newlines, records byte spans for every + * token, and never exits on bad input — a stray character becomes an LT_ERR + * token and lexing continues, because a file being typed into is malformed most + * of the time. + * ==========================================================================*/ +#ifndef LUDIC_SYNTAX_H +#define LUDIC_SYNTAX_H + +#include +#include +#include +#include + +/* ---------- token kinds ---------------------------------------------------*/ +enum { + LT_EOF, LT_NL, LT_COMMENT, + LT_ID, /* a plain identifier */ + LT_KW, /* a reserved word (see KEYWORDS) */ + LT_TYPE, /* a built-in type name: int fixed bool entity str ptr void */ + LT_PHASE, /* Start Input FixedUpdate Update LateUpdate Render */ + LT_BOOL, /* true false */ + LT_INT, LT_FLOAT, LT_STR, LT_CHAR, + LT_ANNO, /* @deterministic — the '@' and the name as one token */ + LT_OP, + LT_ERR +}; + +typedef struct { + int kind; + int start, end; /* byte offsets into the source buffer */ + int line; /* 0-based */ + int bad; /* set on an unterminated string / stray character */ +} LTok; + +typedef struct { + LTok* v; int n, cap; + const char* src; + int* linestart; int nline, caplin; +} LLex; + +/* ---------- the vocabulary ------------------------------------------------*/ +/* Reserved words, grouped so editors can colour them differently. The groups + * mirror the compiler's parser: anything parse_decl() dispatches on is a + * declaration keyword, anything stmt() dispatches on is a statement keyword. */ +static const char* LUDIC_KW_DECL[] = { + "game","module","import","component","struct","archetype","enum","ui", + "const","var","fn","extern","system","main", 0 +}; +static const char* LUDIC_KW_CLAUSE[] = { + "phase","query","reads","writes", 0 +}; +/* Documented design targets the self-hosted parser does not accept yet. Kept + * out of the highlighted vocabulary (they would read as working keywords) until + * they are implemented; check-vocabulary.py verifies the lists above are a + * subset of what selfhost/parse*.ludic actually dispatches on. */ +static const char* LUDIC_KW_RESERVED[] = { + "scene","layer","on","start", 0 +}; +static const char* LUDIC_KW_STMT[] = { + "let","return","if","else","when","while","for","in","spawn","despawn", + "match","machine","state","become","enter","where","and","or","not", + "break","continue","new", 0 +}; +static const char* LUDIC_TYPES[] = { + "int","fixed","bool","entity","str","ptr","void", 0 +}; +static const char* LUDIC_PHASES[] = { + "Start","Input","FixedUpdate","Update","LateUpdate","Render", 0 +}; +static const char* LUDIC_WIDGETS[] = { + "panel","col","row","label","button","image","spacer", 0 +}; +static const char* LUDIC_WIDGET_PROPS[] = { + "id","text","skin","image","align","w","h","pad","gap","size","font", + "inset","grow","bg","fg","border","focus","x","y", 0 +}; + +/* A builtin is a name the runtime provides (`clear(c)` -> rt_clear). An + * intrinsic is a name the compiler lowers directly to libc/OS. Editors treat + * both as "standard library", but the signatures differ, so they stay apart. */ +typedef struct { const char* name; const char* sig; const char* doc; } LBuiltin; + +static const LBuiltin LUDIC_BUILTINS[] = { + {"min","min(a: int, b: int) -> int","Smaller of two integers."}, + {"max","max(a: int, b: int) -> int","Larger of two integers."}, + {"abs","abs(a: int) -> int","Absolute value."}, + {"clamp","clamp(v: int, lo: int, hi: int) -> int","Constrain v to [lo, hi]."}, + {"seed","seed(i: int)","Seed the deterministic RNG."}, + {"rng_range","rng_range(lo: int, hi: int) -> int","Deterministic integer in [lo, hi]."}, + {"rng_chance","rng_chance(pct: int) -> bool","True pct% of the time, deterministically."}, + {"fx","fx(i: int) -> fixed","Widen an int to Q16.16 fixed-point."}, + {"flr","flr(f: fixed) -> int","Truncate a fixed-point value toward zero."}, + {"map_size","map_size(w: int, h: int)","Set the tilemap dimensions."}, + {"map_row","map_row(y: int, row: str)","Fill one tilemap row from a string."}, + {"tile","tile(x: int, y: int) -> int","Tile code at a map cell."}, + {"clear","clear(color: int)","Clear the framebuffer to a 0xRRGGBB colour."}, + {"present","present()","Push the framebuffer to the window (or out.ppm when headless)."}, + {"fill_rect","fill_rect(x: int, y: int, w: int, h: int, color: int)","Filled rectangle."}, + {"frame_rect","frame_rect(x: int, y: int, w: int, h: int, color: int)","One-pixel rectangle outline."}, + {"put_px","put_px(x: int, y: int, color: int)","Write a single pixel."}, + {"text","text(x: int, y: int, s: str, color: int, scale: int)","Draw text with the built-in 5x7 bitmap font."}, + {"text_int","text_int(x: int, y: int, n: int, color: int, scale: int)","Draw an integer with the 5x7 bitmap font."}, + {"font_load","font_load(path: str) -> int","Load a TrueType .ttf/.ttc; returns a font id."}, + {"text_ttf","text_ttf(font: int, x: int, y: int, utf8: str, color: int, px: int)","Draw UTF-8 text with a TrueType font."}, + {"text_w","text_w(font: int, utf8: str, px: int) -> int","Advance width of the string, in pixels."}, + {"text_h","text_h(font: int, px: int) -> int","Line height of the font, in pixels."}, + {"image_load","image_load(path: str) -> int","Decode a PNG into an image id."}, + {"draw_image","draw_image(id: int, x: int, y: int)","Blit an image at its natural size."}, + {"draw_image_scaled","draw_image_scaled(id: int, x: int, y: int, w: int, h: int)","Blit an image stretched to w x h."}, + {"draw_9slice","draw_9slice(id: int, x: int, y: int, w: int, h: int, inset: int)","Nine-slice an image across a w x h box."}, + {"load_png","load_png(path: str) -> int","Decode a PNG as a 16x16 sprite sheet; returns the first sprite id."}, + {"load_sprites","load_sprites(path: str)","Load the sprite sheet used by draw_sprite."}, + {"draw_sprite","draw_sprite(id: int, x: int, y: int)","Blit a sprite."}, + {"draw_sprite_scaled","draw_sprite_scaled(id: int, x: int, y: int, scale: int)","Blit a sprite at an integer scale."}, + {"ui_build","ui_build()","Construct every declared `ui` tree (loads skins and images)."}, + {"ui_open","ui_open(id: int)","Make a ui tree active and focus its first button."}, + {"ui_tick","ui_tick(key: int)","Feed a key to the UI: w/s move focus, space/enter activate."}, + {"ui_render","ui_render()","Lay out and draw the active ui tree."}, + {"ui_clicked","ui_clicked(id: int) -> bool","True on the frame a widget was activated."}, + {"ui_set_text","ui_set_text(id: int, s: str)","Replace a widget's text."}, + {"ui_set_int","ui_set_int(id: int, n: int)","Replace a widget's text with a number."}, + {"ui_focus","ui_focus(id: int)","Move keyboard focus to a widget."}, + {"ui_focused","ui_focused() -> int","Id of the focused widget."}, + {"ui_visible","ui_visible(id: int, on: bool)","Show or hide a widget subtree."}, + {"key","key() -> int","Key code pressed this frame, 0 if none."}, + {"reg","reg(i: int) -> int","Read one of the 64 integer resources shared by systems."}, + {"setreg","setreg(i: int, v: int)","Write one of the 64 integer resources."}, + {"self","self() -> entity","The entity of the innermost query loop."}, + {"save","save()","Write a binary snapshot of the whole ECS world."}, + {"load","load() -> bool","Restore the snapshot; false if there is none."}, + {"status","status(s: str)","Set the one-line status message."}, + {"print_int","print_int(i: int)","Print an integer to stdout."}, + {"quit","quit()","Stop the frame loop and exit."}, + {0,0,0} +}; + +static const LBuiltin LUDIC_INTRINSICS[] = { + {"mem_alloc","mem_alloc(n: int) -> ptr","Allocate n bytes (malloc)."}, + {"mem_realloc","mem_realloc(p: ptr, n: int) -> ptr","Resize a block to n bytes, preserving its contents (realloc)."}, + {"os_argc","os_argc() -> int","Number of command-line arguments, argv[0] included."}, + {"os_arg","os_arg(i: int) -> str","The i-th command-line argument."}, + {"file_stderr","file_stderr() -> ptr","The standard error stream, for file_write."}, + {"mem_free","mem_free(p: ptr)","Release an allocation."}, + {"mem_copy","mem_copy(dst: ptr, src: ptr, n: int)","memcpy."}, + {"mem_set","mem_set(p: ptr, byte: int, n: int)","memset."}, + {"peek8","peek8(p: ptr, off: int) -> int","Read one byte."}, + {"poke8","poke8(p: ptr, off: int, v: int)","Write one byte."}, + {"peek32","peek32(p: ptr, off: int) -> int","Read a 32-bit word."}, + {"poke32","poke32(p: ptr, off: int, v: int)","Write a 32-bit word."}, + {"peekp","peekp(p: ptr, off: int) -> ptr","Read a pointer-sized word."}, + {"pokep","pokep(p: ptr, off: int, v: ptr)","Write a pointer-sized word."}, + {"peekf","peekf(p: ptr, off: int) -> fixed","Read a fixed-point word."}, + {"pokef","pokef(p: ptr, off: int, v: fixed)","Write a fixed-point word."}, + {"ptr_add","ptr_add(p: ptr, off: int) -> ptr","Offset a pointer by bytes."}, + {"ptr_null","ptr_null() -> ptr","The null pointer."}, + {"ptr_is_null","ptr_is_null(p: ptr) -> bool","Null test."}, + {"as_fixed","as_fixed(i: int) -> fixed","Reinterpret an int as fixed (no conversion)."}, + {"as_int","as_int(f: fixed) -> int","Reinterpret a fixed as int (no conversion)."}, + {"file_open","file_open(path: str, mode: str) -> ptr","fopen."}, + {"file_read","file_read(f: ptr, buf: ptr, n: int) -> int","fread."}, + {"file_write","file_write(f: ptr, buf: ptr, n: int) -> int","fwrite."}, + {"file_seek","file_seek(f: ptr, off: int, whence: int) -> int","fseek."}, + {"file_tell","file_tell(f: ptr) -> int","ftell."}, + {"file_close","file_close(f: ptr)","fclose."}, + {"read_byte","read_byte() -> int","Read one byte from stdin, -1 at EOF."}, + {"write_byte","write_byte(b: int)","Write one byte to stdout."}, + {"print_str","print_str(s: str)","Write a string to stdout."}, + {"str_len","str_len(s: str) -> int","Length of a string in bytes."}, + {"shl","shl(v: int, n: int) -> int","Shift left."}, + {"shr","shr(v: int, n: int) -> int","Logical shift right."}, + {"band","band(a: int, b: int) -> int","Bitwise and."}, + {"bor","bor(a: int, b: int) -> int","Bitwise or."}, + {"bxor","bxor(a: int, b: int) -> int","Bitwise xor."}, + {"bnot","bnot(a: int) -> int","Bitwise not."}, + {"os_exit","os_exit(code: int)","Terminate the process."}, + {"os_time","os_time() -> int","Seconds since the epoch."}, + {"is_windowed","is_windowed() -> bool","True when the build has a window."}, + {"game_title","game_title() -> str","The name from the `game` declaration."}, + {"win_open","win_open(title: str, w: int, h: int, scale: int)","Open the platform window."}, + {"win_poll","win_poll() -> int","Pump the event queue; returns a key code."}, + {"win_present","win_present(px: ptr, w: int, h: int)","Blit a framebuffer to the window."}, + {"win_running","win_running() -> bool","False once the window has been closed."}, + {"win_close","win_close()","Close the platform window."}, + {0,0,0} +}; + +static int lud_in(const char** set, const char* s){ + for (int i = 0; set[i]; i++) if (!strcmp(set[i], s)) return 1; + return 0; +} +static const LBuiltin* lud_lookup(const LBuiltin* t, const char* s){ + for (int i = 0; t[i].name; i++) if (!strcmp(t[i].name, s)) return &t[i]; + return 0; +} +static int lud_is_keyword(const char* s){ + return lud_in(LUDIC_KW_DECL, s) || lud_in(LUDIC_KW_CLAUSE, s) || lud_in(LUDIC_KW_STMT, s); +} + +/* ---------- lexing --------------------------------------------------------*/ +static void ltok_push(LLex* L, int kind, int start, int end, int line, int bad){ + if (L->n >= L->cap){ L->cap = L->cap ? L->cap * 2 : 512; L->v = realloc(L->v, L->cap * sizeof(LTok)); } + L->v[L->n++] = (LTok){ kind, start, end, line, bad }; +} +static void lline_push(LLex* L, int off){ + if (L->nline >= L->caplin){ L->caplin = L->caplin ? L->caplin * 2 : 256; L->linestart = realloc(L->linestart, L->caplin * sizeof(int)); } + L->linestart[L->nline++] = off; +} + +static const char* LUDIC_OPS2[] = { "->","+=","-=","*=","/=","==","!=","<=",">=","&&","||","..","=>", 0 }; + +/* Lex the whole buffer. Comments and newlines are kept — the formatter needs + * both, and a highlighter needs the comments. */ +static void lud_lex(LLex* L, const char* src){ + memset(L, 0, sizeof(*L)); + L->src = src; + lline_push(L, 0); + int i = 0, line = 0; + while (src[i]){ + char c = src[i]; + if (c == '\n'){ ltok_push(L, LT_NL, i, i + 1, line, 0); i++; line++; lline_push(L, i); continue; } + if (c == ' ' || c == '\t' || c == '\r'){ i++; continue; } + if (c == '#'){ int s = i; while (src[i] && src[i] != '\n') i++; ltok_push(L, LT_COMMENT, s, i, line, 0); continue; } + if (c == '"'){ + int s = i; i++; int bad = 0; + while (src[i] && src[i] != '"' && src[i] != '\n'){ if (src[i] == '\\' && src[i+1]) i += 2; else i++; } + if (src[i] == '"') i++; else bad = 1; + ltok_push(L, LT_STR, s, i, line, bad); continue; + } + if (c == '\''){ + int s = i; i++; int bad = 0; + if (src[i] == '\\' && src[i+1]) i += 2; else if (src[i] && src[i] != '\n') i++; + if (src[i] == '\'') i++; else bad = 1; + ltok_push(L, LT_CHAR, s, i, line, bad); continue; + } + if (isdigit((unsigned char)c)){ + int s = i; + if (c == '0' && (src[i+1] == 'x' || src[i+1] == 'X')){ + i += 2; while (isxdigit((unsigned char)src[i])) i++; + ltok_push(L, LT_INT, s, i, line, 0); continue; + } + while (isdigit((unsigned char)src[i])) i++; + if (src[i] == '.' && isdigit((unsigned char)src[i+1])){ + i++; while (isdigit((unsigned char)src[i])) i++; + ltok_push(L, LT_FLOAT, s, i, line, 0); continue; + } + ltok_push(L, LT_INT, s, i, line, 0); continue; + } + /* @name is one token: an annotation reads as a unit, and the formatter + * must never put a space between the sigil and the name. */ + if (c == '@' && (isalpha((unsigned char)src[i+1]) || src[i+1] == '_')){ + int s = i; i++; while (isalnum((unsigned char)src[i]) || src[i] == '_') i++; + ltok_push(L, LT_ANNO, s, i, line, 0); continue; + } + if (isalpha((unsigned char)c) || c == '_'){ + int s = i; while (isalnum((unsigned char)src[i]) || src[i] == '_') i++; + int len = i - s; char w[128]; + if (len < (int)sizeof(w)){ memcpy(w, src + s, len); w[len] = 0; } else { w[0] = 0; } + int k = LT_ID; + if (!strcmp(w, "true") || !strcmp(w, "false")) k = LT_BOOL; + else if (lud_in(LUDIC_TYPES, w)) k = LT_TYPE; + else if (lud_in(LUDIC_PHASES, w)) k = LT_PHASE; + else if (lud_is_keyword(w)) k = LT_KW; + ltok_push(L, k, s, i, line, 0); continue; + } + { + int matched = 0; + for (int k = 0; LUDIC_OPS2[k]; k++) + if (src[i] == LUDIC_OPS2[k][0] && src[i+1] == LUDIC_OPS2[k][1]){ + ltok_push(L, LT_OP, i, i + 2, line, 0); i += 2; matched = 1; break; + } + if (matched) continue; + } + if (c == ';'){ ltok_push(L, LT_OP, i, i + 1, line, 0); i++; continue; } + if (strchr("+-*/%<>=(){}[],:.!@", c)){ ltok_push(L, LT_OP, i, i + 1, line, 0); i++; continue; } + /* Anything else is an error token — but a whole UTF-8 character's worth + * at a time. Splitting a multi-byte character into one token per byte + * would let a consumer that re-emits tokens (the formatter) put spaces + * inside it and corrupt the file. */ + { + unsigned char u = (unsigned char)c; + int len = u < 0x80 ? 1 : u < 0xE0 ? 2 : u < 0xF0 ? 3 : 4; + for (int k = 1; k < len; k++) if (!src[i + k] || ((unsigned char)src[i + k] & 0xC0) != 0x80){ len = k; break; } + ltok_push(L, LT_ERR, i, i + len, line, 1); i += len; + } + } + ltok_push(L, LT_EOF, i, i, line, 0); +} +static void lud_lex_free(LLex* L){ free(L->v); free(L->linestart); memset(L, 0, sizeof(*L)); } + +/* ---------- helpers over the token stream --------------------------------*/ +static int ltok_len(const LTok* t){ return t->end - t->start; } +static int ltok_is(const LLex* L, int i, const char* s){ + if (i < 0 || i >= L->n) return 0; + const LTok* t = &L->v[i]; int n = ltok_len(t); + return (int)strlen(s) == n && !strncmp(L->src + t->start, s, n); +} +/* Copy a token's text into a caller buffer; returns buf. */ +static char* ltok_text(const LLex* L, int i, char* buf, int cap){ + const LTok* t = &L->v[i]; int n = ltok_len(t); + if (n >= cap) n = cap - 1; + memcpy(buf, L->src + t->start, n); buf[n] = 0; return buf; +} +/* Index of the next token that is not a newline or comment, or -1. */ +static int ltok_next_sig(const LLex* L, int i){ + for (int j = i + 1; j < L->n; j++){ + int k = L->v[j].kind; + if (k != LT_NL && k != LT_COMMENT) return j; + } + return -1; +} +static int ltok_prev_sig(const LLex* L, int i){ + for (int j = i - 1; j >= 0; j--){ + int k = L->v[j].kind; + if (k != LT_NL && k != LT_COMMENT) return j; + } + return -1; +} +#endif /* LUDIC_SYNTAX_H */