Merge vocabulary rename + annotation DSL (Phase 6)

program (was game/module), entry (was main), property (was component),
model (was archetype), handler (was system). Plus an annotation DSL:
@Queries(these: [Prop{constraint}, ...], on: Model) on a handler desugars to the
query loop, and @Handles on a program. Behaviour-preserving (goldens
byte-identical, C-free fixpoint holds); test.sh 15/15, test-tools 28/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 18:53:02 +03:00
commit ecead0564e
43 changed files with 3962 additions and 3277 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 |
| Byte-level memory | ✅ | `peek8`/`poke8`, `peek32`/`poke32`, `peekp`/`pokep`, `ptr_add` |
| `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` |
| `str` accepted where `ptr` expected | ✅ | `f("A")` into `fn f(p: ptr)` |
| 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
magic number. Across a 6,000-line compiler this is the difference between
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
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
obvious wrong turn.
@ -211,11 +211,11 @@ No copying, no by-value passing, no nested-struct inlining — a `struct` value
a layout table.
**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
`%Cmp_`-style type *without* the parallel entity arrays: `new` 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.
**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). |
| **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. |
| **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. |
**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. |
| **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 | — |
| **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. |
| **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. |
| **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. |
### 5.4 Proposals
@ -497,7 +497,7 @@ point of the mistake. *Fixes R5. Cost:* ~40 lines.
section.** Two honest options per construct, no third:
- `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
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
@ -542,7 +542,7 @@ Recording these so they are not relitigated:
- **Braces, not indentation** (§5.2).
- **`#` 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
readability asset.
- **`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,
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
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
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
The self-host compiler was extended to the **whole** language — components,
archetypes, systems, phases, `for … in query` (with `where`), spawn/despawn,
The self-host compiler was extended to the **whole** language — properties,
models, systems, phases, `for … in query` (with `where`), spawn/despawn,
`self()`, `machine`/`become`, `match`, `save`/`load` snapshots, the retained
`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.
@ -791,7 +791,7 @@ existing framing ("the same floor Rust and Swift stand on") already covers it.
## 8. Risks and gotchas
- **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).
- **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
@ -852,9 +852,9 @@ current tree (`./test.sh` = 64/64).
**Recursion** ✅ → `55`
```ludic
game P {
program P {
fn fib(n: int) -> int { if n < 2 { return n }; return fib(n-1) + fib(n-2) }
system B phase Start { print_int(fib(10)); quit() }
handler B phase Start { print_int(fib(10)); quit() }
}
```
@ -908,19 +908,19 @@ spelling is equally legal (§5.3).
**R1 — statements now require a separator (Rule B, syntax-redesign Phase 2)** → parse error
```ludic
# doc-check: skip — intentionally rejected under Rule B: needs a newline or ';'
game P { system B phase Start { let x = 1 x = x + 1 print_int(x) quit() } }
program P { handler B phase Start { let x = 1 x = x + 1 print_int(x) quit() } }
```
Statements no longer sit adjacent with only spaces between them; the compiler
reports `expected newline or ';' between statements`. Put each on its own line,
or separate them with `;` (both lex to the same separator token):
```ludic
game P { system B phase Start { let x = 1; x = x + 1; print_int(x); quit() } }
program P { handler B phase Start { let x = 1; x = x + 1; print_int(x); quit() } }
```
**R2 — commas omitted throughout** → `7`
```ludic
# doc-check: skip — composite: declaration plus statements
component Pos { x: int = 0 y: int = 0 }
property Pos { x: int = 0 y: int = 0 }
spawn Hero { Pos { x: 7 y: 2 } }
```
@ -950,11 +950,11 @@ print_int(half(8))
```
And a system may declare read-only access, then write — also compiles:
```ludic
system Violate phase Update reads [Pos] query (p) [Pos] { p.x = 99 }
handler Violate phase Update reads [Pos] query (p) [Pos] { p.x = 99 }
```
**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
parenthesisation) while `ludic-fmt` returns the input **unchanged**.

View file

@ -37,7 +37,7 @@ and find your program rewritten in another language.
app.ludic
│ ludicc — lex, parse, check, lower (compiler/ludicc.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)
▼
app.o Mach-O / ELF / COFF object code
@ -91,7 +91,7 @@ A source file opens with `game Name { … }` or `module Name { … }`.
```ludic
# doc-check: skip — illustrative: elided body
module Combat {
program Combat {
@export fn damage(attack: int, armour: int, roll: int) -> int { … }
fn curve(level: int) -> int { … } # private: not a symbol
}
@ -216,7 +216,7 @@ python3 -m http.server -d build/web 8000 # then open http://localhost:8000/
itch.io — and the game runs. It needs no server-side anything, and no
cross-origin isolation headers.
**No game logic passes through JavaScript.** The systems, the queries, the
**No game logic passes through JavaScript.** The handlers, the queries, the
fixed-point arithmetic, the PNG decoder, the TrueType rasteriser and the UI are
all compiled Ludic executing as wasm. `platform.js` is 300 lines and implements
the same five-function window protocol `cocoa.ll` implements, plus the host
@ -248,7 +248,7 @@ A browser tab cannot be held inside that loop — it would never paint, and the
key events the loop is waiting on would never be delivered. So a web build
exports those four functions instead of `main`, and `platform.js` calls
`ludic_frame` from `requestAnimationFrame`. Both targets emit the four from the
same code in `ll_emit_loop_parts`, so the systems that run, and the phase order
same code in `ll_emit_loop_parts`, so the handlers that run, and the phase order
they run in, are identical; only the owner of the loop differs.
### The floor
@ -302,8 +302,8 @@ asserts exactly that, which is a much stronger check on the backend than
## What a build contains
Everything: components and archetypes, spawn/despawn, queries with bindings,
`where` filters and archetype filters, `match`, `machine`/`become`,
Everything: properties and models, spawn/despawn, queries with bindings,
`where` filters and model filters, `match`, `machine`/`become`,
`scene`/`layer`/`enter`, module state (`var`), `const`, int and Q16.16
fixed-point arithmetic, control flow, functions, `extern fn` FFI, strings, the
entity allocator, save/load snapshots, the frame loop, the window, and the whole

View file

@ -12,23 +12,23 @@ program.ludic ──ludicc──▶ program.ll ──▶ program.o ──▶ nat
`ludicc` lowers Ludic to **LLVM IR itself** and links the result — see
[COMPILING.md](COMPILING.md) for the pipeline, `module`/`export`, and
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
A program is one `game` block containing declarations:
A program is one `program` block containing declarations:
```ludic
# doc-check: skip — illustrative: elided import list
game Name {
program Name {
import ... # pull declarations in from another file
component ... # data (per entity)
property ... # data (per entity)
struct ... # a plain record, not tied to an entity
archetype ... # a named entity KIND (bundle of components)
model ... # a named entity KIND (bundle of properties)
const ... # compile-time constants
fn ... # functions
extern fn ... # bind a C library symbol (FFI)
system ... # behavior, grouped into phases
handler ... # behavior, grouped into phases
}
```
@ -36,43 +36,43 @@ game Name {
```ludic
# doc-check: skip — paths resolve only inside the repo
game ChronoRift {
program ChronoRift {
import "chronorift/world.ludic" # path is relative to THIS file
import "chronorift/combat.ludic"
}
```
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
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
(even via different chains) pulls it in once. Diagnostics report the true file:
```
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
carries. It replaces the empty "tag component" idiom: identity is stored as one
An `model` names a *kind* of entity and the fixed set of properties it
carries. It replaces the empty "tag property" idiom: identity is stored as one
integer per entity, not a parallel boolean array.
```ludic
# doc-check: skip — composite: declarations and statements together
component Pos { x: int = 0, y: int = 0 }
component Stats { hp: int = 10 }
property Pos { x: int = 0, y: int = 0 }
property Stats { hp: int = 10 }
archetype Player { Pos, Stats } # Player IS a kind, not a component
archetype Enemy { Pos, Stats }
model Player { Pos, Stats } # Player IS a kind, not a property
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
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
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:
```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
let w = text_w(f, "measure me", 28) # pixel width
```
@ -115,22 +115,22 @@ ui MainMenu {
Widget types: `panel` (container + optional skin/bg/border), `col` / `row`
(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
systems:
handlers:
```ludic
system Boot phase Start {
handler Boot phase Start {
setreg(R_FONT, font_load("…Arial.ttf"))
ui_build() # construct the tree (loads skins/images)
ui_open(UI_MainMenu) # make it active, focus the first button
}
system Nav phase Update {
handler Nav phase Update {
ui_tick(key()) # w/s move focus, space/enter activate
if ui_clicked(UI_Quit) { quit() }
ui_set_int(UI_HpLabel, hp) # poke dynamic values by id
}
system Draw phase Render { clear(0x0e0e16); ui_render(); present() }
handler Draw phase Render { clear(0x0e0e16); ui_render(); present() }
```
See `examples/menu.ludic` for a complete title screen.
@ -151,12 +151,12 @@ point (`1.5`) is `fixed`. Arithmetic on two `fixed` values lowers to
`fxmul`/`fxdiv`; mixing `int` and `fixed` promotes the `int`. Convert with
`fx(i)` (int→fixed) and `flr(f)` (fixed→int).
## Components, entities, queries
## Properties, entities, queries
```ludic
# doc-check: skip — composite: declarations and statements together
component Pos { x: int = 0, y: int = 0 } # typed fields with defaults
component Player { } # a tag (no fields)
property Pos { x: int = 0, y: int = 0 } # typed fields with defaults
property Player { } # a tag (no fields)
spawn Hero { # create an entity
Pos { x: 10, y: 5 }
@ -164,44 +164,44 @@ spawn Hero { # create an 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 (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.
## Systems & phases
## Handlers & phases
```ludic
system Move @deterministic
handler Move @deterministic
reads [Vel] # declared data access (parsed and reserved; not yet
writes [Pos] # consumed by any analysis pass — see "Not yet implemented")
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 }
```
Phases run in this order every frame: **`Start`** (once at boot), then each
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 —
`@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
keywords. (`@export` sets the export flag; the others parse but have no codegen
effect in the self-hosted compiler yet.)
### The `query` clause
A system declares the entities it works on, alongside its phase. The body then
runs **once per matching entity**, with the components bound and `self()` giving
A handler declares the entities it works on, alongside its phase. The body then
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
signature:
```ludic
system CleanBattle phase LateUpdate
handler CleanBattle phase LateUpdate
query (b, p) [Battle, Pos, {Enemy}] where b.hp <= 0
{ despawn self() }
```
@ -209,32 +209,32 @@ system CleanBattle phase LateUpdate
is the same program as
```ludic
system CleanBattle phase LateUpdate {
handler CleanBattle phase LateUpdate {
for (b, p) in query [Battle, Pos, {Enemy}] where b.hp <= 0 { despawn self() }
}
```
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
(`{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.
### 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
matched on their field values:
```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
```
The same `where` works on an inline `for (…) in query […]`.
`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
register for every entity). Keep whole-system guards in the body of a system
guard that concerns the whole handler (`where reg(R_MODE) != 1` would re-read the
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
`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
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
`struct` is the aggregate that is *not* tied to an entity — a plain record, for
@ -255,7 +295,7 @@ the data a program keeps outside the ECS.
```ludic
struct Tok { kind: int = 0, line: int = 0, next: Tok }
system Lex phase Update {
handler Lex phase Update {
let t = new Tok # allocates; every field seeded from its default
t.kind = 1
}
@ -269,7 +309,7 @@ struct Tok { kind: int = 0, line: int = 0, next: Tok }
fn bump(t: Tok) -> void { t.kind = t.kind + 1 }
system Share phase Update {
handler Share phase Update {
let a = new Tok
let b = a # b and a are the SAME object
b.kind = 9
@ -283,7 +323,7 @@ Fields chain, so a struct can refer to its own type and be walked without
temporaries — which is what an AST or a linked list needs:
```ludic
system Walk phase Update {
handler Walk phase Update {
let a = new Tok
let b = new Tok
a.next = b
@ -300,7 +340,7 @@ snippet below does not compile today. Programs use `[]T` slices for now.
```ludic
# doc-check: skip — [T; N] fixed arrays are not yet implemented (design target)
var table: [int; 8] # module-level storage
system S phase Update {
handler S phase Update {
let buf: [int; 4] # a local; no initializer needed
buf[0] = 10
table[2] = buf[0]
@ -312,7 +352,7 @@ capacity. `push` appends, doubling the storage when it is full; because the
header never moves, an append is visible to everything holding that slice.
```ludic
system Collect phase Update {
handler Collect phase Update {
let toks = new []Tok
push(toks, new Tok)
for i in 0 .. len(toks) { print_int(toks[i].kind) }
@ -442,7 +482,7 @@ ints (`0xff8800`).
# ui_focus(id) ui_focused()->int ui_visible(id,bool)
# assets load_png(path)->id (decodes a PNG; returns a 16x16 sprite id)
# 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
# save save() load()->bool (binary snapshot of the whole ECS World)
# control quit() print_int(i)
@ -453,10 +493,10 @@ ints (`0xff8800`).
## Tooling
```bash
ludicc game.ludic -o build/game # native binary (windowed for a game)
ludicc game.ludic --headless -o g # headless build (renders out.ppm; reads stdin)
ludicc game.ludic --emit-llvm -o g.ll # stop at LLVM IR
ludic game.ludic # compile AND run (forwards the exit code)
ludicc app.ludic -o build/app # native binary (windowed for a game)
ludicc app.ludic --headless -o app # headless build (renders out.ppm; reads stdin)
ludicc app.ludic --emit-llvm -o app.ll # stop at LLVM IR
ludic app.ludic # compile AND run (forwards the exit code)
```
`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`,
`--save-temps`, `--run`), the `LUDIC_HOME` / `LUDIC_CC` environment variables,
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.
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,
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
labels, focusable buttons).
- `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
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.
- **`[T; N]` fixed arrays** — documented above, but `ptype` parses only `[]T`
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
> 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
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
# doc-check: skip — illustrative: elided bodies
@ -553,7 +593,7 @@ scene Title start {
on exit { ui_visible(UI_Menu, 0) }
layer Main {
system Choose phase Update {
handler Choose phase Update {
if ui_clicked(UI_NewGame) { enter Overworld }
}
}
@ -562,34 +602,34 @@ scene Title start {
scene Overworld {
on enter { spawn_party() }
layer World { system Move phase Update { … } }
layer Hud { system Draw phase Render { … } }
layer World { handler Move phase Update { … } }
layer Hud { handler Draw phase Render { … } }
}
```
- Exactly **one scene is active**. The one marked `start` runs first (or the
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.
- **Layers group systems and declaration order is draw order**: within a phase,
global systems run first, then the active scene's layers in the order they
- **Layers group handlers and declaration order is draw order**: within a phase,
global handlers run first, then the active scene's layers in the order they
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`; 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
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
store — there is no dispatch table.
`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:
```ludic
system CleanBattle phase LateUpdate
handler CleanBattle phase LateUpdate
query (b, p) [Battle, Pos, {Foe}] where b.hp <= 0
{
despawn self()

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/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 |
| `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/snake.ludic` | a second, unrelated game — proves the language is general (same toolchain, no engine hardcoding) |
| `build.sh` | `./build.sh examples/<name>.ludic` |
@ -119,10 +119,10 @@ same highlighting, checking and formatting as the source tree.
## 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).
- `archetype` — named entity **kinds** (bundles of components); identity is one
int per entity, replacing empty tag components. Filter with `{Kind}`.
- `model` — named entity **kinds** (bundles of properties); identity is one
int per entity, replacing empty tag properties. Filter with `{Kind}`.
- `import "file"` — multi-file programs (fragments spliced in, include-guarded,
per-file diagnostics).
- 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] 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] 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] CC0 Kenney PNG sprites (`load_png`, decoder written in Ludic) + scrolling camera
- [x] Overworld: tilemap, movement, collision

View file

@ -112,11 +112,11 @@ alternative if the paren form is disliked: keep whitespace separation but coloni
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before
edge system Move @deterministic reads [Vel] writes [Pos] phase FixedUpdate
edge handler Move @deterministic reads [Vel] writes [Pos] phase FixedUpdate
query (p, v) [Pos, Vel] where a.x > 0 { … }
# after
@edge @deterministic
system Move
handler Move
phase FixedUpdate
reads [Vel] writes [Pos]
query (p, v) [Pos, Vel] where p.x > 0
@ -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
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.
---

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()
}
}

View file

@ -7,7 +7,7 @@
# chronorift/, pulled in with `import`. Paths resolve relative to this file, and
# each import is include-guarded so re-imports are free.
# ============================================================================
game ChronoRift {
program ChronoRift {
import "chronorift/world.ludic" # components, entity archetypes, constants
import "chronorift/rules.ludic" # pure rules + map data (functions)
import "chronorift/overworld.ludic" # boot, movement, encounters, shop

View file

@ -8,7 +8,7 @@
enum KnightAct { Attack, Guard, Item, Flee }
enum MageAct { Attack, Heal, Guard }
system Battle phase Update {
handler Battle phase Update {
if reg(R_MODE) == 1 {
let k = key()
for (e) in query [Stats, {Enemy}] {
@ -144,7 +144,7 @@ system Battle phase Update {
}
}
system CleanBattle phase LateUpdate {
handler CleanBattle phase LateUpdate {
if reg(R_MODE) != 1 {
for (e) in query [Stats, {Enemy}] {
despawn self()
@ -152,7 +152,7 @@ system CleanBattle phase LateUpdate {
}
}
system Meta phase Update {
handler Meta phase Update {
let k = key()
if reg(R_MODE) == 2 {
if k == 0 { setreg(R_ACK, 1) }

View file

@ -1,6 +1,6 @@
# draw.ludic — the Render system (overworld, battle, and overlay screens).
system Draw phase Render {
handler Draw phase Render {
let m = reg(R_MODE)
clear(0x101018)

View file

@ -1,6 +1,6 @@
# overworld.ludic — boot, movement/encounter input, and the shop system.
system Boot phase Start {
handler Boot phase Start {
load_png("assets/kenney/tiny-town/Tiles/tile_0000.png") # 0 grass
load_png("assets/kenney/tiny-town/Tiles/tile_0004.png") # 1 tree
load_png("assets/kenney/tiny-town/Tiles/tile_0025.png") # 2 path
@ -33,7 +33,7 @@ system Boot phase Start {
setreg(R_POTION, 2)
}
system Control phase Input {
handler Control phase Input {
if reg(R_MODE) == 0 {
for (p) in query [Pos, {Player}] {
let k = key()
@ -122,7 +122,7 @@ system Control phase Input {
}
# shop (at the house 'h' tile): buy potions with gold
system Shop phase Update {
handler Shop phase Update {
if reg(R_MODE) == 4 {
let k = key()
if k == 'w' { setreg(R_CUR, max(0, reg(R_CUR) - 1)) }

View file

@ -1,17 +1,17 @@
# world.ludic — the data model: components, entity archetypes, constants.
# Imported by examples/chronorift.ludic. No `game` wrapper (this is a fragment).
component Pos { x: int = 0, y: int = 0 }
component Actor { kind: int = 0 }
component Stats { hp: int = 1, maxhp: int = 1, mp: int = 0, maxmp: int = 0,
property Pos { x: int = 0, y: int = 0 }
property Actor { kind: int = 0 }
property Stats { hp: int = 1, maxhp: int = 1, mp: int = 0, maxmp: int = 0,
atk: int = 1, def: int = 0, lvl: int = 1, xp: int = 0, guard: int = 0 }
component Party { slot: int = 0 }
property Party { slot: int = 0 }
# Entity KINDS. Identity is stored as one integer per entity (L_kind), not as
# an empty marker component. Query membership with {Player} / {Enemy}.
archetype Player { Pos, Actor, Party, Stats } # the controllable knight
archetype Ally { Party, Stats } # backline mage (no overworld body)
archetype Enemy { Actor, Stats } # battle foe
model Player { Pos, Actor, Party, Stats } # the controllable knight
model Ally { Party, Stats } # backline mage (no overworld body)
model Enemy { Actor, Stats } # battle foe
const MW: int = 40
const MH: int = 18

View file

@ -1,10 +1,10 @@
# hello.ludic — smallest program that exercises the ECS pipeline
game Hello {
program Hello {
component Pos { x: int = 0, y: int = 0 }
component Vel { dx: int = 0, dy: int = 0 }
property Pos { x: int = 0, y: int = 0 }
property Vel { dx: int = 0, dy: int = 0 }
system Boot phase Start {
handler Boot phase Start {
spawn Mob {
Pos { x: 3, y: 4 }
Vel { dx: 1, dy: 0 }
@ -17,14 +17,14 @@ game Hello {
# a system can declare the entities it operates on: the body then runs once
# per match, with the components bound and self() giving that entity.
system Move phase FixedUpdate
handler Move phase FixedUpdate
query (p, v) [Pos, Vel]
{
p.x = p.x + v.dx
p.y = p.y + v.dy
}
system Report phase Update {
handler Report phase Update {
for (p) in query [Pos] {
print_int(p.x)
print_int(p.y)

View file

@ -9,20 +9,20 @@
# that: the same declaration binds a C, Rust or Zig library just as well. This
# is how Ludic reaches the outside world without a C shim.
# ============================================================================
game Arena {
program Arena {
extern fn damage(attack: int, armour: int, roll: int) -> int = "damage"
extern fn hits_to_kill(hp: int, attack: int, armour: int) -> int = "hits_to_kill"
extern fn xp_for(level: int, kills: int) -> int = "xp_for"
component Fighter { hp: int = 30, attack: int = 9, armour: int = 2 }
property Fighter { hp: int = 30, attack: int = 9, armour: int = 2 }
system Boot phase Start {
handler Boot phase Start {
spawn Hero { Fighter { hp: 40, attack: 12, armour: 3 } }
spawn Orc { Fighter { hp: 24, attack: 7, armour: 1 } }
}
system Fight phase Update {
handler Fight phase Update {
for (f) in query [Fighter] {
print_int(damage(f.attack, f.armour, 0))
print_int(damage(f.attack, f.armour, 20))

View file

@ -11,7 +11,7 @@
# engine, a test harness, a scripting host. Non-exported functions stay private
# to the library.
# ============================================================================
module Combat {
program Combat {
const CRIT_MULT: int = 2

View file

@ -2,7 +2,7 @@
# block); the engine lays it out (stacked panel, padding/gap/alignment), draws
# it (background, TrueType labels, focusable buttons), and routes keyboard focus
# and activation. The game only loads a font, opens the UI, and reads clicks.
game Menu {
program Menu {
const R_FONT: int = 0
const R_MSG: int = 1
@ -19,13 +19,13 @@ game Menu {
}
}
system Boot phase Start {
handler Boot phase Start {
setreg(R_FONT, font_load("/System/Library/Fonts/Supplemental/Arial.ttf"))
ui_build()
ui_open(UI_MainMenu)
}
system Nav phase Update {
handler Nav phase Update {
ui_tick(key())
if ui_clicked(UI_NewGame) { ui_set_text(UI_NewGame, "Starting...") }
if ui_clicked(UI_Continue) { ui_set_text(UI_Continue, "No save") }
@ -33,7 +33,7 @@ game Menu {
if ui_clicked(UI_Quit) { quit() }
}
system Draw phase Render {
handler Draw phase Render {
clear(0x0e0e16)
ui_render()
present()

View file

@ -9,29 +9,29 @@
# the whole body — the loop header just moves into the declaration. The body runs
# once per matching entity, and self() is that entity.
# ============================================================================
game QueryDecl {
component Battle { hp: int = 0, side: int = 0 }
component Pos { x: int = 0, y: int = 0 }
archetype Foe { Battle, Pos }
program QueryDecl {
property Battle { hp: int = 0, side: int = 0 }
property Pos { x: int = 0, y: int = 0 }
model Foe { Battle, Pos }
system Seed phase Start {
handler Seed phase Start {
spawn Foe { Battle { hp: 3, side: 1 } }
spawn Foe { Battle { hp: 0, side: 1 } }
spawn Foe { Battle { hp: -2, side: 0 } }
}
system CleanBattle phase LateUpdate
handler CleanBattle phase LateUpdate
query (b, p) [Battle, Pos, {Foe}] where b.hp <= 0 and b.side == 1
{
print_int(p.x)
despawn self()
}
system Census phase Render
handler Census phase Render
query (b) [Battle]
{
print_int(b.hp)
}
system Bye phase Render { quit() }
handler Bye phase Render { quit() }
}

View file

@ -4,16 +4,16 @@
# LANGUAGE.md §"Scenes & layers"). It is deliberately excluded from test.sh.
# Games that need scene-like states use a mode register + `machine` today, as
# examples/chronorift does.
game SceneDemo {
program SceneDemo {
const R_N: int = 0
system Boot phase Start { setreg(R_N, 0); print_int(1000) }
handler Boot phase Start { setreg(R_N, 0); print_int(1000) }
scene Title start {
on enter { print_int(1) }
on exit { print_int(2) }
layer Main {
system Tick phase Update {
handler Tick phase Update {
setreg(R_N, reg(R_N) + 1)
print_int(100 + reg(R_N))
if reg(R_N) >= 2 { enter Play }
@ -25,14 +25,14 @@ game SceneDemo {
on enter { print_int(3); setreg(R_N, 0) }
on exit { print_int(4) }
layer World {
system Step phase Update {
handler Step phase Update {
setreg(R_N, reg(R_N) + 1)
print_int(200 + reg(R_N))
if reg(R_N) >= 2 { quit() }
}
}
layer Hud {
system Draw phase Render { print_int(900) }
handler Draw phase Render { print_int(900) }
}
}
}

View file

@ -3,10 +3,10 @@
# general: compiled by the SAME unmodified ludicc + runtime as the JRPG.
# No sprites/assets — pure primitives. Build: ./build.sh examples/snake.ludic
# ============================================================================
game Snake {
program Snake {
component Pos { x: int = 0, y: int = 0 }
component Seg { order: int = 0 }
property Pos { x: int = 0, y: int = 0 }
property Seg { order: int = 0 }
const GW: int = 20
const GH: int = 15
@ -39,9 +39,9 @@ game Snake {
return 0
}
system Boot phase Start { reset(0) }
handler Boot phase Start { reset(0) }
system Input phase Input {
handler Input phase Input {
let k = key()
if reg(R_MODE) == 0 {
if k == 'w' { if reg(R_DIR) != 1 { setreg(R_DIR, 0) } }
@ -55,7 +55,7 @@ game Snake {
}
}
system Move phase Update {
handler Move phase Update {
if reg(R_MODE) == 0 {
setreg(R_TICK, reg(R_TICK) + 1)
if reg(R_TICK) >= 6 {
@ -110,7 +110,7 @@ game Snake {
}
}
system Draw phase Render {
handler Draw phase Render {
clear(0x0d1020)
for gy in 0 .. GH {
for gx in 0 .. GW {

View file

@ -24,7 +24,7 @@ FRAGS="selfhost/str.ludic selfhost/buf.ludic selfhost/io.ludic selfhost/ast.ludi
selfhost/lex.ludic selfhost/parse.ludic selfhost/parse_game.ludic selfhost/emit_core.ludic selfhost/emit_head.ludic
selfhost/emit_addr.ludic selfhost/emit_intrin.ludic selfhost/emit_intrin2.ludic selfhost/emit_math.ludic selfhost/emit_new.ludic
selfhost/emit_expr.ludic selfhost/emit_stmt.ludic selfhost/emit_ecs.ludic selfhost/emit_query.ludic selfhost/emit_spawn.ludic selfhost/emit_game.ludic selfhost/emit_machine.ludic selfhost/emit_save.ludic selfhost/emit_ui.ludic selfhost/emit_decl.ludic selfhost/main.ludic"
{ echo "game SelfHost {"; for f in $FRAGS; do cat "$f"; echo; done; echo "}"; } > "$B/selfhost.ludic"
{ echo "program SelfHost {"; for f in $FRAGS; do cat "$f"; echo; done; echo "}"; } > "$B/selfhost.ludic"
# the seed-built compiler compiles its own source
"$B/sh_seed" "$B/selfhost.ludic" > "$B/out.ll" 2>/dev/null || { echo "FAIL: seed compiler self-compile"; exit 1; }

View file

@ -33,7 +33,7 @@ selfhost/emit_ui.ludic
selfhost/emit_decl.ludic
selfhost/main.ludic"
SRC=build/selfhost.ludic
{ echo "game SelfHost {"; for f in $FRAGS; do cat "$f"; echo; done; echo "}"; } > "$SRC"
{ echo "program SelfHost {"; for f in $FRAGS; do cat "$f"; echo; done; echo "}"; } > "$SRC"
# the compiler emits IR to stdout; clang assembles and links it
"$LC" "$SRC" > "$OUT.ll" 2>/dev/null && ${LUDIC_CC:-clang} "$OUT.ll" -o "$OUT" 2>/dev/null
rc=$?; rm -f "$OUT.ll"; [ $rc -eq 0 ]

View file

@ -4,7 +4,7 @@
fn emit_init_component(e: ptr, comp: ptr, rec: Node) -> void {
let me = itoa(MAX_ENT)
let c = find_comp(comp)
if ptr_is_null(c) { perr(sconcat("spawn: unknown component ", comp)) }
if ptr_is_null(c) { perr(sconcat("spawn: unknown property ", comp)) }
let hp = nreg(); emit(" "); emit(hp); emit(" = getelementptr inbounds ["); emit(me); emit(" x i8], ptr @H_"); emit(comp); emit(", i32 0, i32 "); emit(e); emit("\n")
emit(" store i8 1, ptr "); emit(hp); emit("\n")
let slot = nreg()

File diff suppressed because it is too large Load diff

View file

@ -51,7 +51,7 @@ fn die(msg: ptr) -> void {
os_exit(1)
}
main {
entry {
let path = ptr_null()
let out = ptr_null()
let want = 0 # 0 = auto, 1 = windowed, 2 = headless

View file

@ -281,11 +281,13 @@ fn already_loaded(full: ptr) -> bool {
# `@pure`, `@deterministic`, … — one channel, not a zoo of prefix keywords.
fn parse_one_decl() -> void {
let is_export = false
let qspec: Node = ptr_null()
while is_op("@") {
pi = pi + 1; let a = eat_id() # collect a leading @annotation
if streq(a, "export") { is_export = true }
if is_op("(") { let d = 0 # optional @anno(args) — skipped
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } }
if streq(a, "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
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()
}
if is_id("import") { pi = pi + 1
@ -297,15 +299,22 @@ fn parse_one_decl() -> void {
}
if is_id("struct") { push(prog, parse_struct()); return }
if is_id("enum") { push(prog, parse_enum()); return }
if is_id("component") { push(prog, parse_component()); return }
if is_id("archetype") { push(prog, parse_archetype()); return }
if is_id("system") { push(prog, parse_system()); return }
if is_id("property") { push(prog, parse_component()); return }
if is_id("model") { push(prog, parse_archetype()); return }
if is_id("handler") {
let h = parse_system()
if 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("var") { push(prog, parse_var()); return }
if is_id("const") { push(prog, parse_const()); return }
if is_id("fn") { let f = parse_fn(); if is_export { f.ival = 1 }; push(prog, f); return }
if is_id("extern") { push(prog, parse_extern()); return }
if is_id("main") { push(prog, parse_main()); return }
if is_id("entry") { push(prog, parse_main()); return }
perr("expected declaration")
}
@ -340,9 +349,16 @@ fn parse_program() -> void {
loaded_paths = new []ptr
skipnl()
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() }
if is_id("game") or is_id("module") { pi = pi + 1; g_game_name = eat_id(); skipnl(); eat_op("{") }
# @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("{") }
while true {
skipnl()
if toks[pi].kind == TK_EOF { break }

View file

@ -84,6 +84,62 @@ fn parse_query_for() -> Node {
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 {
pi = pi + 1; let n = node(S_SPAWN); n.s = eat_id(); skipnl(); eat_op("{")
while true {

View file

@ -11,7 +11,7 @@ FRAGS="selfhost/str.ludic selfhost/buf.ludic selfhost/io.ludic selfhost/ast.ludi
selfhost/lex.ludic selfhost/parse.ludic selfhost/parse_game.ludic selfhost/emit_core.ludic selfhost/emit_head.ludic
selfhost/emit_addr.ludic selfhost/emit_intrin.ludic selfhost/emit_intrin2.ludic selfhost/emit_math.ludic selfhost/emit_new.ludic
selfhost/emit_expr.ludic selfhost/emit_stmt.ludic selfhost/emit_ecs.ludic selfhost/emit_query.ludic selfhost/emit_spawn.ludic selfhost/emit_game.ludic selfhost/emit_machine.ludic selfhost/emit_save.ludic selfhost/emit_ui.ludic selfhost/emit_decl.ludic selfhost/main.ludic"
{ echo "game SelfHost {"; for f in $FRAGS; do cat "$f"; echo; done; echo "}"; } > "$B/selfhost.ludic"
{ echo "program SelfHost {"; for f in $FRAGS; do cat "$f"; echo; done; echo "}"; } > "$B/selfhost.ludic"
if $CC selfhost/ludicc.seed.ll -o "$B/sh_old" 2>/dev/null; then
# compile once with the old seed, then AGAIN with the freshly built one so the
# seed is a fixed point of the NEW compiler, not a one-step image of the old.

View file

@ -1,6 +1,6 @@
game T {
program T {
fn fib(n: int) -> int { if n < 2 { return n }; return fib(n-1) + fib(n-2) }
main {
entry {
print_int(fib(10)) # 55
let s = 0
for i in 0 .. 10 { if i == 5 { break }; if i % 2 == 0 { continue }; s = s + i }

View file

@ -1,5 +1,5 @@
game T {
main {
program T {
entry {
let half = 0.5
let a = 1.5
print_int(flr(a + half)) # flr(2.0) = 2

View file

@ -1,9 +1,9 @@
game T {
program T {
fn classify(c: int) -> int {
match c { 65, 66 => { return 1 }; 67 => { return 2 }; _ => { return 9 } }
return 0
}
main {
entry {
print_int(classify(65)) # 1
print_int(classify(67)) # 2
print_int(classify(90)) # 9

View file

@ -1,5 +1,5 @@
game T {
main {
program T {
entry {
let xs = new []int
print_int(len(xs)) # 0
for i in 0 .. 20 { push(xs, i * i) }

View file

@ -1,7 +1,7 @@
game T {
program T {
struct P { x: int = 0, y: int = 7, next: P }
fn bump(p: P) -> void { p.x = p.x + 100 }
main {
entry {
let a = new P
print_int(a.y) # 7 default
a.x = 5

View file

@ -50,6 +50,11 @@ qsmoke() { # name
else bad "$n: $(tail -1 /tmp/qs.out)"; fi
}
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 ---
echo "== self-hosted front-end binaries (ludicc / ludic) =="
# The two commands are one multi-call native binary built from the seed with
@ -72,7 +77,7 @@ if ./ludicc examples/snake.ludic --emit-llvm -o /tmp/cli_snake.ll >/tmp/cli.out
else bad "ludicc --emit-llvm: $(tail -1 /tmp/cli.out)"; fi
# ludic compiles, runs, and forwards the program's exit code.
printf 'main { os_exit(42) }\n' > /tmp/cli_exit.ludic
printf 'entry { os_exit(42) }\n' > /tmp/cli_exit.ludic
./ludic /tmp/cli_exit.ludic >/tmp/cli.out 2>&1
if [ "$?" -eq 42 ]; then ok "ludic app.ludic -> compiles, runs, forwards exit code"
else bad "ludic run: expected exit 42, got $? ($(tail -1 /tmp/cli.out))"; fi

View file

@ -25,8 +25,8 @@ build_one() {
if [ -x "$OUT/$name" ]; then
newest=$(find "$SRC" -name '*.h' -o -name "$(basename "$main")" | while read -r f; do
[ "$f" -nt "$OUT/$name" ] && echo new
done)
[ -z "$newest" ] && { echo "$name: up to date"; return; }
done) || true
if [ -z "$newest" ]; then echo "$name: up to date"; return; fi
fi
echo "cc: $main -> $OUT/$name"
$CC $CFLAGS "$main" -o "$OUT/$name"

View file

@ -19,22 +19,22 @@ A bare fragment is wrapped first: a declaration list goes inside
import re, subprocess, sys, os, tempfile
LC = './build/ludicc'
DECL = ('game','module','component','archetype','system','edge','fn','pure',
'extern','const','var','ui','scene','import')
DECL = ('program','property','model','handler','enum','fn',
'extern','const','var','ui','struct','import')
def classify(body):
first = next((l.strip() for l in body.split('\n')
if l.strip() and not l.strip().startswith('#')), '')
head = first.split('(')[0].split()[0] if first else ''
if head in ('game','module'): return 'whole'
if head == 'program': return 'whole'
return 'decls' if head in DECL else 'stmts'
def wraps(body, kind):
"""Candidate framings, best guess first. An excerpt often mixes declarations
with loose statements, so both are tried and either parsing counts."""
if kind == 'whole': return [body]
as_decls = 'game DocCheck {\n' + body + '\n}\n'
as_stmts = 'game DocCheck {\n system DocS phase Start {\n' + body + '\n }\n}\n'
as_decls = 'program DocCheck {\n' + body + '\n}\n'
as_stmts = 'program DocCheck {\n handler DocS phase Start {\n' + body + '\n }\n}\n'
return [as_decls, as_stmts] if kind == 'decls' else [as_stmts, as_decls]
def fences(path):

View file

@ -11,8 +11,8 @@
;;; Code:
(defconst ludic--declaration-keywords
'("game" "module" "import" "component" "struct" "archetype" "enum" "ui"
"const" "var" "fn" "extern" "system" "main"))
'("program" "import" "property" "struct" "model" "enum" "ui"
"const" "var" "fn" "extern" "handler" "entry"))
(defconst ludic--clause-keywords
'("phase" "query" "reads" "writes"))

View file

@ -41,8 +41,8 @@ object LudicTokens {
*/
object LudicVocabulary {
val DECL = setOf(
"game", "module", "import", "component", "struct", "archetype", "enum", "ui",
"const", "var", "fn", "extern", "system", "main"
"program", "import", "property", "struct", "model", "enum", "ui",
"const", "var", "fn", "extern", "handler", "entry"
)
val CLAUSE = setOf(
"phase", "query", "reads", "writes"

View file

@ -54,21 +54,21 @@
"declaration": {
"patterns": [
{
"match": "\\b(game|module)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(program)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.unit.ludic" },
"2": { "name": "entity.name.type.unit.ludic" }
}
},
{
"match": "\\b(component)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(property)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.component.ludic" },
"2": { "name": "entity.name.type.component.ludic" }
}
},
{
"match": "\\b(archetype)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(model)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.archetype.ludic" },
"2": { "name": "entity.name.type.archetype.ludic" }
@ -82,7 +82,7 @@
}
},
{
"match": "\\b(system)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(handler)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.system.ludic" },
"2": { "name": "entity.name.function.system.ludic" }
@ -165,7 +165,7 @@
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\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": "storage.type.ludic", "match": "\\b(program|property|struct|model|enum|ui|const|var|let|fn|handler|entry|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

@ -54,21 +54,21 @@
"declaration": {
"patterns": [
{
"match": "\\b(game|module)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(program)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.unit.ludic" },
"2": { "name": "entity.name.type.unit.ludic" }
}
},
{
"match": "\\b(component)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(property)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.component.ludic" },
"2": { "name": "entity.name.type.component.ludic" }
}
},
{
"match": "\\b(archetype)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(model)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.archetype.ludic" },
"2": { "name": "entity.name.type.archetype.ludic" }
@ -82,7 +82,7 @@
}
},
{
"match": "\\b(system)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(handler)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.system.ludic" },
"2": { "name": "entity.name.function.system.ludic" }
@ -165,7 +165,7 @@
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\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": "storage.type.ludic", "match": "\\b(program|property|struct|model|enum|ui|const|var|let|fn|handler|entry|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

@ -421,14 +421,14 @@ static void lp_decl(LP* p, int parent){
}
return;
}
if (!strcmp(w, "component") || !strcmp(w, "archetype")){
int is_comp = !strcmp(w, "component");
if (!strcmp(w, "property") || !strcmp(w, "model")){
int is_comp = !strcmp(w, "property");
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, is_comp ? LS_COMPONENT : LS_ARCHETYPE, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
while (lp_kind(p) == LT_ANNO) lp_adv(p);
snprintf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %s {",
is_comp ? "component" : "archetype", D->sym[s].name);
is_comp ? "property" : "model", D->sym[s].name);
if (!lp_is(p, "{")) return;
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
@ -518,8 +518,8 @@ static void lp_decl(LP* p, int parent){
}
return;
}
if (!strcmp(w, "system") || !strcmp(w, "edge")){
if (!strcmp(w, "edge")){ lp_adv(p); if (!lp_is(p, "system")) return; }
if (!strcmp(w, "handler") || !strcmp(w, "edge")){
if (!strcmp(w, "edge")){ lp_adv(p); if (!lp_is(p, "handler")) return; }
lp_adv(p);
int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_SYSTEM, nt, parent);
@ -638,15 +638,15 @@ static void ldoc_parse(LDoc* D){
lp_skipnl(&p);
while (lp_is(&p, "import")) { lp_decl(&p, -1); lp_skipnl(&p); }
int unit_parent = -1;
if (lp_is(&p, "game") || lp_is(&p, "module")){
D->is_module = lp_is(&p, "module");
if (lp_is(&p, "program") || lp_is(&p, "program")){
D->is_module = lp_is(&p, "program");
D->is_unit = 1;
lp_adv(&p);
int nt = lp_name(&p);
if (nt >= 0){
unit_parent = lsym_add(D, LS_UNIT, nt, -1);
lcpy(D->unit, sizeof(D->unit), D->sym[unit_parent].name, (int)strlen(D->sym[unit_parent].name));
lcatf(D->sym[unit_parent].detail, sizeof(D->sym[unit_parent].detail), "%s %s", D->is_module ? "module" : "game", D->unit);
lcatf(D->sym[unit_parent].detail, sizeof(D->sym[unit_parent].detail), "%s %s", D->is_module ? "program" : "program", D->unit);
}
if (lp_is(&p, "{")){
int close = D->match[p.i];

View file

@ -1080,13 +1080,13 @@ static void on_completion(JVal* msg, JVal* id){
comp_builtins(&c);
comp_scope(&c, D, off, 0);
/* the shapes people actually type */
comp_item(&c, "system", CI_SNIPPET, "system … phase … { }", "A system with a phase and a body.",
comp_item(&c, "handler", CI_SNIPPET, "handler … phase … { }", "A system with a phase and a body.",
"system ${1:Name} phase ${2|Start,Input,FixedUpdate,Update,LateUpdate,Render|} {\n $0\n}");
comp_item(&c, "system+query", CI_SNIPPET, "system … query … { }", "A system that runs once per matching entity.",
comp_item(&c, "system+query", CI_SNIPPET, "handler … query … { }", "A system that runs once per matching entity.",
"system ${1:Name} phase ${2|Update,FixedUpdate,LateUpdate,Render,Input|}\n query (${3:p}) [${4:Pos}]\n{\n $0\n}");
comp_item(&c, "component", CI_SNIPPET, "component … { }", "A component with typed fields.",
comp_item(&c, "property", CI_SNIPPET, "property … { }", "A component with typed fields.",
"component ${1:Name} { ${2:x}: ${3:int} = ${4:0} }");
comp_item(&c, "archetype", CI_SNIPPET, "archetype … { }", "An entity kind bundling components.",
comp_item(&c, "model", CI_SNIPPET, "archetype … { }", "An entity kind bundling components.",
"archetype ${1:Name} {\n ${2:Pos}\n}");
comp_item(&c, "for-query", CI_SNIPPET, "for (…) in query […] { }", "Iterate matching entities.",
"for (${1:p}) in query [${2:Pos}] {\n $0\n}");

View file

@ -52,8 +52,8 @@ 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",
"const","var","fn","extern","system","main", 0
"program","import","property","struct","model","enum","ui",
"const","var","fn","extern","handler","entry", 0
};
static const char* LUDIC_KW_CLAUSE[] = {
"phase","query","reads","writes", 0

View file

@ -109,7 +109,7 @@ def main():
"signatureHelpProvider", "inlayHintProvider", "documentLinkProvider"]:
check_true(f"advertises {cap}", cap in caps)
# ---- a fragment file: no `game` block, only reachable through an import --
# ---- a fragment file: no `program` block, only reachable through an import --
frag = os.path.join(ROOT, "examples/chronorift/combat.ludic")
text = c.open(frag)
uri = {"uri": "file://" + frag}
@ -137,13 +137,13 @@ def main():
comp = c.request("textDocument/completion", {"textDocument": uri,
"position": position_of(text, "st.guard", 3)})
labels = [i["label"] for i in comp["items"]]
check_true("completion after '.' offers only that component's fields",
check_true("completion after '.' offers only that property's fields",
"guard" in labels and "hp" in labels and "clear" not in labels)
# a query's term list admits components and archetypes and nothing else —
# a query's term list admits propertys and models and nothing else —
# not the loop variables, not builtins, not keywords
qsrc = ('game QT {\n component Pos { x: int = 0 }\n component Vel { dx: int = 0 }\n'
' system S phase Update {\n for (a, b) in query [Pos, ] { }\n }\n}\n')
qsrc = ('program QT {\n property Pos { x: int = 0 }\n property Vel { dx: int = 0 }\n'
' handler S phase Update {\n for (a, b) in query [Pos, ] { }\n }\n}\n')
qfile = "/tmp/ludic_lsp_test_query.ludic"
with open(qfile, "w") as fh:
fh.write(qsrc)
@ -152,7 +152,7 @@ def main():
qcomp = c.request("textDocument/completion",
{"textDocument": quri, "position": position_of(qsrc, "[Pos, ]", 6)})
qlabels = sorted(i["label"] for i in qcomp["items"])
check("completion inside query terms lists components only", qlabels, ["Pos", "Vel"])
check("completion inside query terms lists propertys only", qlabels, ["Pos", "Vel"])
body = c.request("textDocument/completion",
{"textDocument": quri, "position": position_of(qsrc, "] { }", 3)})
blabels = [i["label"] for i in body["items"]]
@ -196,7 +196,7 @@ def main():
# any file and answers structural queries without a compiler present.)
bad = "/tmp/ludic_lsp_test_bad.ludic"
with open(bad, "w") as fh:
fh.write("game Bad {\n component P { x: nosuchtype = 0 }\n}\n")
fh.write("program Bad {\n property P { x: nosuchtype = 0 }\n}\n")
c.open(bad)
syms = c.request("textDocument/documentSymbol", {"textDocument": {"uri": "file://" + bad}})
check_true("LSP opens and analyses a file with no compiler present",
@ -206,7 +206,7 @@ def main():
# ---- markdown: ```ludic fences are real Ludic ---------------------------
md = "/tmp/ludic_lsp_test.md"
with open(md, "w") as fh:
fh.write("# Title\n\nprose\n\n```ludic\ngame D { component Pt { x: int = 0 } }\n```\n\n"
fh.write("# Title\n\nprose\n\n```ludic\nprogram D { property Pt { x: int = 0 } }\n```\n\n"
"```python\nz = 1\n```\n")
c.open(md, "markdown")
mduri = {"uri": "file://" + md}
@ -241,17 +241,17 @@ def main():
# Renaming any one of them must leave the other three alone.
amb = "/tmp/ludic_lsp_test_rename.ludic"
with open(amb, "w") as fh:
fh.write("game R {\n"
" component Pos { x: int = 0, y: int = 0 }\n"
" component Vel { x: int = 0 }\n"
fh.write("program R {\n"
" property Pos { x: int = 0, y: int = 0 }\n"
" property Vel { x: int = 0 }\n"
" fn helper(x: int) -> int { return x + 1 }\n"
" system A phase Update {\n"
" handler A phase Update {\n"
" for (p) in query [Pos] {\n"
" let x = 5\n"
" p.x = p.x + x\n"
" }\n"
" }\n"
" system B phase Update {\n"
" handler B phase Update {\n"
" for (v) in query [Vel] { v.x = v.x + 1 }\n"
" }\n"
"}\n")
@ -268,7 +268,7 @@ def main():
def lines_touched(edits):
return sorted({e["range"]["start"]["line"] for e in edits})
field = rename_at(position_of(atext, "component Pos { x", 16))
field = rename_at(position_of(atext, "property Pos { x", 15))
check("renaming Pos.x touches only its declaration and its uses",
lines_touched(field), [1, 7])
local = rename_at(position_of(atext, "let x = 5", 4))
@ -280,7 +280,7 @@ def main():
# find-usages must be equally precise
refs_x = c.request("textDocument/references",
{"textDocument": auri,
"position": position_of(atext, "component Vel { x", 16),
"position": position_of(atext, "property Vel { x", 15),
"context": {"includeDeclaration": True}})
check("find-usages of Vel.x does not pick up Pos.x",
sorted({r["range"]["start"]["line"] for r in refs_x}), [2, 11])
@ -288,7 +288,7 @@ def main():
# ---- workspace-wide symbol search ---------------------------------------
ws = c.request("workspace/symbol", {"query": "Stats"})
check_true("workspace symbol search finds a component", any(s["name"] == "Stats" for s in ws))
check_true("workspace symbol search finds a property", any(s["name"] == "Stats" for s in ws))
c.close()
print(f" {passed} passed, {failed} failed")

View file

@ -71,34 +71,34 @@ else bad "formatter altered non-whitespace bytes" "$DRIFT"; fi
# --- multi-byte characters must survive intact ------------------------------
# A UTF-8 character outside a string is an error to the compiler, but the
# formatter must still not split it: doing so silently corrupts the file.
printf 'game E {\n # \xe2\x80\xa6 ellipsis in a comment\n const S: str = "caf\xc3\xa9 \xe2\x86\x92 na\xc3\xafve"\n}\n' > "$TMP/utf8.ludic"
printf 'program E {\n # \xe2\x80\xa6 ellipsis in a comment\n const S: str = "caf\xc3\xa9 \xe2\x86\x92 na\xc3\xafve"\n}\n' > "$TMP/utf8.ludic"
"$FMT" "$TMP/utf8.ludic" > "$TMP/utf8.out"
if cmp -s "$TMP/utf8.ludic" "$TMP/utf8.out"; then ok "UTF-8 preserved byte for byte"
else bad "UTF-8 mangled" "$(diff "$TMP/utf8.ludic" "$TMP/utf8.out" | head -2)"; fi
# the same character where the compiler cannot make sense of it at all: it must
# still come out as one character, not as three spaced-out replacement bytes
printf 'game F {\nfn f() -> int { return \xe2\x80\xa6 }\n}\n' > "$TMP/utf8b.ludic"
printf 'program F {\nfn f() -> int { return \xe2\x80\xa6 }\n}\n' > "$TMP/utf8b.ludic"
a=$(tr -d ' \t\n' < "$TMP/utf8b.ludic" | shasum | cut -d' ' -f1)
b=$("$FMT" "$TMP/utf8b.ludic" | tr -d ' \t\n' | shasum | cut -d' ' -f1)
check "unrecognised characters pass through whole" "$b" "$a"
# --- comments survive, which is the whole reason this is not ludicc --fmt ---
printf 'game C {\n # keep me\n component P { x: int = 0 } # and me\n}\n' > "$TMP/c.ludic"
printf 'program C {\n # keep me\n property P { x: int = 0 } # and me\n}\n' > "$TMP/c.ludic"
N=$("$FMT" "$TMP/c.ludic" | grep -c '#')
check "comments preserved" "$N" "2"
# --- messy input is normalised ----------------------------------------------
printf 'game M{\ncomponent P{x:int=0}\nsystem S phase Update{let a=1+2\nif a== -1 {a=-a}}\n}\n' > "$TMP/m.ludic"
printf 'program M{\nproperty P{x:int=0}\nhandler S phase Update{let a=1+2\nif a== -1 {a=-a}}\n}\n' > "$TMP/m.ludic"
"$FMT" "$TMP/m.ludic" > "$TMP/m.out"
grep -q '^ component P { x: int = 0 }$' "$TMP/m.out" && ok "reindents and respaces" || bad "reindents and respaces"
grep -q '^ property P { x: int = 0 }$' "$TMP/m.out" && ok "reindents and respaces" || bad "reindents and respaces"
grep -q 'a = -a' "$TMP/m.out" && ok "unary minus not spaced" || bad "unary minus not spaced"
# --- language conventions are respected, not overwritten --------------------
printf 'game U {\n ui M {\n panel id=Root w=10 {\n button id=Go text="go"\n }\n }\n}\n' > "$TMP/u.ludic"
printf 'program U {\n ui M {\n panel id=Root w=10 {\n button id=Go text="go"\n }\n }\n}\n' > "$TMP/u.ludic"
"$FMT" "$TMP/u.ludic" | grep -q 'id=Root' && ok "widget props stay tight (id=Root)" || bad "widget props stay tight"
printf 'game Q {\n system S phase Update {\n for (p) in query [Pos, {Foe}] { }\n }\n component Pos { x: int = 0 }\n component Foe { }\n}\n' > "$TMP/q.ludic"
printf 'program Q {\n handler S phase Update {\n for (p) in query [Pos, {Foe}] { }\n }\n property Pos { x: int = 0 }\n property Foe { }\n}\n' > "$TMP/q.ludic"
"$FMT" "$TMP/q.ludic" | grep -q '\[Pos, {Foe}\]' && ok "query tags stay tight ({Foe})" || bad "query tags stay tight"
printf 'game A {\n const R_A: int = 0 # one\n const R_BBB: int = 1 # two\n}\n' > "$TMP/a.ludic"
printf 'program A {\n const R_A: int = 0 # one\n const R_BBB: int = 1 # two\n}\n' > "$TMP/a.ludic"
"$FMT" "$TMP/a.ludic" | grep -q 'R_A: int = 0 # one' && ok "hand alignment preserved" || bad "hand alignment preserved"
# --- --check exit status, for CI and pre-commit hooks -----------------------
@ -109,11 +109,11 @@ check "--check exits 1 on unformatted input" "$?" "1"
check "--check exits 0 after -w" "$?" "0"
# --- markdown: fences formatted, prose untouched ----------------------------
printf '# T\n\nprose with spaces\n\n```ludic\ngame D{component P{x:int=0}}\n```\n\n```python\nz = 1\n```\n' > "$TMP/d.md"
printf '# T\n\nprose with spaces\n\n```ludic\nprogram D{property P{x:int=0}}\n```\n\n```python\nz = 1\n```\n' > "$TMP/d.md"
"$FMT" "$TMP/d.md" > "$TMP/d.out"
grep -q 'prose with spaces' "$TMP/d.out" && ok "markdown prose untouched" || bad "markdown prose untouched"
grep -q 'z = 1' "$TMP/d.out" && ok "non-ludic fences untouched" || bad "non-ludic fences untouched"
grep -q 'game D { component P { x: int = 0 } }' "$TMP/d.out" && ok "ludic fences formatted" || bad "ludic fences formatted"
grep -q 'program D { property P { x: int = 0 } }' "$TMP/d.out" && ok "ludic fences formatted" || bad "ludic fences formatted"
echo "language server"
python3 tools/test-lsp.py