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 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 16:35:46 +03:00
parent f87964c5f7
commit 9585405dc0
8 changed files with 426 additions and 64 deletions

View file

@ -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.
---

View file

@ -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:

View file

@ -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"

View file

@ -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",

View file

@ -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" }

View file

@ -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" }

View file

@ -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",

View file

@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
/* ---------- 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 */