Phase 6b: a handler's query is @Queries only; drop the signature clauses

The handler-signature `query (vars) [terms] where …` clause and the
`reads`/`writes` clauses overlapped the `@Queries` annotation (and each other):
two ways to attach a query to a handler. Consolidated on the decorator.

  - parse_system no longer parses `query`/`reads`/`writes` clauses; it keeps the
    postfix `@anno(...)` channel and `phase`. A handler's query is the prefix
    `@Queries(these: [...], on: Model)` annotation. Data-access hints are now
    `@Reads(...)`/`@Writes(...)` — absorbed by the generic annotation skipper,
    same parse-and-reserve status the old clauses had.
  - The inline `for (…) in query […] where …` statement is unchanged and still
    covers cross-property constraints / multiple kind filters. `query` stays a
    keyword there (now dispatched via is_id so the vocabulary check sees it).

examples/hello.ludic and examples/qdecl.ludic migrated to `@Queries` (qdecl now
demonstrates a per-property constraint + `on:` tag); outputs unchanged
(4 4 10 3 / 0 3 -2). LANGUAGE.md handler sections rewritten. Vocabulary: drop
`reads`/`writes` from CLAUSE (ludic_syntax.h, grammar, LudicTokens.kt).

Reseeded (21931 lines); C-free fixpoint holds; goldens identical; 17/17;
vocab + doc-fences clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 23:20:09 +03:00
parent da48dc8499
commit 3fd599ce47
10 changed files with 2829 additions and 3071 deletions

View file

