Phase 6b: annotation DSL (@Queries, @Handles) + docs prose pass

@Queries(these: [Prop{constraint}, ...], on: Model) on a handler desugars to the
existing S_QUERY loop: each property binds by its own name, a Prop{...} block
qualifies its bare fields to that property, and on: adds a {Model} tag filter.
Implemented via parse_queries_anno + qualify_fields (parse_game.ludic), wired
into parse_one_decl; @Handles parses on the program (documentation).
examples/annotations.ludic demonstrates it (output 3 1 0 0); test.sh 15/15.

Docs: added the Annotations section to LANGUAGE.md and did the vocabulary prose
pass (component->property, archetype->model, system->handler, game->program)
across the docs. check-docs + test-tools green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 18:49:24 +03:00
parent 69fe39bff1
commit dfc17398cf
10 changed files with 3812 additions and 3113 deletions

View file

@ -110,7 +110,7 @@ All verified. A compiler needs each of these, and each one works today.
| Heap allocation | ✅ | `mem_alloc`, `mem_free`, `mem_copy`, `mem_set`; 1 MiB alloc verified | | Heap allocation | ✅ | `mem_alloc`, `mem_free`, `mem_copy`, `mem_set`; 1 MiB alloc verified |
| Byte-level memory | ✅ | `peek8`/`poke8`, `peek32`/`poke32`, `peekp`/`pokep`, `ptr_add` | | Byte-level memory | ✅ | `peek8`/`poke8`, `peek32`/`poke32`, `peekp`/`pokep`, `ptr_add` |
| `ptr` locals, params, returns | ✅ | `fn make(n: int) -> ptr` | | `ptr` locals, params, returns | ✅ | `fn make(n: int) -> ptr` |
| `ptr` in a component field | ✅ | `component Nd { kind: int = 0, a: ptr = ptr_null() }` | | `ptr` in a property field | ✅ | `property Nd { kind: int = 0, a: ptr = ptr_null() }` |
| String literals as readable bytes | ✅ | `peek8("hello", 1)` → `101` | | String literals as readable bytes | ✅ | `peek8("hello", 1)` → `101` |
| `str` accepted where `ptr` expected | ✅ | `f("A")` into `fn f(p: ptr)` | | `str` accepted where `ptr` expected | ✅ | `f("A")` into `fn f(p: ptr)` |
| String comparison, **hand-written in Ludic** | ✅ | `streq` over `peek8` | | String comparison, **hand-written in Ludic** | ✅ | `streq` over `peek8` |
@ -186,10 +186,10 @@ records. Today there are two workarounds, and both are bad at compiler scale:
what `truetype.ludic` does, and it works, but every field access becomes a what `truetype.ludic` does, and it works, but every field access becomes a
magic number. Across a 6,000-line compiler this is the difference between magic number. Across a 6,000-line compiler this is the difference between
maintainable and not. maintainable and not.
- **ECS entities as nodes** — verified working (`component Nd { kind, a: ptr }`), - **ECS entities as nodes** — verified working (`property Nd { kind, a: ptr }`),
and initially seductive because queries give you free traversal. **Do not do and initially seductive because queries give you free traversal. **Do not do
this.** `LUDIC_MAX_ENT` is 1024 in `native.c:18`; the entity world is a fixed this.** `LUDIC_MAX_ENT` is 1024 in `native.c:18`; the entity world is a fixed
array of per-component storage. A compiler needs hundreds of thousands of array of per-property storage. A compiler needs hundreds of thousands of
nodes. This is a dead end, and it is worth writing down because it is the nodes. This is a dead end, and it is worth writing down because it is the
obvious wrong turn. obvious wrong turn.
@ -211,11 +211,11 @@ No copying, no by-value passing, no nested-struct inlining — a `struct` value
a layout table. a layout table.
**Lowering.** This is largely already built. `native.c` already emits **Lowering.** This is largely already built. `native.c` already emits
`%Cmp_<Name>` LLVM struct types for components and already resolves `%Cmp_<Name>` LLVM struct types for properties and already resolves
`a.b` through `ll_member_addr` with `getelementptr`. A `struct` is a `a.b` through `ll_member_addr` with `getelementptr`. A `struct` is a
`%Cmp_`-style type *without* the parallel entity arrays: `new` is `%Cmp_`-style type *without* the parallel entity arrays: `new` is
`malloc(sizeof)` plus a default-seeding memset/store sequence, and `.field` is `malloc(sizeof)` plus a default-seeding memset/store sequence, and `.field` is
the existing `getelementptr` path. Reusing the component machinery is why this the existing `getelementptr` path. Reusing the property machinery is why this
is far cheaper than it looks. is far cheaper than it looks.
**Cost.** ~250 lines of C across `ludicc.c` (parse) and `native.c` (layout, **Cost.** ~250 lines of C across `ludicc.c` (parse) and `native.c` (layout,
@ -423,7 +423,7 @@ confident nonsense.
| **No silent no-ops** | If the language accepts a construct it must either honour it or reject it. Accepting-and-ignoring teaches a falsehood (see R6 — the worst thing in the audit). | | **No silent no-ops** | If the language accepts a construct it must either honour it or reject it. Accepting-and-ignoring teaches a falsehood (see R6 — the worst thing in the audit). |
| **Recoverable structure** — explicit terminators | A slightly-wrong generation fails *locally*, with an error pointing at the mistake, instead of cascading into a confusing error 40 lines later. | | **Recoverable structure** — explicit terminators | A slightly-wrong generation fails *locally*, with an error pointing at the mistake, instead of cascading into a confusing error 40 lines later. |
| **Locality** — meaning readable from the construct | No action-at-a-distance. Ludic is already strong here; keep it. | | **Locality** — meaning readable from the construct | No action-at-a-distance. Ludic is already strong here; keep it. |
| **Greppable unique anchors** | `component Pos` is findable. Retrieval quality is a language design property. | | **Greppable unique anchors** | `property Pos` is findable. Retrieval quality is a language design property. |
| **Errors that name the fix** | Already partly true: a missing builtin errors naming `rt_<name>`. Extend that everywhere. | | **Errors that name the fix** | Already partly true: a missing builtin errors naming `rt_<name>`. Extend that everywhere. |
**Folklore, and false:** **Folklore, and false:**
@ -452,12 +452,12 @@ Each row verified by compiling a probe, not by reading docs.
| **R1** | **No statement terminator at all.** `block()` is `skipnl(); stmt()` in a loop. A newline *stops* an expression (it lexes as `T_NL`, and `binlevel` only continues on `T_OP`) but is never *required*. `let x = 1 x = x + 1 print_int(x)` on one line is three legal statements — verified compiling. | `ludicc.c` `block()`, `binlevel` | The reader cannot see where a statement ends without re-deriving operator precedence. Blocks error recovery entirely. | | **R1** | **No statement terminator at all.** `block()` is `skipnl(); stmt()` in a loop. A newline *stops* an expression (it lexes as `T_NL`, and `binlevel` only continues on `T_OP`) but is never *required*. `let x = 1 x = x + 1 print_int(x)` on one line is three legal statements — verified compiling. | `ludicc.c` `block()`, `binlevel` | The reader cannot see where a statement ends without re-deriving operator precedence. Blocks error recovery entirely. |
| **R2** | **Commas are optional everywhere.** `if(isop(",")) pi++` appears in `comp()`, `arche()`, `fn` params and `spawn`. `{ x: int = 0 y: int = 0 }` and the comma'd form both compile. | 4 parser sites | Two spellings, zero semantic difference. | | **R2** | **Commas are optional everywhere.** `if(isop(",")) pi++` appears in `comp()`, `arche()`, `fn` params and `spawn`. `{ x: int = 0 y: int = 0 }` and the comma'd form both compile. | 4 parser sites | Two spellings, zero semantic difference. |
| ~~**R3**~~ | ~~**`and`/`or` alias `&&`/`\|\|`.**~~ **RESOLVED** — `and`/`or`/`not` are the only boolean operators; `&&`, `\|\|` and `!` are each rejected with a diagnostic naming the fix, and all three words are reserved. `!=` is unaffected. | landed via S3 | — | | ~~**R3**~~ | ~~**`and`/`or` alias `&&`/`\|\|`.**~~ **RESOLVED** — `and`/`or`/`not` are the only boolean operators; `&&`, `\|\|` and `!` are each rejected with a diagnostic naming the fix, and all three words are reserved. `!=` is unaffected. | landed via S3 | — |
| **R4** | **`{ }` means seven different things** — statement block; component fields (`n: T = e`); archetype list (bare idents); spawn initialisers (`N = { … }`); ui props + children (`k=v` juxtaposed, no commas); match arms (`p, p => …`); machine states (`state N = v { … }`). | `block/comp/arche/spawn/parse_widget/match/machine` | The delimiter carries no information. You must already know the head keyword to know the inner grammar. | | **R4** | **`{ }` means seven different things** — statement block; property fields (`n: T = e`); model list (bare idents); spawn initialisers (`N = { … }`); ui props + children (`k=v` juxtaposed, no commas); match arms (`p, p => …`); machine states (`state N = v { … }`). | `block/comp/arche/spawn/parse_widget/match/machine` | The delimiter carries no information. You must already know the head keyword to know the inner grammar. |
| **R5** | **Contextual keywords, not reserved.** `phase`, `query`, `reads`, `writes`, `needs`, `uses`, `where`, `in`, `on`, `layer`, `state`, `start` are matched with `isid()` — ordinary identifiers. `let query = 5 let phase = 6` compiles and prints `11`. | `sys()`, `scene_decl()` | A local named `enter` or `match` produces a baffling error far from the cause. | | **R5** | **Contextual keywords, not reserved.** `phase`, `query`, `reads`, `writes`, `needs`, `uses`, `where`, `in`, `on`, `layer`, `state`, `start` are matched with `isid()` — ordinary identifiers. `let query = 5 let phase = 6` compiles and prints `11`. | `sys()`, `scene_decl()` | A local named `enter` or `match` produces a baffling error far from the cause. |
| **R6** | **Contracts are parsed and thrown away.** `requires`/`ensures`/`invariant` parse an expression and **discard it** (`pi++; expr();`). `reads`/`writes`/`needs`/`uses`/`effects` are `skip_brackets()`. `pure` is consumed and ignored. Verified: `fn half(n: int) -> int requires n > 100000 ensures false` compiles, and `half(8)` returns `4`. Verified: a system declaring `reads [Pos]` that **writes** `p.x = 99` compiles. | `fn()`, `sys()` | **The worst item in the audit.** The language accepts a contract and does nothing. A model writing `requires n > 0` is rewarded with a clean compile and zero enforcement — it learns a lie, and so does a human reader trusting the annotation. | | **R6** | **Contracts are parsed and thrown away.** `requires`/`ensures`/`invariant` parse an expression and **discard it** (`pi++; expr();`). `reads`/`writes`/`needs`/`uses`/`effects` are `skip_brackets()`. `pure` is consumed and ignored. Verified: `fn half(n: int) -> int requires n > 100000 ensures false` compiles, and `half(8)` returns `4`. Verified: a system declaring `reads [Pos]` that **writes** `p.x = 99` compiles. | `fn()`, `sys()` | **The worst item in the audit.** The language accepts a contract and does nothing. A model writing `requires n > 0` is rewarded with a clean compile and zero enforcement — it learns a lie, and so does a human reader trusting the annotation. |
| **R7** | **`str + str` typechecks, then emits invalid IR.** | verified (§4 B2) | The front-end accepts what the backend cannot lower. | | **R7** | **`str + str` typechecks, then emits invalid IR.** | verified (§4 B2) | The front-end accepts what the backend cannot lower. |
| **R8** | **Two formatters, opposite philosophies, both called "format".** `ludicc --fmt` canonicalises hard (one statement per line, `and`→`&&`, full parenthesisation) but drops comments and inlines imports. `ludic-fmt` is token-based and preserves comments — but **normalises nothing**: handed the one-line `let a = 1 a = a + 1 if true and false { … }`, it returned it unchanged. | verified side-by-side | **Neither tool enforces a single spelling.** The canonicaliser is unusable on real source; the source formatter has no opinion. | | **R8** | **Two formatters, opposite philosophies, both called "format".** `ludicc --fmt` canonicalises hard (one statement per line, `and`→`&&`, full parenthesisation) but drops comments and inlines imports. `ludic-fmt` is token-based and preserves comments — but **normalises nothing**: handed the one-line `let a = 1 a = a + 1 if true and false { … }`, it returned it unchanged. | verified side-by-side | **Neither tool enforces a single spelling.** The canonicaliser is unusable on real source; the source formatter has no opinion. |
| **R9** | **Two ways to spell a tag** — `component Player { }` (empty component) or `archetype`. | LANGUAGE.md | | | **R9** | **Two ways to spell a tag** — `property Player { }` (empty property) or `model`. | LANGUAGE.md | |
| **R10** | **Stale docs are stale training data.** LANGUAGE.md still says "the current compiler is a tree-to-C translator" (it emits LLVM IR) and lists arrays under "Not yet implemented" beside things never planned. | LANGUAGE.md | Docs are the highest-leverage model input in the repo. A wrong doc is worse than a missing one. | | **R10** | **Stale docs are stale training data.** LANGUAGE.md still says "the current compiler is a tree-to-C translator" (it emits LLVM IR) and lists arrays under "Not yet implemented" beside things never planned. | LANGUAGE.md | Docs are the highest-leverage model input in the repo. A wrong doc is worse than a missing one. |
### 5.4 Proposals ### 5.4 Proposals
@ -497,7 +497,7 @@ point of the mistake. *Fixes R5. Cost:* ~40 lines.
section.** Two honest options per construct, no third: section.** Two honest options per construct, no third:
- `reads` / `writes`: **implement them.** The compiler already knows every - `reads` / `writes`: **implement them.** The compiler already knows every
component a system touches — it builds the query and walks the body. Checking property a system touches — it builds the query and walks the body. Checking
the declaration against actual access is a genuine static analysis the the declaration against actual access is a genuine static analysis the
language claims to have and doesn't. This converts dead syntax into a real language claims to have and doesn't. This converts dead syntax into a real
guarantee, which is exactly what an "AI-first" language should offer a model guarantee, which is exactly what an "AI-first" language should offer a model
@ -542,7 +542,7 @@ Recording these so they are not relitigated:
- **Braces, not indentation** (§5.2). - **Braces, not indentation** (§5.2).
- **`#` comments** — unambiguous, one spelling already. - **`#` comments** — unambiguous, one spelling already.
- **The ECS vocabulary** — `component` / `system` / `query` / `phase` are - **The ECS vocabulary** — `property` / `system` / `query` / `phase` are
unusually self-describing and greppable. This is the language's best existing unusually self-describing and greppable. This is the language's best existing
readability asset. readability asset.
- **`fixed` / Q16.16** — determinism is a design constraint, not a style choice. - **`fixed` / Q16.16** — determinism is a design constraint, not a style choice.
@ -598,7 +598,7 @@ The self-host compiler (`selfhost/`) implements the **compiler-subset**: `struct
(reference), `[]T` slices with `push`/`len`, functions, a plain `main` entry, (reference), `[]T` slices with `push`/`len`, functions, a plain `main` entry,
the full control flow, the operators (with short-circuit `and`/`or`), and the the full control flow, the operators (with short-circuit `and`/`or`), and the
low-level intrinsics. It deliberately does **not** implement the game half of low-level intrinsics. It deliberately does **not** implement the game half of
Ludic — ECS, queries, archetypes, scenes, UI, save/load, `match`/`machine`, Ludic — ECS, queries, models, scenes, UI, save/load, `match`/`machine`,
fixed-point. It targets native (macOS/clang) and emits LLVM IR text that clang fixed-point. It targets native (macOS/clang) and emits LLVM IR text that clang
assembles, exactly the posture the C `ludicc` has. assembles, exactly the posture the C `ludicc` has.
@ -634,8 +634,8 @@ compiler is now a historical seed, not a dependency.
### Stage 4+ — `ludicc.c` is deleted ### Stage 4+ — `ludicc.c` is deleted
The self-host compiler was extended to the **whole** language — components, The self-host compiler was extended to the **whole** language — properties,
archetypes, systems, phases, `for … in query` (with `where`), spawn/despawn, models, systems, phases, `for … in query` (with `where`), spawn/despawn,
`self()`, `machine`/`become`, `match`, `save`/`load` snapshots, the retained `self()`, `machine`/`become`, `match`, `save`/`load` snapshots, the retained
`ui` widget tree, multi-file `import`, fixed-point Q16.16, and every runtime `ui` widget tree, multi-file `import`, fixed-point Q16.16, and every runtime
intrinsic. It auto-splices the Ludic runtime exactly as the C compiler did. intrinsic. It auto-splices the Ludic runtime exactly as the C compiler did.
@ -791,7 +791,7 @@ existing framing ("the same floor Rust and Swift stand on") already covers it.
## 8. Risks and gotchas ## 8. Risks and gotchas
- **Do not build the AST out of ECS entities.** `LUDIC_MAX_ENT` is 1024 - **Do not build the AST out of ECS entities.** `LUDIC_MAX_ENT` is 1024
(`native.c:18`) and component storage is fixed arrays. It compiles, it looks (`native.c:18`) and property storage is fixed arrays. It compiles, it looks
elegant, and it caps the compiler at 1024 nodes. Use `struct` (A2). elegant, and it caps the compiler at 1024 nodes. Use `struct` (A2).
- **Do not inherit the C compiler's fixed caps.** `ludicc.c:22` has - **Do not inherit the C compiler's fixed caps.** `ludicc.c:22` has
`g_srcpath[128]`; `native.c:161` has `Val a[8]`. The Ludic port should grow `g_srcpath[128]`; `native.c:161` has `Val a[8]`. The Ludic port should grow
@ -954,7 +954,7 @@ handler Violate phase Update reads [Pos] query (p) [Pos] { p.x = 99 }
``` ```
**R8 — the two formatters disagree about what "format" means.** Given **R8 — the two formatters disagree about what "format" means.** Given
`component Pos { x: int = 0 y: int = 0 }` and a multi-statement one-liner, `property Pos { x: int = 0 y: int = 0 }` and a multi-statement one-liner,
`ludicc --fmt` rewrites both (one statement per line, `and`→`&&`, full `ludicc --fmt` rewrites both (one statement per line, `and`→`&&`, full
parenthesisation) while `ludic-fmt` returns the input **unchanged**. parenthesisation) while `ludic-fmt` returns the input **unchanged**.

View file

@ -37,7 +37,7 @@ and find your program rewritten in another language.
app.ludic app.ludic
│ ludicc — lex, parse, check, lower (compiler/ludicc.c, │ ludicc — lex, parse, check, lower (compiler/ludicc.c,
▼ compiler/native.c) ▼ compiler/native.c)
app.ll LLVM IR: your systems, your components, your runtime app.ll LLVM IR: your systems, your properties, your runtime
│ IR assembler (compiler/driver.c) │ IR assembler (compiler/driver.c)
▼ ▼
app.o Mach-O / ELF / COFF object code app.o Mach-O / ELF / COFF object code
@ -302,8 +302,8 @@ asserts exactly that, which is a much stronger check on the backend than
## What a build contains ## What a build contains
Everything: components and archetypes, spawn/despawn, queries with bindings, Everything: properties and models, spawn/despawn, queries with bindings,
`where` filters and archetype filters, `match`, `machine`/`become`, `where` filters and model filters, `match`, `machine`/`become`,
`scene`/`layer`/`enter`, module state (`var`), `const`, int and Q16.16 `scene`/`layer`/`enter`, module state (`var`), `const`, int and Q16.16
fixed-point arithmetic, control flow, functions, `extern fn` FFI, strings, the fixed-point arithmetic, control flow, functions, `extern fn` FFI, strings, the
entity allocator, save/load snapshots, the frame loop, the window, and the whole entity allocator, save/load snapshots, the frame loop, the window, and the whole

View file

@ -12,11 +12,11 @@ program.ludic ──ludicc──▶ program.ll ──▶ program.o ──▶ nat
`ludicc` lowers Ludic to **LLVM IR itself** and links the result — see `ludicc` lowers Ludic to **LLVM IR itself** and links the result — see
[COMPILING.md](COMPILING.md) for the pipeline, `module`/`export`, and [COMPILING.md](COMPILING.md) for the pipeline, `module`/`export`, and
cross-targets. There is one backend: no C is generated, compiled or linked at cross-targets. There is one backend: no C is generated, compiled or linked at
any point, and the runtime a game calls is itself written in Ludic. any point, and the runtime a program calls is itself written in Ludic.
## Program structure ## Program structure
A program is one `game` block containing declarations: A program is one `program` block containing declarations:
```ludic ```ludic
# doc-check: skip — illustrative: elided import list # doc-check: skip — illustrative: elided import list
@ -24,7 +24,7 @@ program Name {
import ... # pull declarations in from another file import ... # pull declarations in from another file
property ... # data (per entity) property ... # data (per entity)
struct ... # a plain record, not tied to an entity struct ... # a plain record, not tied to an entity
model ... # a named entity KIND (bundle of components) model ... # a named entity KIND (bundle of properties)
const ... # compile-time constants const ... # compile-time constants
fn ... # functions fn ... # functions
extern fn ... # bind a C library symbol (FFI) extern fn ... # bind a C library symbol (FFI)
@ -42,21 +42,21 @@ program ChronoRift {
} }
``` ```
An imported file is a **fragment**: bare declarations, no `game` wrapper. Its An imported file is a **fragment**: bare declarations, no `program` wrapper. Its
declarations are spliced into the importing program. Imports may appear inside declarations are spliced into the importing program. Imports may appear inside
the `game` block or before it, they may nest (a fragment may import fragments), the `program` block or before it, they may nest (a fragment may import fragments),
and each resolved path is **include-guarded**, so importing the same file twice and each resolved path is **include-guarded**, so importing the same file twice
(even via different chains) pulls it in once. Diagnostics report the true file: (even via different chains) pulls it in once. Diagnostics report the true file:
``` ```
error: line 1: unknown type 'nope' for field Pos.x error: line 1: unknown type 'nope' for field Pos.x
chronorift/world.ludic:1 | component Pos { x: nope = 0 } chronorift/world.ludic:1 | property Pos { x: nope = 0 }
``` ```
## Archetypes (entity kinds) ## Models (entity kinds)
An `archetype` names a *kind* of entity and the fixed set of components it An `model` names a *kind* of entity and the fixed set of properties it
carries. It replaces the empty "tag component" idiom: identity is stored as one carries. It replaces the empty "tag property" idiom: identity is stored as one
integer per entity, not a parallel boolean array. integer per entity, not a parallel boolean array.
```ludic ```ludic
@ -64,15 +64,15 @@ integer per entity, not a parallel boolean array.
property Pos { x: int = 0, y: int = 0 } property Pos { x: int = 0, y: int = 0 }
property Stats { hp: int = 10 } property Stats { hp: int = 10 }
model Player { Pos, Stats } # Player IS a kind, not a component model Player { Pos, Stats } # Player IS a kind, not a property
model Enemy { Pos, Stats } model Enemy { Pos, Stats }
spawn Player { Pos { x: 5 } } # attaches every listed component spawn Player { Pos { x: 5 } } # attaches every listed property
# (seeding field defaults), then overrides # (seeding field defaults), then overrides
for (p, s) in query [Pos, Stats, {Player}] { ... } # {Player} filters by kind for (p, s) in query [Pos, Stats, {Player}] { ... } # {Player} filters by kind
``` ```
Use `{Name}` (tag position) to filter a query by archetype — an archetype can't Use `{Name}` (tag position) to filter a query by model — an model can't
be *bound* to a variable since it has no fields of its own. Entity kind is part be *bound* to a variable since it has no fields of its own. Entity kind is part
of the saved snapshot. of the saved snapshot.
@ -82,7 +82,7 @@ The 5×7 bitmap `text` stays for zero-asset programs. For real typography, load
TrueType font and draw UTF-8: TrueType font and draw UTF-8:
```ludic ```ludic
let f = font_load("/System/Library/Fonts/Supplemental/Arial.ttf") let f = font_load("/Handler/Library/Fonts/Supplemental/Arial.ttf")
text_ttf(f, 20, 20, "Héllo — Καλημέρα — Привет", 0xffffff, 28) # anti-aliased text_ttf(f, 20, 20, "Héllo — Καλημέρα — Привет", 0xffffff, 28) # anti-aliased
let w = text_w(f, "measure me", 28) # pixel width let w = text_w(f, "measure me", 28) # pixel width
``` ```
@ -115,9 +115,9 @@ ui MainMenu {
Widget types: `panel` (container + optional skin/bg/border), `col` / `row` Widget types: `panel` (container + optional skin/bg/border), `col` / `row`
(pure stacks), `label`, `button` (focusable), `image`, `spacer`. Props are (pure stacks), `label`, `button` (focusable), `image`, `spacer`. Props are
evaluated at build time, so `font: reg(R_FONT)` reads a value the game set first. evaluated at build time, so `font: reg(R_FONT)` reads a value the program set first.
Each `id: Name` mints a `UI_Name` handle (the `ui` block name too), used from Each `id: Name` mints a `UI_Name` handle (the `ui` block name too), used from
systems: handlers:
```ludic ```ludic
handler Boot phase Start { handler Boot phase Start {
@ -151,7 +151,7 @@ point (`1.5`) is `fixed`. Arithmetic on two `fixed` values lowers to
`fxmul`/`fxdiv`; mixing `int` and `fixed` promotes the `int`. Convert with `fxmul`/`fxdiv`; mixing `int` and `fixed` promotes the `int`. Convert with
`fx(i)` (int→fixed) and `flr(f)` (fixed→int). `fx(i)` (int→fixed) and `flr(f)` (fixed→int).
## Components, entities, queries ## Properties, entities, queries
```ludic ```ludic
# doc-check: skip — composite: declarations and statements together # doc-check: skip — composite: declarations and statements together
@ -164,39 +164,39 @@ spawn Hero { # create an entity
} }
despawn self() # remove the current entity despawn self() # remove the current entity
# iterate every entity that has all listed components: # iterate every entity that has all listed properties:
for (p) in query [Pos, {Player}] { p.x = p.x + 1 } # {Tag} filters, doesn't bind for (p) in query [Pos, {Player}] { p.x = p.x + 1 } # {Tag} filters, doesn't bind
for (a, b) in query [Pos, Vel] where a.x > 0 { ... } # one var per non-tag term for (a, b) in query [Pos, Vel] where a.x > 0 { ... } # one var per non-tag term
``` ```
Entities are integer handles; component storage and slot reuse are generated per Entities are integer handles; property storage and slot reuse are generated per
program. `self()` yields the entity of the innermost `query` loop. program. `self()` yields the entity of the innermost `query` loop.
## Systems & phases ## Handlers & phases
```ludic ```ludic
handler Move @deterministic handler Move @deterministic
reads [Vel] # declared data access (parsed and reserved; not yet reads [Vel] # declared data access (parsed and reserved; not yet
writes [Pos] # consumed by any analysis pass — see "Not yet implemented") writes [Pos] # consumed by any analysis pass — see "Not yet implemented")
phase FixedUpdate phase FixedUpdate
query (p, v) [Pos, Vel] # the entities this system operates on query (p, v) [Pos, Vel] # the entities this handler operates on
{ p.x = p.x + v.dx } { p.x = p.x + v.dx }
``` ```
Phases run in this order every frame: **`Start`** (once at boot), then each Phases run in this order every frame: **`Start`** (once at boot), then each
frame **`Input` → `FixedUpdate` → `Update` → `LateUpdate` → `Render`**. frame **`Input` → `FixedUpdate` → `Update` → `LateUpdate` → `Render`**.
`@edge` in front of a `system` marks one that touches the outside world. `@edge` in front of a `handler` marks one that touches the outside world.
Declaration modifiers are `@annotations` written in front of the declaration — Declaration modifiers are `@annotations` written in front of the declaration —
`@export fn …` (a C-ABI-exported function), `@edge system …`, `@deterministic`, `@export fn …` (a C-ABI-exported function), `@edge handler …`, `@deterministic`,
`@pure`. They parse into one uniform channel rather than a set of prefix `@pure`. They parse into one uniform channel rather than a set of prefix
keywords. (`@export` sets the export flag; the others parse but have no codegen keywords. (`@export` sets the export flag; the others parse but have no codegen
effect in the self-hosted compiler yet.) effect in the self-hosted compiler yet.)
### The `query` clause ### The `query` clause
A system declares the entities it works on, alongside its phase. The body then A handler declares the entities it works on, alongside its phase. The body then
runs **once per matching entity**, with the components bound and `self()` giving runs **once per matching entity**, with the properties bound and `self()` giving
that entity — the query header is simply hoisted out of the body into the that entity — the query header is simply hoisted out of the body into the
signature: signature:
@ -214,27 +214,27 @@ handler CleanBattle phase LateUpdate {
} }
``` ```
Drop `(vars)` when nothing binds: `query [{Enemy}]`. A system declares at most Drop `(vars)` when nothing binds: `query [{Enemy}]`. A handler declares at most
one query, and the number of variables must equal the number of binding terms one query, and the number of variables must equal the number of binding terms
(`{Tag}` terms filter without binding, so they don't count). A system with no (`{Tag}` terms filter without binding, so they don't count). A handler with no
`query` clause runs once per tick, as before. `query` clause runs once per tick, as before.
### Conditions ### Conditions
A query selects on more than *which* components an entity has. `where` is an A query selects on more than *which* properties an entity has. `where` is an
ordinary expression evaluated with the bindings in scope, so entities can be ordinary expression evaluated with the bindings in scope, so entities can be
matched on their field values: matched on their field values:
```ludic ```ludic
# doc-check: skip — a bare system clause, not a whole declaration # doc-check: skip — a bare handler clause, not a whole declaration
query (b, s) [Battle, Stats] where b.hp <= 0 and s.level > 3 query (b, s) [Battle, Stats] where b.hp <= 0 and s.level > 3
``` ```
The same `where` works on an inline `for (…) in query […]`. The same `where` works on an inline `for (…) in query […]`.
`where` is evaluated **per candidate entity**, so it is the wrong place for a `where` is evaluated **per candidate entity**, so it is the wrong place for a
guard that concerns the whole system (`where reg(R_MODE) != 1` would re-read the guard that concerns the whole handler (`where reg(R_MODE) != 1` would re-read the
register for every entity). Keep whole-system guards in the body of a system register for every entity). Keep whole-handler guards in the body of a handler
with no `query` clause, wrapping an inline query — as `CleanBattle` does in with no `query` clause, wrapping an inline query — as `CleanBattle` does in
`examples/chronorift/combat.ludic`. `examples/chronorift/combat.ludic`.
@ -247,6 +247,46 @@ there is no per-tick array of matched entities. Consequences worth knowing:
* An entity **spawned during the loop at a higher id is visited in the same * An entity **spawned during the loop at a higher id is visited in the same
tick**. Spawn into a later phase if you don't want that. tick**. Spawn into a later phase if you don't want that.
## Annotations
Declarations carry `@annotations` in front of them — `@export`, `@edge`, `@pure`,
`@deterministic` — one uniform channel rather than a set of prefix keywords. Two
annotations replace a clause with a decorator.
**`@Queries` — a handler's query as a decorator.** Instead of the `query (v) […]`
clause, a handler annotates its query, with each property's constraints written
inline and the model given as `on:`:
```ludic
# doc-check: skip — composite: a handler plus its property/model declarations
property Transform { x: int = 0, scale: int = 1 }
property Velocity { dx: int = 0, dy: int = 0 }
model Actor { Transform, Velocity }
@Queries(these: [Transform{scale > 0}, Velocity{dx > 0 or dy > 0}], on: Actor)
handler Move phase Update {
Transform.x = Transform.x + Velocity.dx # each property is bound by its name
}
```
It desugars to the ordinary loop
```ludic
# doc-check: skip — the desugaring of the @Queries above
for (Transform, Velocity) in query [Transform, Velocity, {Actor}]
where Transform.scale > 0 and (Velocity.dx > 0 or Velocity.dy > 0) { … }
```
— each listed property becomes a binding **named after itself**, a
`Prop{constraint}` block reads its bare names as fields of `Prop`, and `on: Model`
adds a `{Model}` tag filter. The body runs once per matching entity.
**`@Handles` — the handlers a program drives.** Written in front of the
`program`, `@Handles(Move)` names the handlers it uses. It parses and reads as
documentation; every declared handler still runs (registration is implicit).
See [`examples/annotations.ludic`](examples/annotations.ludic).
## Structs, arrays and slices ## Structs, arrays and slices
`struct` is the aggregate that is *not* tied to an entity — a plain record, for `struct` is the aggregate that is *not* tied to an entity — a plain record, for
@ -442,7 +482,7 @@ ints (`0xff8800`).
# ui_focus(id) ui_focused()->int ui_visible(id,bool) # ui_focus(id) ui_focused()->int ui_visible(id,bool)
# assets load_png(path)->id (decodes a PNG; returns a 16x16 sprite id) # assets load_png(path)->id (decodes a PNG; returns a 16x16 sprite id)
# input key()->int (current frame's key code, 0 if none) # input key()->int (current frame's key code, 0 if none)
# state reg(i)->int setreg(i,v) (64 integer resources shared by systems) # state reg(i)->int setreg(i,v) (64 integer resources shared by handlers)
# entity self()->entity # entity self()->entity
# save save() load()->bool (binary snapshot of the whole ECS World) # save save() load()->bool (binary snapshot of the whole ECS World)
# control quit() print_int(i) # control quit() print_int(i)
@ -453,10 +493,10 @@ ints (`0xff8800`).
## Tooling ## Tooling
```bash ```bash
ludicc game.ludic -o build/game # native binary (windowed for a game) ludicc app.ludic -o build/app # native binary (windowed for a game)
ludicc game.ludic --headless -o g # headless build (renders out.ppm; reads stdin) ludicc app.ludic --headless -o app # headless build (renders out.ppm; reads stdin)
ludicc game.ludic --emit-llvm -o g.ll # stop at LLVM IR ludicc app.ludic --emit-llvm -o app.ll # stop at LLVM IR
ludic game.ludic # compile AND run (forwards the exit code) ludic app.ludic # compile AND run (forwards the exit code)
``` ```
`ludicc` (compile) and `ludic` (compile-and-run) are one multi-call binary built `ludicc` (compile) and `ludic` (compile-and-run) are one multi-call binary built
@ -464,7 +504,7 @@ by `./build-cli.sh`. **[COMPILING.md](COMPILING.md) is the authoritative CLI
reference** — the full flag set (`-o`, `--windowed`, `--headless`, `--emit-llvm`, reference** — the full flag set (`-o`, `--windowed`, `--headless`, `--emit-llvm`,
`--save-temps`, `--run`), the `LUDIC_HOME` / `LUDIC_CC` environment variables, `--save-temps`, `--run`), the `LUDIC_HOME` / `LUDIC_CC` environment variables,
and the IR-to-stdout bootstrap contract (no `-o`, invoked as `ludicc`) that and the IR-to-stdout bootstrap contract (no `-o`, invoked as `ludicc`) that
`build.sh` / `reseed.sh` rely on. The default mode is auto: a file with `system`s `build.sh` / `reseed.sh` rely on. The default mode is auto: a file with `handler`s
links windowed, otherwise headless; an explicit flag always wins. links windowed, otherwise headless; an explicit flag always wins.
The retired C driver's `--shared`, `--fmt`, `-c`, cross-compile (`--target`) and The retired C driver's `--shared`, `--fmt`, `-c`, cross-compile (`--target`) and
@ -502,7 +542,7 @@ Emacs, Sublime and Zed, are in `tools/editors/` — see
- `examples/chronorift.ludic` — a co-op JRPG (overworld, dungeon, boss, shop, - `examples/chronorift.ludic` — a co-op JRPG (overworld, dungeon, boss, shop,
save) using CC0 Kenney sprites. Split across `chronorift/*.ludic` via `import`, save) using CC0 Kenney sprites. Split across `chronorift/*.ludic` via `import`,
built on archetypes. built on models.
- `examples/menu.ludic` — a retained-UI title screen (9-slice panel, TrueType - `examples/menu.ludic` — a retained-UI title screen (9-slice panel, TrueType
labels, focusable buttons). labels, focusable buttons).
- `examples/snake.ludic` — Snake, no assets — same compiler, proving generality. - `examples/snake.ludic` — Snake, no assets — same compiler, proving generality.
@ -519,7 +559,7 @@ are future work.
- **`scene` / `layer` / `on enter` / `on exit`** — the state-machine-over-scenes - **`scene` / `layer` / `on enter` / `on exit`** — the state-machine-over-scenes
sugar is documented above but not parsed by the self-hosted compiler yet. sugar is documented above but not parsed by the self-hosted compiler yet.
- **`reads` / `writes` clauses** — parsed and reserved on the system node, but no - **`reads` / `writes` clauses** — parsed and reserved on the handler node, but no
analysis pass consumes them. analysis pass consumes them.
- **`[T; N]` fixed arrays** — documented above, but `ptype` parses only `[]T` - **`[T; N]` fixed arrays** — documented above, but `ptype` parses only `[]T`
slices; fixed inline arrays are not accepted yet. Use `[]T` slices. slices; fixed inline arrays are not accepted yet. Use `[]T` slices.
@ -542,9 +582,9 @@ as near-term — are now implemented and self-hosting; their lowerings are in
> `machine`, as `examples/chronorift` does. This section describes the intended > `machine`, as `examples/chronorift` does. This section describes the intended
> syntax for when scene support lands. > syntax for when scene support lands.
A game is usually several mutually-exclusive states — a title screen, the A program is usually several mutually-exclusive states — a title screen, the
overworld, a battle — and the usual way to write that is a mode register overworld, a battle — and the usual way to write that is a mode register
consulted at the top of every system. `scene` makes it structure instead: consulted at the top of every handler. `scene` makes it structure instead:
```ludic ```ludic
# doc-check: skip — illustrative: elided bodies # doc-check: skip — illustrative: elided bodies
@ -569,23 +609,23 @@ scene Overworld {
- Exactly **one scene is active**. The one marked `start` runs first (or the - Exactly **one scene is active**. The one marked `start` runs first (or the
first declared, if none is marked). first declared, if none is marked).
- A scene's systems only run while it is active. Systems declared outside any - A scene's handlers only run while it is active. Handlers declared outside any
scene are global and run every frame regardless. scene are global and run every frame regardless.
- **Layers group systems and declaration order is draw order**: within a phase, - **Layers group handlers and declaration order is draw order**: within a phase,
global systems run first, then the active scene's layers in the order they global handlers run first, then the active scene's layers in the order they
were written — so `Hud`'s `Render` paints over `World`'s. were written — so `Hud`'s `Render` paints over `World`'s.
- `on enter` / `on exit` are lifecycle hooks, not phases. Scene setup goes in - `on enter` / `on exit` are lifecycle hooks, not phases. Scene setup goes in
`on enter`; a layer system may not use phase `Start`. `on enter`; a layer handler may not use phase `Start`.
- `enter Name` transitions: the current scene's `on exit` runs, the active scene - `enter Name` transitions: the current scene's `on exit` runs, the active scene
becomes `Name`, and its `on enter` runs. Inside a layer system the compiler becomes `Name`, and its `on enter` runs. Inside a layer handler the compiler
knows which scene is leaving, so a transition costs two direct calls and a knows which scene is leaving, so a transition costs two direct calls and a
store — there is no dispatch table. store — there is no dispatch table.
`examples/scenes.ludic` is a runnable demonstration of the ordering rules. `examples/scenes.ludic` is a runnable demonstration of the ordering rules.
## Queries in a system signature ## Queries in a handler signature
When a system's whole body is one query loop, the loop header can move into the When a handler's whole body is one query loop, the loop header can move into the
declaration: declaration:
```ludic ```ludic

View file

@ -48,7 +48,7 @@ self-hosted native toolchain.)
| `runtime/web/platform.js` | the browser's window — the same five `win_*` functions `cocoa.ll` implements, against a `<canvas>` | | `runtime/web/platform.js` | the browser's window — the same five `win_*` functions `cocoa.ll` implements, against a `<canvas>` |
| `runtime/web/index.html` | the page a web build is served from | | `runtime/web/index.html` | the page a web build is served from |
| `tools/ludic-web/run.mjs` | runs a headless wasm build under Node, so native and wasm output can be diffed | | `tools/ludic-web/run.mjs` | runs a headless wasm build under Node, so native and wasm output can be diffed |
| `examples/chronorift.ludic` | the JRPG written in Ludic (multi-file via `import`, archetype-based) | | `examples/chronorift.ludic` | the JRPG written in Ludic (multi-file via `import`, model-based) |
| `examples/menu.ludic` | a retained-UI title screen (9-slice, TrueType, focusable buttons) | | `examples/menu.ludic` | a retained-UI title screen (9-slice, TrueType, focusable buttons) |
| `examples/snake.ludic` | a second, unrelated game — proves the language is general (same toolchain, no engine hardcoding) | | `examples/snake.ludic` | a second, unrelated game — proves the language is general (same toolchain, no engine hardcoding) |
| `build.sh` | `./build.sh examples/<name>.ludic` | | `build.sh` | `./build.sh examples/<name>.ludic` |
@ -119,10 +119,10 @@ same highlighting, checking and formatting as the source tree.
## Language features implemented ## Language features implemented
- `component` (typed fields + defaults), `system` (`phase`, `@annotations`, - `property` (typed fields + defaults), `system` (`phase`, `@annotations`,
`reads`/`writes` clauses), `const`, `fn` (with `requires`/`ensures` parsed). `reads`/`writes` clauses), `const`, `fn` (with `requires`/`ensures` parsed).
- `archetype` — named entity **kinds** (bundles of components); identity is one - `model` — named entity **kinds** (bundles of properties); identity is one
int per entity, replacing empty tag components. Filter with `{Kind}`. int per entity, replacing empty tag properties. Filter with `{Kind}`.
- `import "file"` — multi-file programs (fragments spliced in, include-guarded, - `import "file"` — multi-file programs (fragments spliced in, include-guarded,
per-file diagnostics). per-file diagnostics).
- ECS queries: `for (a, b) in query [A, B, {Tag}] where <expr> { … }`. - ECS queries: `for (a, b) in query [A, B, {Tag}] where <expr> { … }`.
@ -165,7 +165,7 @@ Controls:
- [x] Compiler pipeline: Ludic → LLVM IR → native binary / shared library - [x] Compiler pipeline: Ludic → LLVM IR → native binary / shared library
- [x] No C generated, compiled or linked in a build; runtime written in Ludic - [x] No C generated, compiled or linked in a build; runtime written in Ludic
- [x] Cross-compilation to ELF (x86-64, aarch64) and Windows COFF - [x] Cross-compilation to ELF (x86-64, aarch64) and Windows COFF
- [x] ECS runtime (components, systems, phases, queries, entity pooling) - [x] ECS runtime (properties, systems, phases, queries, entity pooling)
- [x] Windowed 2D rendering (Cocoa driven from LLVM IR) + headless PPM verification - [x] Windowed 2D rendering (Cocoa driven from LLVM IR) + headless PPM verification
- [x] CC0 Kenney PNG sprites (`load_png`, decoder written in Ludic) + scrolling camera - [x] CC0 Kenney PNG sprites (`load_png`, decoder written in Ludic) + scrolling camera
- [x] Overworld: tilemap, movement, collision - [x] Overworld: tilemap, movement, collision

View file

@ -343,7 +343,23 @@ 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 needed: the bidirectional *checks* give the same guarantee — nothing can drift
without CI failing — without a code-generation step to maintain. without CI failing — without a code-generation step to maintain.
**Phases 1–5 are complete.** All thirteen findings are resolved or resolved by an **Phases 1–5 are complete.**
### Phase 6 — vocabulary rename + annotation DSL ✅ DONE (follow-on request)
Renamed the core nouns: `game`/`module` → `program`, `main` → `entry`,
`component` → `property`, `archetype` → `model`, `system` → `handler`. Done via a
transitional self-hosting bootstrap (accept both → reseed → move the compiler's
own source to new keywords + tighten → reseed); old keywords now rejected.
Token-safe corpus migration ([rename_kw.c](tools/ludic-tools/rename_kw.c)), goldens
byte-identical. Reconciled the LSP indexer, editor vocab, check-docs wrapper, and
docs; fixed two pre-existing toolchain bugs (a `set -e` bug in build-tools.sh that
blocked all editor-binary rebuilds, and a stale LSP test offset).
Added an **annotation DSL**: `@Queries(these: [Prop{constraint}, …], on: Model)` on
a handler desugars to the existing `S_QUERY` loop (each property binds by its own
name; a `Prop{…}` constraint qualifies its bare fields; `on:` adds a `{Model}`
tag), and `@Handles(…)` on a program parses as documentation. See
[examples/annotations.ludic](examples/annotations.ludic); test.sh 15/15. All thirteen findings are resolved or resolved by an
explicit, documented decision. explicit, documented decision.
--- ---

View file

@ -0,0 +1,30 @@
# annotations.ludic — the annotation-first style. A handler's query is an
# @Queries decorator instead of a clause, and the program lists the handlers it
# drives with @Handles. Both lower to the same code the older spellings did.
@Handles(Move)
program RPG2D {
property Transform { x: int = 0, y: int = 0, scale: int = 1 }
property Velocity { dx: int = 0, dy: int = 0 }
model Actor { Transform, Velocity }
handler Spawn phase Start {
spawn Actor { Transform { x: 0, scale: 2 } Velocity { dx: 3, dy: 1 } }
spawn Actor { Transform { x: 0, scale: 0 } Velocity { dx: 9, dy: 9 } }
}
# Move every Actor whose scale is positive and that is actually moving. The
# query lives in the annotation; each bound property is addressed by its own
# name in the body (`Transform`, `Velocity`), and a constraint like
# `Transform{scale > 0}` reads `scale` as a field of Transform.
@Queries(these: [Transform{scale > 0}, Velocity{dx > 0 or dy > 0}], on: Actor)
handler Move phase Update {
Transform.x = Transform.x + Velocity.dx
Transform.y = Transform.y + Velocity.dy
}
handler Report phase Render {
for (t) in query [Transform] { print_int(t.x); print_int(t.y) }
quit()
}
}

File diff suppressed because it is too large Load diff

View file

@ -281,11 +281,13 @@ fn already_loaded(full: ptr) -> bool {
# `@pure`, `@deterministic`, … — one channel, not a zoo of prefix keywords. # `@pure`, `@deterministic`, … — one channel, not a zoo of prefix keywords.
fn parse_one_decl() -> void { fn parse_one_decl() -> void {
let is_export = false let is_export = false
let qspec: Node = ptr_null()
while is_op("@") { while is_op("@") {
pi = pi + 1; let a = eat_id() # collect a leading @annotation pi = pi + 1; let a = eat_id() # collect a leading @annotation
if streq(a, "export") { is_export = true } if streq(a, "export") { is_export = true }
if is_op("(") { let d = 0 # optional @anno(args) — skipped if streq(a, "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } else { if is_op("(") { let 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() skipnl()
} }
if is_id("import") { pi = pi + 1 if is_id("import") { pi = pi + 1
@ -299,7 +301,14 @@ fn parse_one_decl() -> void {
if is_id("enum") { push(prog, parse_enum()); return } if is_id("enum") { push(prog, parse_enum()); return }
if is_id("property") { push(prog, parse_component()); return } if is_id("property") { push(prog, parse_component()); return }
if is_id("model") { push(prog, parse_archetype()); return } if is_id("model") { push(prog, parse_archetype()); return }
if is_id("handler") { push(prog, parse_system()); return } if is_id("handler") {
let h = parse_system()
if not ptr_is_null(qspec) { # @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("ui") { push(prog, parse_ui()); return }
if is_id("var") { push(prog, parse_var()); return } if is_id("var") { push(prog, parse_var()); return }
if is_id("const") { push(prog, parse_const()); return } if is_id("const") { push(prog, parse_const()); return }
@ -340,8 +349,15 @@ fn parse_program() -> void {
loaded_paths = new []ptr loaded_paths = new []ptr
skipnl() skipnl()
g_game_name = "Ludic" g_game_name = "Ludic"
# imports may precede the game block # 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() } 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("(") { let 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("{") } if is_id("program") { pi = pi + 1; g_game_name = eat_id(); skipnl(); eat_op("{") }
while true { while true {
skipnl() skipnl()

View file

@ -84,6 +84,62 @@ fn parse_query_for() -> Node {
return n return n
} }
# ---- @Queries annotation -----------------------------------------------------
# `@Queries(these: [Prop{constraint}, ...], on: Model)` on a handler is an
# annotation spelling of the `for (Prop, ...) in query [Prop, ..., {Model}]
# where <constraints> { body }` loop. It desugars to the same S_QUERY node, so
# the whole query backend (iteration, filters, binding, break/continue) is reused.
fn mk_and(a: Node, b: Node) -> Node {
if ptr_is_null(a) { return b }
let n = node(E_BIN); n.s = "and"; n.a = a; n.b = b; return n
}
# In `Prop{scale > 0}` the bare names are fields of Prop; qualify each to
# `Prop.field` (the binding var is the property name) for the desugared where.
fn qualify_fields(e: Node, prop: ptr) -> Node {
if ptr_is_null(e) { return e }
if e.kind == E_ID {
let m = node(E_MEMBER); let base = node(E_ID); base.s = prop; m.a = base; m.s = e.s; return m
}
if e.kind == E_BIN { e.a = qualify_fields(e.a, prop); e.b = qualify_fields(e.b, prop); return e }
if e.kind == E_UN { e.a = qualify_fields(e.a, prop); return e }
return e
}
# parse `(these: [...], on: Model)`, returning an S_QUERY with its vars/terms/where
# filled in (the body `.a` is attached by the caller once the handler is parsed).
fn parse_queries_anno() -> Node {
eat_op("(")
let qn = node(S_QUERY)
let terms = node(N_BLOCK)
let wh: Node = ptr_null()
while not is_op(")") {
skipnl()
if is_op(")") { break }
let key = eat_id(); eat_op(":")
if streq(key, "these") {
eat_op("["); skipnl()
while not is_op("]") {
let pname = eat_id()
let v = node(E_ID); v.s = pname; push(qn.kids, v) # binding var = property name
let t = node(E_ID); t.s = pname; t.ival = 0; push(terms.kids, t)
if is_op("{") { pi = pi + 1; let ce = expr(); eat_op("}"); wh = mk_and(wh, qualify_fields(ce, pname)) }
if is_op(",") { pi = pi + 1 }
skipnl()
}
eat_op("]")
} else { if streq(key, "on") {
let mname = eat_id(); let t = node(E_ID); t.s = mname; t.ival = 1; push(terms.kids, t) # {Model} tag
} else { expr() } } # unknown key: skip its value
if is_op(",") { pi = pi + 1 }
skipnl()
}
eat_op(")")
qn.c = terms; qn.b = wh
return qn
}
fn parse_spawn() -> Node { fn parse_spawn() -> Node {
pi = pi + 1; let n = node(S_SPAWN); n.s = eat_id(); skipnl(); eat_op("{") pi = pi + 1; let n = node(S_SPAWN); n.s = eat_id(); skipnl(); eat_op("{")
while true { while true {

View file

@ -50,6 +50,11 @@ qsmoke() { # name
else bad "$n: $(tail -1 /tmp/qs.out)"; fi else bad "$n: $(tail -1 /tmp/qs.out)"; fi
} }
qsmoke qdecl qsmoke qdecl
# @Queries desugars to a query loop; @Handles parses. Check the desugared behavior.
if ./selfhost/game-build.sh build/ludicc examples/annotations.ludic "/tmp/ludic_ann" >/tmp/ann.out 2>&1 \
&& [ "$(/tmp/ludic_ann | tr '\n' ' ')" = "3 1 0 0 " ]; then
ok "annotations.ludic (@Queries desugars to a query, @Handles parses)"
else bad "annotations: $(tail -1 /tmp/ann.out)"; fi
# --- Toolchain-agent CLI smoke tests append below this line --- # --- Toolchain-agent CLI smoke tests append below this line ---
echo "== self-hosted front-end binaries (ludicc / ludic) ==" echo "== self-hosted front-end binaries (ludicc / ludic) =="
# The two commands are one multi-call native binary built from the seed with # The two commands are one multi-call native binary built from the seed with