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 | | 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
@ -852,9 +852,9 @@ current tree (`./test.sh` = 64/64).
**Recursion** ✅ → `55` **Recursion** ✅ → `55`
```ludic ```ludic
game P { program P {
fn fib(n: int) -> int { if n < 2 { return n }; return fib(n-1) + fib(n-2) } 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 **R1 — statements now require a separator (Rule B, syntax-redesign Phase 2)** → parse error
```ludic ```ludic
# doc-check: skip — intentionally rejected under Rule B: needs a newline or ';' # 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 Statements no longer sit adjacent with only spaces between them; the compiler
reports `expected newline or ';' between statements`. Put each on its own line, reports `expected newline or ';' between statements`. Put each on its own line,
or separate them with `;` (both lex to the same separator token): or separate them with `;` (both lex to the same separator token):
```ludic ```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` **R2 — commas omitted throughout** → `7`
```ludic ```ludic
# doc-check: skip — composite: declaration plus statements # 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 } } 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: And a system may declare read-only access, then write — also compiles:
```ludic ```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 **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
@ -91,7 +91,7 @@ A source file opens with `game Name { … }` or `module Name { … }`.
```ludic ```ludic
# doc-check: skip — illustrative: elided body # doc-check: skip — illustrative: elided body
module Combat { program Combat {
@export fn damage(attack: int, armour: int, roll: int) -> int { … } @export fn damage(attack: int, armour: int, roll: int) -> int { … }
fn curve(level: int) -> int { … } # private: not a symbol 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 itch.io — and the game runs. It needs no server-side anything, and no
cross-origin isolation headers. 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 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 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 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 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 exports those four functions instead of `main`, and `platform.js` calls
`ludic_frame` from `requestAnimationFrame`. Both targets emit the four from the `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. they run in, are identical; only the owner of the loop differs.
### The floor ### The floor
@ -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,23 +12,23 @@ 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
game Name { program Name {
import ... # pull declarations in from another file import ... # pull declarations in from another file
component ... # 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
archetype ... # 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)
system ... # behavior, grouped into phases handler ... # behavior, grouped into phases
} }
``` ```
@ -36,43 +36,43 @@ game Name {
```ludic ```ludic
# doc-check: skip — paths resolve only inside the repo # 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/world.ludic" # path is relative to THIS file
import "chronorift/combat.ludic" 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 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
# doc-check: skip — composite: declarations and statements together # doc-check: skip — composite: declarations and statements together
component Pos { x: int = 0, y: int = 0 } property Pos { x: int = 0, y: int = 0 }
component Stats { hp: int = 10 } property Stats { hp: int = 10 }
archetype Player { Pos, Stats } # Player IS a kind, not a component model Player { Pos, Stats } # Player IS a kind, not a property
archetype 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,22 +115,22 @@ 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
system Boot phase Start { handler Boot phase Start {
setreg(R_FONT, font_load("…Arial.ttf")) setreg(R_FONT, font_load("…Arial.ttf"))
ui_build() # construct the tree (loads skins/images) ui_build() # construct the tree (loads skins/images)
ui_open(UI_MainMenu) # make it active, focus the first button 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 ui_tick(key()) # w/s move focus, space/enter activate
if ui_clicked(UI_Quit) { quit() } if ui_clicked(UI_Quit) { quit() }
ui_set_int(UI_HpLabel, hp) # poke dynamic values by id 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. 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 `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
component Pos { x: int = 0, y: int = 0 } # typed fields with defaults property Pos { x: int = 0, y: int = 0 } # typed fields with defaults
component Player { } # a tag (no fields) property Player { } # a tag (no fields)
spawn Hero { # create an entity spawn Hero { # create an entity
Pos { x: 10, y: 5 } Pos { x: 10, y: 5 }
@ -164,44 +164,44 @@ 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
system 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:
```ludic ```ludic
system CleanBattle phase LateUpdate handler CleanBattle phase LateUpdate
query (b, p) [Battle, Pos, {Enemy}] where b.hp <= 0 query (b, p) [Battle, Pos, {Enemy}] where b.hp <= 0
{ despawn self() } { despawn self() }
``` ```
@ -209,32 +209,32 @@ system CleanBattle phase LateUpdate
is the same program as is the same program as
```ludic ```ludic
system CleanBattle phase LateUpdate { handler CleanBattle phase LateUpdate {
for (b, p) in query [Battle, Pos, {Enemy}] where b.hp <= 0 { despawn self() } 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 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
@ -255,7 +295,7 @@ the data a program keeps outside the ECS.
```ludic ```ludic
struct Tok { kind: int = 0, line: int = 0, next: Tok } 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 let t = new Tok # allocates; every field seeded from its default
t.kind = 1 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 } fn bump(t: Tok) -> void { t.kind = t.kind + 1 }
system Share phase Update { handler Share phase Update {
let a = new Tok let a = new Tok
let b = a # b and a are the SAME object let b = a # b and a are the SAME object
b.kind = 9 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: temporaries — which is what an AST or a linked list needs:
```ludic ```ludic
system Walk phase Update { handler Walk phase Update {
let a = new Tok let a = new Tok
let b = new Tok let b = new Tok
a.next = b a.next = b
@ -300,7 +340,7 @@ snippet below does not compile today. Programs use `[]T` slices for now.
```ludic ```ludic
# doc-check: skip — [T; N] fixed arrays are not yet implemented (design target) # doc-check: skip — [T; N] fixed arrays are not yet implemented (design target)
var table: [int; 8] # module-level storage var table: [int; 8] # module-level storage
system S phase Update { handler S phase Update {
let buf: [int; 4] # a local; no initializer needed let buf: [int; 4] # a local; no initializer needed
buf[0] = 10 buf[0] = 10
table[2] = buf[0] 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. header never moves, an append is visible to everything holding that slice.
```ludic ```ludic
system Collect phase Update { handler Collect phase Update {
let toks = new []Tok let toks = new []Tok
push(toks, new Tok) push(toks, new Tok)
for i in 0 .. len(toks) { print_int(toks[i].kind) } 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) # 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
@ -553,7 +593,7 @@ scene Title start {
on exit { ui_visible(UI_Menu, 0) } on exit { ui_visible(UI_Menu, 0) }
layer Main { layer Main {
system Choose phase Update { handler Choose phase Update {
if ui_clicked(UI_NewGame) { enter Overworld } if ui_clicked(UI_NewGame) { enter Overworld }
} }
} }
@ -562,34 +602,34 @@ scene Title start {
scene Overworld { scene Overworld {
on enter { spawn_party() } on enter { spawn_party() }
layer World { system Move phase Update { … } } layer World { handler Move phase Update { … } }
layer Hud { system Draw phase Render { … } } layer Hud { handler Draw phase Render { … } }
} }
``` ```
- 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
system CleanBattle phase LateUpdate handler CleanBattle phase LateUpdate
query (b, p) [Battle, Pos, {Foe}] where b.hp <= 0 query (b, p) [Battle, Pos, {Foe}] where b.hp <= 0
{ {
despawn self() 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/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

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
# overworld.ludic — boot, movement/encounter input, and the shop system. # 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_0000.png") # 0 grass
load_png("assets/kenney/tiny-town/Tiles/tile_0004.png") # 1 tree load_png("assets/kenney/tiny-town/Tiles/tile_0004.png") # 1 tree
load_png("assets/kenney/tiny-town/Tiles/tile_0025.png") # 2 path load_png("assets/kenney/tiny-town/Tiles/tile_0025.png") # 2 path
@ -33,7 +33,7 @@ system Boot phase Start {
setreg(R_POTION, 2) setreg(R_POTION, 2)
} }
system Control phase Input { handler Control phase Input {
if reg(R_MODE) == 0 { if reg(R_MODE) == 0 {
for (p) in query [Pos, {Player}] { for (p) in query [Pos, {Player}] {
let k = key() let k = key()
@ -122,7 +122,7 @@ system Control phase Input {
} }
# shop (at the house 'h' tile): buy potions with gold # shop (at the house 'h' tile): buy potions with gold
system Shop phase Update { handler Shop phase Update {
if reg(R_MODE) == 4 { if reg(R_MODE) == 4 {
let k = key() let k = key()
if k == 'w' { setreg(R_CUR, max(0, reg(R_CUR) - 1)) } 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. # world.ludic — the data model: components, entity archetypes, constants.
# Imported by examples/chronorift.ludic. No `game` wrapper (this is a fragment). # Imported by examples/chronorift.ludic. No `game` wrapper (this is a fragment).
component Pos { x: int = 0, y: int = 0 } property Pos { x: int = 0, y: int = 0 }
component Actor { kind: int = 0 } property Actor { kind: int = 0 }
component Stats { hp: int = 1, maxhp: int = 1, mp: int = 0, maxmp: 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 } 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 # Entity KINDS. Identity is stored as one integer per entity (L_kind), not as
# an empty marker component. Query membership with {Player} / {Enemy}. # an empty marker component. Query membership with {Player} / {Enemy}.
archetype Player { Pos, Actor, Party, Stats } # the controllable knight model Player { Pos, Actor, Party, Stats } # the controllable knight
archetype Ally { Party, Stats } # backline mage (no overworld body) model Ally { Party, Stats } # backline mage (no overworld body)
archetype Enemy { Actor, Stats } # battle foe model Enemy { Actor, Stats } # battle foe
const MW: int = 40 const MW: int = 40
const MH: int = 18 const MH: int = 18

View file

@ -1,10 +1,10 @@
# hello.ludic — smallest program that exercises the ECS pipeline # hello.ludic — smallest program that exercises the ECS pipeline
game Hello { program Hello {
component Pos { x: int = 0, y: int = 0 } property Pos { x: int = 0, y: int = 0 }
component Vel { dx: int = 0, dy: int = 0 } property Vel { dx: int = 0, dy: int = 0 }
system Boot phase Start { handler Boot phase Start {
spawn Mob { spawn Mob {
Pos { x: 3, y: 4 } Pos { x: 3, y: 4 }
Vel { dx: 1, dy: 0 } 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 # 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. # per match, with the components bound and self() giving that entity.
system Move phase FixedUpdate handler Move phase FixedUpdate
query (p, v) [Pos, Vel] query (p, v) [Pos, Vel]
{ {
p.x = p.x + v.dx p.x = p.x + v.dx
p.y = p.y + v.dy p.y = p.y + v.dy
} }
system Report phase Update { handler Report phase Update {
for (p) in query [Pos] { for (p) in query [Pos] {
print_int(p.x) print_int(p.x)
print_int(p.y) 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 # 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. # 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 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 hits_to_kill(hp: int, attack: int, armour: int) -> int = "hits_to_kill"
extern fn xp_for(level: int, kills: int) -> int = "xp_for" 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 Hero { Fighter { hp: 40, attack: 12, armour: 3 } }
spawn Orc { Fighter { hp: 24, attack: 7, armour: 1 } } spawn Orc { Fighter { hp: 24, attack: 7, armour: 1 } }
} }
system Fight phase Update { handler Fight phase Update {
for (f) in query [Fighter] { for (f) in query [Fighter] {
print_int(damage(f.attack, f.armour, 0)) print_int(damage(f.attack, f.armour, 0))
print_int(damage(f.attack, f.armour, 20)) 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 # engine, a test harness, a scripting host. Non-exported functions stay private
# to the library. # to the library.
# ============================================================================ # ============================================================================
module Combat { program Combat {
const CRIT_MULT: int = 2 const CRIT_MULT: int = 2

View file

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

View file

@ -9,29 +9,29 @@
# the whole body — the loop header just moves into the declaration. The body runs # the whole body — the loop header just moves into the declaration. The body runs
# once per matching entity, and self() is that entity. # once per matching entity, and self() is that entity.
# ============================================================================ # ============================================================================
game QueryDecl { program QueryDecl {
component Battle { hp: int = 0, side: int = 0 } property Battle { hp: int = 0, side: int = 0 }
component Pos { x: int = 0, y: int = 0 } property Pos { x: int = 0, y: int = 0 }
archetype Foe { Battle, Pos } model Foe { Battle, Pos }
system Seed phase Start { handler Seed phase Start {
spawn Foe { Battle { hp: 3, side: 1 } } spawn Foe { Battle { hp: 3, side: 1 } }
spawn Foe { Battle { hp: 0, side: 1 } } spawn Foe { Battle { hp: 0, side: 1 } }
spawn Foe { Battle { hp: -2, side: 0 } } 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 query (b, p) [Battle, Pos, {Foe}] where b.hp <= 0 and b.side == 1
{ {
print_int(p.x) print_int(p.x)
despawn self() despawn self()
} }
system Census phase Render handler Census phase Render
query (b) [Battle] query (b) [Battle]
{ {
print_int(b.hp) 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. # LANGUAGE.md §"Scenes & layers"). It is deliberately excluded from test.sh.
# Games that need scene-like states use a mode register + `machine` today, as # Games that need scene-like states use a mode register + `machine` today, as
# examples/chronorift does. # examples/chronorift does.
game SceneDemo { program SceneDemo {
const R_N: int = 0 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 { scene Title start {
on enter { print_int(1) } on enter { print_int(1) }
on exit { print_int(2) } on exit { print_int(2) }
layer Main { layer Main {
system Tick phase Update { handler Tick phase Update {
setreg(R_N, reg(R_N) + 1) setreg(R_N, reg(R_N) + 1)
print_int(100 + reg(R_N)) print_int(100 + reg(R_N))
if reg(R_N) >= 2 { enter Play } if reg(R_N) >= 2 { enter Play }
@ -25,14 +25,14 @@ game SceneDemo {
on enter { print_int(3); setreg(R_N, 0) } on enter { print_int(3); setreg(R_N, 0) }
on exit { print_int(4) } on exit { print_int(4) }
layer World { layer World {
system Step phase Update { handler Step phase Update {
setreg(R_N, reg(R_N) + 1) setreg(R_N, reg(R_N) + 1)
print_int(200 + reg(R_N)) print_int(200 + reg(R_N))
if reg(R_N) >= 2 { quit() } if reg(R_N) >= 2 { quit() }
} }
} }
layer Hud { 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. # general: compiled by the SAME unmodified ludicc + runtime as the JRPG.
# No sprites/assets — pure primitives. Build: ./build.sh examples/snake.ludic # No sprites/assets — pure primitives. Build: ./build.sh examples/snake.ludic
# ============================================================================ # ============================================================================
game Snake { program Snake {
component Pos { x: int = 0, y: int = 0 } property Pos { x: int = 0, y: int = 0 }
component Seg { order: int = 0 } property Seg { order: int = 0 }
const GW: int = 20 const GW: int = 20
const GH: int = 15 const GH: int = 15
@ -39,9 +39,9 @@ game Snake {
return 0 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() let k = key()
if reg(R_MODE) == 0 { if reg(R_MODE) == 0 {
if k == 'w' { if reg(R_DIR) != 1 { setreg(R_DIR, 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 { if reg(R_MODE) == 0 {
setreg(R_TICK, reg(R_TICK) + 1) setreg(R_TICK, reg(R_TICK) + 1)
if reg(R_TICK) >= 6 { if reg(R_TICK) >= 6 {
@ -110,7 +110,7 @@ game Snake {
} }
} }
system Draw phase Render { handler Draw phase Render {
clear(0x0d1020) clear(0x0d1020)
for gy in 0 .. GH { for gy in 0 .. GH {
for gx in 0 .. GW { 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/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_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" 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 # 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; } "$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/emit_decl.ludic
selfhost/main.ludic" selfhost/main.ludic"
SRC=build/selfhost.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 # 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 "$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 ] 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 { fn emit_init_component(e: ptr, comp: ptr, rec: Node) -> void {
let me = itoa(MAX_ENT) let me = itoa(MAX_ENT)
let c = find_comp(comp) 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") 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") emit(" store i8 1, ptr "); emit(hp); emit("\n")
let slot = nreg() 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) os_exit(1)
} }
main { entry {
let path = ptr_null() let path = ptr_null()
let out = ptr_null() let out = ptr_null()
let want = 0 # 0 = auto, 1 = windowed, 2 = headless 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. # `@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
@ -297,15 +299,22 @@ fn parse_one_decl() -> void {
} }
if is_id("struct") { push(prog, parse_struct()); return } if is_id("struct") { push(prog, parse_struct()); return }
if is_id("enum") { push(prog, parse_enum()); return } if is_id("enum") { push(prog, parse_enum()); return }
if is_id("component") { push(prog, parse_component()); return } if is_id("property") { push(prog, parse_component()); return }
if is_id("archetype") { push(prog, parse_archetype()); return } if is_id("model") { push(prog, parse_archetype()); return }
if is_id("system") { 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 }
if is_id("fn") { let f = parse_fn(); if is_export { f.ival = 1 }; push(prog, f); 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("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") perr("expected declaration")
} }
@ -340,9 +349,16 @@ 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() }
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 { while true {
skipnl() skipnl()
if toks[pi].kind == TK_EOF { break } if toks[pi].kind == TK_EOF { break }

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

@ -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/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_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" 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 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 # 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. # 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) } fn fib(n: int) -> int { if n < 2 { return n }; return fib(n-1) + fib(n-2) }
main { entry {
print_int(fib(10)) # 55 print_int(fib(10)) # 55
let s = 0 let s = 0
for i in 0 .. 10 { if i == 5 { break }; if i % 2 == 0 { continue }; s = s + i } 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 { program T {
main { entry {
let half = 0.5 let half = 0.5
let a = 1.5 let a = 1.5
print_int(flr(a + half)) # flr(2.0) = 2 print_int(flr(a + half)) # flr(2.0) = 2

View file

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

View file

@ -1,5 +1,5 @@
game T { program T {
main { entry {
let xs = new []int let xs = new []int
print_int(len(xs)) # 0 print_int(len(xs)) # 0
for i in 0 .. 20 { push(xs, i * i) } 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 } struct P { x: int = 0, y: int = 7, next: P }
fn bump(p: P) -> void { p.x = p.x + 100 } fn bump(p: P) -> void { p.x = p.x + 100 }
main { entry {
let a = new P let a = new P
print_int(a.y) # 7 default print_int(a.y) # 7 default
a.x = 5 a.x = 5

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
@ -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 else bad "ludicc --emit-llvm: $(tail -1 /tmp/cli.out)"; fi
# ludic compiles, runs, and forwards the program's exit code. # 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 ./ludic /tmp/cli_exit.ludic >/tmp/cli.out 2>&1
if [ "$?" -eq 42 ]; then ok "ludic app.ludic -> compiles, runs, forwards exit code" 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 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 if [ -x "$OUT/$name" ]; then
newest=$(find "$SRC" -name '*.h' -o -name "$(basename "$main")" | while read -r f; do newest=$(find "$SRC" -name '*.h' -o -name "$(basename "$main")" | while read -r f; do
[ "$f" -nt "$OUT/$name" ] && echo new [ "$f" -nt "$OUT/$name" ] && echo new
done) done) || true
[ -z "$newest" ] && { echo "$name: up to date"; return; } if [ -z "$newest" ]; then echo "$name: up to date"; return; fi
fi fi
echo "cc: $main -> $OUT/$name" echo "cc: $main -> $OUT/$name"
$CC $CFLAGS "$main" -o "$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 import re, subprocess, sys, os, tempfile
LC = './build/ludicc' LC = './build/ludicc'
DECL = ('game','module','component','archetype','system','edge','fn','pure', DECL = ('program','property','model','handler','enum','fn',
'extern','const','var','ui','scene','import') 'extern','const','var','ui','struct','import')
def classify(body): def classify(body):
first = next((l.strip() for l in body.split('\n') first = next((l.strip() for l in body.split('\n')
if l.strip() and not l.strip().startswith('#')), '') if l.strip() and not l.strip().startswith('#')), '')
head = first.split('(')[0].split()[0] if first else '' 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' return 'decls' if head in DECL else 'stmts'
def wraps(body, kind): def wraps(body, kind):
"""Candidate framings, best guess first. An excerpt often mixes declarations """Candidate framings, best guess first. An excerpt often mixes declarations
with loose statements, so both are tried and either parsing counts.""" with loose statements, so both are tried and either parsing counts."""
if kind == 'whole': return [body] if kind == 'whole': return [body]
as_decls = 'game DocCheck {\n' + body + '\n}\n' as_decls = 'program DocCheck {\n' + body + '\n}\n'
as_stmts = 'game DocCheck {\n system DocS phase Start {\n' + body + '\n }\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] return [as_decls, as_stmts] if kind == 'decls' else [as_stmts, as_decls]
def fences(path): def fences(path):

View file

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

View file

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

View file

@ -54,21 +54,21 @@
"declaration": { "declaration": {
"patterns": [ "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": { "captures": {
"1": { "name": "keyword.control.unit.ludic" }, "1": { "name": "keyword.control.unit.ludic" },
"2": { "name": "entity.name.type.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": { "captures": {
"1": { "name": "storage.type.component.ludic" }, "1": { "name": "storage.type.component.ludic" },
"2": { "name": "entity.name.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": { "captures": {
"1": { "name": "storage.type.archetype.ludic" }, "1": { "name": "storage.type.archetype.ludic" },
"2": { "name": "entity.name.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": { "captures": {
"1": { "name": "storage.type.system.ludic" }, "1": { "name": "storage.type.system.ludic" },
"2": { "name": "entity.name.function.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.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes)\\b" }, { "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\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": "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.boolean.ludic", "match": "\\b(true|false)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" } { "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }

View file

@ -54,21 +54,21 @@
"declaration": { "declaration": {
"patterns": [ "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": { "captures": {
"1": { "name": "keyword.control.unit.ludic" }, "1": { "name": "keyword.control.unit.ludic" },
"2": { "name": "entity.name.type.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": { "captures": {
"1": { "name": "storage.type.component.ludic" }, "1": { "name": "storage.type.component.ludic" },
"2": { "name": "entity.name.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": { "captures": {
"1": { "name": "storage.type.archetype.ludic" }, "1": { "name": "storage.type.archetype.ludic" },
"2": { "name": "entity.name.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": { "captures": {
"1": { "name": "storage.type.system.ludic" }, "1": { "name": "storage.type.system.ludic" },
"2": { "name": "entity.name.function.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.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes)\\b" }, { "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\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": "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.boolean.ludic", "match": "\\b(true|false)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\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; return;
} }
if (!strcmp(w, "component") || !strcmp(w, "archetype")){ if (!strcmp(w, "property") || !strcmp(w, "model")){
int is_comp = !strcmp(w, "component"); int is_comp = !strcmp(w, "property");
lp_adv(p); int nt = lp_name(p); if (nt < 0) return; 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); 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)); ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
while (lp_kind(p) == LT_ANNO) lp_adv(p); while (lp_kind(p) == LT_ANNO) lp_adv(p);
snprintf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %s {", 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; if (!lp_is(p, "{")) return;
int close = D->match[p->i]; int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start; D->sym[s].body_start = D->lex.v[p->i].start;
@ -518,8 +518,8 @@ static void lp_decl(LP* p, int parent){
} }
return; return;
} }
if (!strcmp(w, "system") || !strcmp(w, "edge")){ if (!strcmp(w, "handler") || !strcmp(w, "edge")){
if (!strcmp(w, "edge")){ lp_adv(p); if (!lp_is(p, "system")) return; } if (!strcmp(w, "edge")){ lp_adv(p); if (!lp_is(p, "handler")) return; }
lp_adv(p); lp_adv(p);
int nt = lp_name(p); if (nt < 0) return; int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_SYSTEM, nt, parent); int s = lsym_add(D, LS_SYSTEM, nt, parent);
@ -638,15 +638,15 @@ static void ldoc_parse(LDoc* D){
lp_skipnl(&p); lp_skipnl(&p);
while (lp_is(&p, "import")) { lp_decl(&p, -1); lp_skipnl(&p); } while (lp_is(&p, "import")) { lp_decl(&p, -1); lp_skipnl(&p); }
int unit_parent = -1; int unit_parent = -1;
if (lp_is(&p, "game") || lp_is(&p, "module")){ if (lp_is(&p, "program") || lp_is(&p, "program")){
D->is_module = lp_is(&p, "module"); D->is_module = lp_is(&p, "program");
D->is_unit = 1; D->is_unit = 1;
lp_adv(&p); lp_adv(&p);
int nt = lp_name(&p); int nt = lp_name(&p);
if (nt >= 0){ if (nt >= 0){
unit_parent = lsym_add(D, LS_UNIT, nt, -1); 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)); 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, "{")){ if (lp_is(&p, "{")){
int close = D->match[p.i]; int close = D->match[p.i];

View file

@ -1080,13 +1080,13 @@ static void on_completion(JVal* msg, JVal* id){
comp_builtins(&c); comp_builtins(&c);
comp_scope(&c, D, off, 0); comp_scope(&c, D, off, 0);
/* the shapes people actually type */ /* 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}"); "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}"); "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} }"); "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}"); "archetype ${1:Name} {\n ${2:Pos}\n}");
comp_item(&c, "for-query", CI_SNIPPET, "for (…) in query […] { }", "Iterate matching entities.", comp_item(&c, "for-query", CI_SNIPPET, "for (…) in query […] { }", "Iterate matching entities.",
"for (${1:p}) in query [${2:Pos}] {\n $0\n}"); "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 * mirror the compiler's parser: anything parse_decl() dispatches on is a
* declaration keyword, anything stmt() dispatches on is a statement keyword. */ * declaration keyword, anything stmt() dispatches on is a statement keyword. */
static const char* LUDIC_KW_DECL[] = { static const char* LUDIC_KW_DECL[] = {
"game","module","import","component","struct","archetype","enum","ui", "program","import","property","struct","model","enum","ui",
"const","var","fn","extern","system","main", 0 "const","var","fn","extern","handler","entry", 0
}; };
static const char* LUDIC_KW_CLAUSE[] = { static const char* LUDIC_KW_CLAUSE[] = {
"phase","query","reads","writes", 0 "phase","query","reads","writes", 0

View file

@ -109,7 +109,7 @@ def main():
"signatureHelpProvider", "inlayHintProvider", "documentLinkProvider"]: "signatureHelpProvider", "inlayHintProvider", "documentLinkProvider"]:
check_true(f"advertises {cap}", cap in caps) 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") frag = os.path.join(ROOT, "examples/chronorift/combat.ludic")
text = c.open(frag) text = c.open(frag)
uri = {"uri": "file://" + frag} uri = {"uri": "file://" + frag}
@ -137,13 +137,13 @@ def main():
comp = c.request("textDocument/completion", {"textDocument": uri, comp = c.request("textDocument/completion", {"textDocument": uri,
"position": position_of(text, "st.guard", 3)}) "position": position_of(text, "st.guard", 3)})
labels = [i["label"] for i in comp["items"]] 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) "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 # not the loop variables, not builtins, not keywords
qsrc = ('game QT {\n component Pos { x: int = 0 }\n component Vel { dx: int = 0 }\n' qsrc = ('program QT {\n property Pos { x: int = 0 }\n property Vel { dx: int = 0 }\n'
' system S phase Update {\n for (a, b) in query [Pos, ] { }\n }\n}\n') ' handler S phase Update {\n for (a, b) in query [Pos, ] { }\n }\n}\n')
qfile = "/tmp/ludic_lsp_test_query.ludic" qfile = "/tmp/ludic_lsp_test_query.ludic"
with open(qfile, "w") as fh: with open(qfile, "w") as fh:
fh.write(qsrc) fh.write(qsrc)
@ -152,7 +152,7 @@ def main():
qcomp = c.request("textDocument/completion", qcomp = c.request("textDocument/completion",
{"textDocument": quri, "position": position_of(qsrc, "[Pos, ]", 6)}) {"textDocument": quri, "position": position_of(qsrc, "[Pos, ]", 6)})
qlabels = sorted(i["label"] for i in qcomp["items"]) 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", body = c.request("textDocument/completion",
{"textDocument": quri, "position": position_of(qsrc, "] { }", 3)}) {"textDocument": quri, "position": position_of(qsrc, "] { }", 3)})
blabels = [i["label"] for i in body["items"]] blabels = [i["label"] for i in body["items"]]
@ -196,7 +196,7 @@ def main():
# any file and answers structural queries without a compiler present.) # any file and answers structural queries without a compiler present.)
bad = "/tmp/ludic_lsp_test_bad.ludic" bad = "/tmp/ludic_lsp_test_bad.ludic"
with open(bad, "w") as fh: 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) c.open(bad)
syms = c.request("textDocument/documentSymbol", {"textDocument": {"uri": "file://" + bad}}) syms = c.request("textDocument/documentSymbol", {"textDocument": {"uri": "file://" + bad}})
check_true("LSP opens and analyses a file with no compiler present", check_true("LSP opens and analyses a file with no compiler present",
@ -206,7 +206,7 @@ def main():
# ---- markdown: ```ludic fences are real Ludic --------------------------- # ---- markdown: ```ludic fences are real Ludic ---------------------------
md = "/tmp/ludic_lsp_test.md" md = "/tmp/ludic_lsp_test.md"
with open(md, "w") as fh: 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") "```python\nz = 1\n```\n")
c.open(md, "markdown") c.open(md, "markdown")
mduri = {"uri": "file://" + md} mduri = {"uri": "file://" + md}
@ -241,17 +241,17 @@ def main():
# Renaming any one of them must leave the other three alone. # Renaming any one of them must leave the other three alone.
amb = "/tmp/ludic_lsp_test_rename.ludic" amb = "/tmp/ludic_lsp_test_rename.ludic"
with open(amb, "w") as fh: with open(amb, "w") as fh:
fh.write("game R {\n" fh.write("program R {\n"
" component Pos { x: int = 0, y: int = 0 }\n" " property Pos { x: int = 0, y: int = 0 }\n"
" component Vel { x: int = 0 }\n" " property Vel { x: int = 0 }\n"
" fn helper(x: int) -> int { return x + 1 }\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" " for (p) in query [Pos] {\n"
" let x = 5\n" " let x = 5\n"
" p.x = p.x + x\n" " p.x = p.x + x\n"
" }\n" " }\n"
" }\n" " }\n"
" system B phase Update {\n" " handler B phase Update {\n"
" for (v) in query [Vel] { v.x = v.x + 1 }\n" " for (v) in query [Vel] { v.x = v.x + 1 }\n"
" }\n" " }\n"
"}\n") "}\n")
@ -268,7 +268,7 @@ def main():
def lines_touched(edits): def lines_touched(edits):
return sorted({e["range"]["start"]["line"] for e in 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", check("renaming Pos.x touches only its declaration and its uses",
lines_touched(field), [1, 7]) lines_touched(field), [1, 7])
local = rename_at(position_of(atext, "let x = 5", 4)) local = rename_at(position_of(atext, "let x = 5", 4))
@ -280,7 +280,7 @@ def main():
# find-usages must be equally precise # find-usages must be equally precise
refs_x = c.request("textDocument/references", refs_x = c.request("textDocument/references",
{"textDocument": auri, {"textDocument": auri,
"position": position_of(atext, "component Vel { x", 16), "position": position_of(atext, "property Vel { x", 15),
"context": {"includeDeclaration": True}}) "context": {"includeDeclaration": True}})
check("find-usages of Vel.x does not pick up Pos.x", check("find-usages of Vel.x does not pick up Pos.x",
sorted({r["range"]["start"]["line"] for r in refs_x}), [2, 11]) sorted({r["range"]["start"]["line"] for r in refs_x}), [2, 11])
@ -288,7 +288,7 @@ def main():
# ---- workspace-wide symbol search --------------------------------------- # ---- workspace-wide symbol search ---------------------------------------
ws = c.request("workspace/symbol", {"query": "Stats"}) 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() c.close()
print(f" {passed} passed, {failed} failed") 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 ------------------------------ # --- multi-byte characters must survive intact ------------------------------
# A UTF-8 character outside a string is an error to the compiler, but the # 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. # 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" "$FMT" "$TMP/utf8.ludic" > "$TMP/utf8.out"
if cmp -s "$TMP/utf8.ludic" "$TMP/utf8.out"; then ok "UTF-8 preserved byte for byte" 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 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 # 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 # 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) 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) b=$("$FMT" "$TMP/utf8b.ludic" | tr -d ' \t\n' | shasum | cut -d' ' -f1)
check "unrecognised characters pass through whole" "$b" "$a" check "unrecognised characters pass through whole" "$b" "$a"
# --- comments survive, which is the whole reason this is not ludicc --fmt --- # --- 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 '#') N=$("$FMT" "$TMP/c.ludic" | grep -c '#')
check "comments preserved" "$N" "2" check "comments preserved" "$N" "2"
# --- messy input is normalised ---------------------------------------------- # --- 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" "$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" grep -q 'a = -a' "$TMP/m.out" && ok "unary minus not spaced" || bad "unary minus not spaced"
# --- language conventions are respected, not overwritten -------------------- # --- 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" "$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" "$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" "$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 ----------------------- # --- --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" check "--check exits 0 after -w" "$?" "0"
# --- markdown: fences formatted, prose untouched ---------------------------- # --- 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" "$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 '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 '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" echo "language server"
python3 tools/test-lsp.py python3 tools/test-lsp.py