@ -175,49 +175,50 @@ program. `self()` yields the entity of the innermost `query` loop.
## Handlers & phases ## Handlers & phases
```ludic ```ludic
handler Move @deterministic @Queries(these: [Pos, Vel]) # the entities this handler operates on
reads [Vel] # declared data access (parsed and reserved; not yet @Writes(Pos) # declared data access (parsed and reserved; not
writes [Pos] # consumed by any analysis pass — see "Not yet implemented") @Reads(Vel) # yet consumed by any analysis pass)
phase FixedUpdate handler Move @deterministic phase FixedUpdate
query (p, v) [Pos, Vel] # the entities this handler operates on { Pos.x = Pos.x + Vel.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 `handler` 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 — Everything a handler declares beyond its `phase` is an `@annotation` — the
`@export fn …` (a C-ABI-exported function), `@edge handler …`, `@deterministic`, handler's query, its data access, and its modifiers all use one uniform channel
`@pure`. They parse into one uniform channel rather than a set of prefix rather than a mix of prefix keywords and signature clauses. `@export fn …`
keywords. (`@export` sets the export flag; the others parse but have no codegen (a C-ABI-exported function), `@edge handler …`, `@deterministic`, `@pure`,
effect in the self-hosted compiler yet.) `@Reads(...)`, `@Writes(...)`. (`@export` sets the export flag; the others parse
but have no codegen effect in the self-hosted compiler yet.)
### The `query` clause ### Declaring a handler's query (`@Queries`)
A handler declares the entities it works on, alongside its phase. The body then `@Queries` declares the entities a handler works on. The body then runs **once
runs **once per matching entity**, with the properties bound and `self()` giving per matching entity**, with each property bound by its own name and `self()`
that entity — the query header is simply hoisted out of the body into the giving that entity — the query header lifts out of the body into an annotation:
signature:
```ludic ```ludic
handler CleanBattle phase LateUpdate # doc-check: skip — illustrative handler
query (b, p) [Battle, Pos, {Enemy}] where b.hp <= 0 @Queries(these: [Battle{hp <= 0}, Pos], on: Enemy)
{ despawn self() } handler CleanBattle phase LateUpdate { despawn self() }
``` ```
is the same program as is the same program as
```ludic ```ludic
handler CleanBattle phase LateUpdate { handler CleanBattle phase LateUpdate {
for (b, p) in query [Battle, Pos, {Enemy}] where b.hp <= 0 { despawn self() } for (Battle, Pos) in query [Battle, Pos, {Enemy}] where Battle.hp <= 0 { despawn self() }
} }
``` ```
Drop `(vars)` when nothing binds: `query [{Enemy}]`. A handler declares at most `these:` lists the bound properties; a `Prop{constraint}` qualifies its bare
one query, and the number of variables must equal the number of binding terms field names to that property (`Battle{hp <= 0}` → `Battle.hp <= 0`). `on: Model`
(`{Tag}` terms filter without binding, so they don't count). A handler with no adds a `{Model}` kind filter. A handler with no `@Queries` runs once per tick.
`query` clause runs once per tick, as before. For a constraint that spans two properties (`Pos.x > Vel.dx`), or several kind
filters, write the loop out with an inline `for (…) in query […] where …`
instead — `@Queries` covers the common per-property case.
### Conditions ### Conditions
@ -226,16 +227,17 @@ 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 handler clause, not a whole declaration # doc-check: skip — illustrative @Queries constraint
query (b, s) [Battle, Stats] where b.hp <= 0 and s.level > 3 @Queries(these: [Battle{hp <= 0}, Stats{level > 3}])
``` ```
The same `where` works on an inline `for (…) in query […]`. The same `where` works on an inline `for (…) in query […]`; in `@Queries` the
equivalent is a per-property `Prop{constraint}`.
`where` is evaluated **per candidate entity**, so it is the wrong place for a A constraint is evaluated **per candidate entity**, so it is the wrong place for
guard that concerns the whole handler (`where reg(R_MODE) != 1` would re-read the a guard that concerns the whole handler (re-reading `reg(R_MODE)` for every
register for every entity). Keep whole-handler guards in the body of a handler entity). Keep whole-handler guards in the body of a handler with no `@Queries`,
with no `query` clause, wrapping an inline query — as `CleanBattle` does in wrapping an inline query — as `CleanBattle` does in
`examples/chronorift/combat.ludic`. `examples/chronorift/combat.ludic`.
### Matching is lazy, not snapshotted ### Matching is lazy, not snapshotted
@ -703,21 +705,19 @@ scene Overworld {
## Queries in a handler signature ## Queries in a handler signature
When a handler'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 lifts into a
declaration: `@Queries` annotation (see "Declaring a handler's query" above):
```ludic ```ludic
handler CleanBattle phase LateUpdate # doc-check: skip — illustrative handler
query (b, p) [Battle, Pos, {Foe}] where b.hp <= 0 @Queries(these: [Battle{hp <= 0}, Pos], on: Foe)
{ handler CleanBattle phase LateUpdate { despawn self() }
despawn self()
}
``` ```
This is exactly equivalent to wrapping the body in This is exactly equivalent to wrapping the body in
`for (b, p) in query [Battle, Pos, {Foe}] where b.hp <= 0 { … }` — same `for (Battle, Pos) in query [Battle, Pos, {Foe}] where Battle.hp <= 0 { … }` —
lowering, same semantics. The body runs once per matching entity and `self()` same lowering, same semantics. The body runs once per matching entity and
is that entity. `self()` is that entity. `examples/qdecl.ludic` is a working example.
Mutation during iteration follows the same rules as an inline query, because it Mutation during iteration follows the same rules as an inline query, because it
is the same loop: entities are visited by ascending id, `despawn` of the current is the same loop: entities are visited by ascending id, `despawn` of the current

View file

@ -15,13 +15,12 @@ program Hello {
} }
} }
# a system can declare the entities it operates on: the body then runs once # a handler declares the entities it operates on with @Queries: the body then
# per match, with the components bound and self() giving that entity. # runs once per match, each property bound by name and self() giving that entity.
handler Move phase FixedUpdate @Queries(these: [Pos, Vel])
query (p, v) [Pos, Vel] handler Move phase FixedUpdate {
{ Pos.x = Pos.x + Vel.dx
p.x = p.x + v.dx Pos.y = Pos.y + Vel.dy
p.y = p.y + v.dy
} }
handler Report phase Update { handler Report phase Update {

View file

@ -1,13 +1,13 @@
# ============================================================================ # ============================================================================
# qdecl.ludic — a system whose query lives in its signature. # qdecl.ludic — a handler whose query lives in a @Queries annotation.
# #
# system CleanBattle phase LateUpdate # @Queries(these: [Battle{hp <= 0 and side == 1}, Pos], on: Foe)
# query (b, p) [Battle, Pos, {Foe}] where b.hp <= 0 # handler CleanBattle phase LateUpdate { … }
# { … }
# #
# is the same thing as writing `for (b, p) in query [...] where ... { … }` around # is the same thing as writing `for (Battle, Pos) in query [Battle, Pos, {Foe}]
# the whole body — the loop header just moves into the declaration. The body runs # where Battle.hp <= 0 and Battle.side == 1 { … }` around the whole body — the
# once per matching entity, and self() is that entity. # loop header moves into the annotation. Each property binds by name, the body
# runs once per matching entity, and self() is that entity.
# ============================================================================ # ============================================================================
program QueryDecl { program QueryDecl {
property Battle { hp: int = 0, side: int = 0 } property Battle { hp: int = 0, side: int = 0 }
@ -20,17 +20,17 @@ program QueryDecl {
spawn Foe { Battle { hp: -2, side: 0 } } spawn Foe { Battle { hp: -2, side: 0 } }
} }
handler CleanBattle phase LateUpdate # a per-property constraint `Battle{...}` qualifies its bare fields to that
query (b, p) [Battle, Pos, {Foe}] where b.hp <= 0 and b.side == 1 # property; `on: Foe` adds the {Foe} kind filter.
{ @Queries(these: [Battle{hp <= 0 and side == 1}, Pos], on: Foe)
print_int(p.x) handler CleanBattle phase LateUpdate {
print_int(Pos.x)
despawn self() despawn self()
} }
handler Census phase Render @Queries(these: [Battle])
query (b) [Battle] handler Census phase Render {
{ print_int(Battle.hp)
print_int(b.hp)
} }
handler Bye phase Render { quit() } handler Bye phase Render { quit() }

File diff suppressed because it is too large Load diff

View file

@ -15,47 +15,22 @@ fn parse_component() -> Node {
eat_op("}"); return n eat_op("}"); return n
} }
# skip an @annotation or a reads/writes/query/... clause we don't model yet
fn skip_clause() -> void {
if is_op("[") { let depth = 0
while true { if is_op("[") { depth = depth + 1 }; if is_op("]") { depth = depth - 1 }
pi = pi + 1; if depth == 0 { break } }
}
}
fn parse_system() -> Node { fn parse_system() -> Node {
pi = pi + 1; let n = node(N_SYS); n.s = eat_id(); n.ty = "Update" pi = pi + 1; let n = node(N_SYS); n.s = eat_id(); n.ty = "Update"
# clauses: @anno, phase X, reads/writes/needs/uses [..], query (..) [..] # postfix clauses on `handler Name …`: @anno(...) (parsed and reserved, e.g.
# A `query (vars) [terms] where c` clause desugars to a body wrapped in one # @deterministic / @Reads(...) / @Writes(...)) and `phase X`. The handler's
# `for (vars) in query [terms] where c { ... }` — the same S_QUERY node the # query lives in a prefix `@Queries(...)` annotation (see parse_one_decl), not
# inline form builds, so `qdecl.ludic` and the inline form share a lowering. # in a signature clause.
let has_q = false
let qn = node(S_QUERY)
while true { while true {
skipnl() # clauses may span several lines skipnl() # clauses may span several lines
if is_op("@") { pi = pi + 1; let a = eat_id(); if is_op("(") { let d = 0 # @anno, one per turn so a if is_op("@") { pi = pi + 1; let a = eat_id(); if is_op("(") { let d = 0 # @anno, one per turn so a
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } }
continue } # newline-separated @anno re-skips at the loop top continue } # newline-separated @anno re-skips at the loop top
if is_id("phase") { pi = pi + 1; n.ty = eat_id(); continue } if is_id("phase") { pi = pi + 1; n.ty = eat_id(); continue }
if is_id("reads") or is_id("writes") { pi = pi + 1; skip_clause(); continue } # `needs`/`uses` synonyms dropped (Rule A cleanup)
if is_id("query") {
pi = pi + 1; has_q = true
if is_op("(") { eat_op("(") # optional (vars); omitted when nothing binds
while not is_op(")") { let v = node(E_ID); v.s = eat_id(); push(qn.kids, v); if is_op(",") { pi = pi + 1 } }
eat_op(")") }
qn.c = parse_query_tail()
qn.b = qn.c.a # where-expr (or null)
continue
}
break break
} }
skipnl() skipnl()
let body = block() n.a = block()
if has_q {
qn.a = body
let wrap = node(N_BLOCK); push(wrap.kids, qn)
n.a = wrap
} else { n.a = body }
return n return n
} }
@ -81,7 +56,8 @@ fn parse_query_for() -> Node {
while not is_op(")") { let v = node(E_ID); v.s = eat_id(); push(n.kids, v); if is_op(",") { pi = pi + 1 } } while not is_op(")") { let v = node(E_ID); v.s = eat_id(); push(n.kids, v); if is_op(",") { pi = pi + 1 } }
eat_op(")") eat_op(")")
let inkw = eat_id() # 'in' let inkw = eat_id() # 'in'
let qkw = eat_id() # 'query' if not is_id("query") { perr("expected 'query' in for-loop") }
pi = pi + 1 # 'query'
n.c = parse_query_tail() n.c = parse_query_tail()
n.b = n.c.a # where n.b = n.c.a # where
n.a = block() n.a = block()

View file

@ -42,11 +42,11 @@ ok "self-host suite: $CORR checks passed (see ./selfhost/test.sh)"
echo "== documented syntax stays compilable (guards against spec/compiler drift) ==" echo "== documented syntax stays compilable (guards against spec/compiler drift) =="
# Compiles a feature example end-to-end (parse -> lower -> link). qdecl exercises # Compiles a feature example end-to-end (parse -> lower -> link). qdecl exercises
# the `query (vars) [terms] where ...` clause in a system SIGNATURE. # a @Queries annotation with a per-property constraint and an `on:` model tag.
qsmoke() { # name qsmoke() { # name
local n="$1" local n="$1"
if ./selfhost/game-build.sh build/ludicc "examples/$n.ludic" "/tmp/ludic_${n}_s" >/tmp/qs.out 2>&1; then if ./selfhost/game-build.sh build/ludicc "examples/$n.ludic" "/tmp/ludic_${n}_s" >/tmp/qs.out 2>&1; then
ok "$n compiles (signature-query desugars to S_QUERY)" ok "$n compiles (@Queries desugars to S_QUERY)"
else bad "$n: $(tail -1 /tmp/qs.out)"; fi else bad "$n: $(tail -1 /tmp/qs.out)"; fi
} }
qsmoke qdecl qsmoke qdecl

View file

@ -45,7 +45,7 @@ object LudicVocabulary {
"const", "var", "fn", "extern", "handler", "entry" "const", "var", "fn", "extern", "handler", "entry"
) )
val CLAUSE = setOf( val CLAUSE = setOf(
"phase", "query", "reads", "writes", "on" "phase", "query", "on"
) )
val STMT = setOf( val STMT = setOf(
"let", "return", "if", "else", "while", "for", "in", "spawn", "despawn", "let", "return", "if", "else", "while", "for", "in", "spawn", "despawn",

View file

@ -163,7 +163,7 @@
"patterns": [ "patterns": [
{ "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|become|return|spawn|despawn|enable|disable|where|break|continue|new)\\b" }, { "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|become|return|spawn|despawn|enable|disable|where|break|continue|new)\\b" },
{ "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|on)\\b" }, { "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\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(program|property|struct|model|enum|ui|const|var|let|fn|handler|entry|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" },

View file

@ -163,7 +163,7 @@
"patterns": [ "patterns": [
{ "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|become|return|spawn|despawn|enable|disable|where|break|continue|new)\\b" }, { "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|become|return|spawn|despawn|enable|disable|where|break|continue|new)\\b" },
{ "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|on)\\b" }, { "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\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(program|property|struct|model|enum|ui|const|var|let|fn|handler|entry|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" },

View file

@ -56,7 +56,7 @@ static const char* LUDIC_KW_DECL[] = {
"const","var","fn","extern","handler","entry", 0 "const","var","fn","extern","handler","entry", 0
}; };
static const char* LUDIC_KW_CLAUSE[] = { static const char* LUDIC_KW_CLAUSE[] = {
"phase","query","reads","writes","on", 0 "phase","query","on", 0
}; };
/* Documented design targets the self-hosted parser does not accept yet. Kept /* Documented design targets the self-hosted parser does not accept yet. Kept
* out of the highlighted vocabulary (they would read as working keywords) until * out of the highlighted vocabulary (they would read as working keywords) until