Phase 3a: spawn/record initializers use ':' (Rule A)

Comp = { f = v }  ->  Comp { f: v }. record() requires ':' and parse_spawn()
drops the '=' before the record; '=' is now assignment/binding only. Records
appear only in games, so the compiler seed is unaffected.

Migration: tools/ludic-tools/migrate_records.c (spawn-context aware). Verified
every golden byte-identical, old '=' form rejected, reseeded, test.sh 14/14.
Docs updated (LANGUAGE.md, BOOTSTRAP.md R2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 15:58:00 +03:00
parent eac8f8f335
commit 39bd430ce8
12 changed files with 2499 additions and 2425 deletions

View file

@ -921,7 +921,7 @@ game P { system B phase Start { let x = 1; x = x + 1; print_int(x); quit() } }
```ludic
# doc-check: skip — composite: declaration plus statements
component Pos { x: int = 0 y: int = 0 }
spawn Hero { Pos = { x = 7 y = 2 } }
spawn Hero { Pos { x: 7 y: 2 } }
```
**R3 — RESOLVED.** Every symbol form is now rejected where it is written:

View file

@ -67,7 +67,7 @@ component Stats { hp: int = 10 }
archetype Player { Pos, Stats } # Player IS a kind, not a component
archetype Enemy { Pos, Stats }
spawn Player { Pos = { x = 5 } } # attaches every listed component
spawn Player { Pos { x: 5 } } # attaches every listed component
# (seeding field defaults), then overrides
for (p, s) in query [Pos, Stats, {Player}] { ... } # {Player} filters by kind
```
@ -159,8 +159,8 @@ component Pos { x: int = 0, y: int = 0 } # typed fields with defaults
component Player { } # a tag (no fields)
spawn Hero { # create an entity
Pos = { x = 10, y = 5 }
Player = { }
Pos { x: 10, y: 5 }
Player { }
}
despawn self() # remove the current entity
@ -390,7 +390,7 @@ postfix(. () )`. Operators are built-in only (no overloading). The boolean
operators are spelled **`and` / `or` / `not`**; `&&` and `||` are not Ludic operators
and `!` are rejected with a diagnostic naming the fix (`!=` is unaffected). Bitwise operations are
functions (`band`, `bor`, `bxor`, `bnot`, `shl`, `shr`), so the symbols are free
— which is why there is only one spelling to remember. `expr with { field = … }` is not implemented; records appear
— which is why there is only one spelling to remember. `expr with { field: … }` is not implemented; records appear
only in `spawn`. Char literals (`'w'`) are `int` code points; colors are hex
ints (`0xff8800`).

View file

@ -4,10 +4,10 @@ A plan to make Ludic's syntax internally consistent. It fixes the drift between
the spec and the compiler, then unifies the grammar around two rules. Scope:
**full redesign (Phases 0–5)**. Named-field direction: **colon everywhere**.
> Status: **Phases 1 & 2 landed** (see below). Phases 3→5 are proposals. The
> phases are ordered so the documentation never describes syntax the compiler
> rejects, and every phase ends with the compiler still self-hosting to a
> fixpoint (`./test.sh`).
> Status: **Phases 1 & 2 landed; Phase 3 in progress** (3a records/spawn done).
> Remaining sub-phases and Phases 4→5 are proposals. The phases are ordered so
> the documentation never describes syntax the compiler rejects, and every phase
> ends with the compiler still self-hosting to a fixpoint (`./test.sh`).
>
> Coordinated with the toolchain agent (CLI front-end / `ludicc`+`ludic`
> binaries) via serialized reseeds of `selfhost/ludicc.seed.ll`; Phase 1 rode in
@ -240,13 +240,29 @@ forbid literal newlines in string literals language-wide (the two lexers should
agree). Flagged to the toolchain owners.
### Phase 3 — Named-field unification (Rule A)
- Migrate spawn/record init (`= {…=…}` → `{…:…}`) and ui props (`key=value` →
named-arg form) in the parser and emitters.
- Collapse `reads/writes/needs/uses` → `reads`/`writes`, stored on the node.
- Move `edge`/`pure`/`export` into `@`-annotations; parse annotations into a list.
- Ship `ludic-fmt --migrate`: a mechanical codemod that rewrites old syntax to
new, run over `examples/`, `runtime/`, and `selfhost/`.
- Gate: compiler still self-hosts; every golden render is byte-identical.
**3a — spawn/record initializers ✅ DONE.** `Comp = { f = v }` → `Comp { f: v }`.
`record()` requires `:` and `parse_spawn()` drops the `=` before the record
([parse.ludic](selfhost/parse.ludic), [parse_game.ludic](selfhost/parse_game.ludic)).
The `=` is now assignment/const/default/extern-binding only. Migration tool:
[migrate_records.c](tools/ludic-tools/migrate_records.c) (spawn-context aware).
Records live only in games, so the seed was unaffected; verified every golden
byte-identical, old `=` form now rejected, reseeded, `test.sh` 14/14. Doc examples
updated (LANGUAGE.md, BOOTSTRAP.md R2).
**3b — ui props → named-argument form.** `panel id=Root w=288` → `panel(id: Root,
w: 288)`. Not started (touches `parse_widget` + `emit_ui`).
**3c — collapse `reads/writes/needs/uses` → `reads`/`writes`, stored on the node.**
Not started.
**3d — modifiers `edge`/`pure`/`export` → `@`-annotations, parsed into a list.**
Not started.
Each sub-phase follows the proven pattern: parser change → verification-gated
migration (IR byte-identical / goldens identical) → reseed → docs. The migration
tools ([migrate_separators.c](tools/ludic-tools/migrate_separators.c),
[migrate_records.c](tools/ludic-tools/migrate_records.c)) are the reusable spine.
### Phase 4 — Control-flow & operator consolidation (largest)
- Remove `when`; confirm `if` covers all uses in the corpus.

View file

@ -16,14 +16,14 @@ system Boot phase Start {
build_field(0)
spawn Player {
Pos = { x = 11, y = 15 }
Actor = { kind = 5 }
Party = { slot = 0 }
Stats = { hp = 40, maxhp = 40, mp = 8, maxmp = 8, atk = 12, def = 6, lvl = 1, xp = 0, guard = 0 }
Pos { x: 11, y: 15 }
Actor { kind: 5 }
Party { slot: 0 }
Stats { hp: 40, maxhp: 40, mp: 8, maxmp: 8, atk: 12, def: 6, lvl: 1, xp: 0, guard: 0 }
}
spawn Ally {
Party = { slot = 1 }
Stats = { hp = 26, maxhp = 26, mp = 16, maxmp = 16, atk = 8, def = 4, lvl = 1, xp = 0, guard = 0 }
Party { slot: 1 }
Stats { hp: 26, maxhp: 26, mp: 16, maxmp: 16, atk: 8, def: 4, lvl: 1, xp: 0, guard: 0 }
}
setreg(R_MODE, 0)
setreg(R_ENC, 4)
@ -69,8 +69,8 @@ system Control phase Input {
if reg(R_BOSS) == 0 {
setreg(R_ETYPE, 2)
spawn Enemy {
Actor = { kind = 8 }
Stats = { hp = 120, maxhp = 120, mp = 0, maxmp = 0, atk = 18, def = 10, lvl = 5, xp = 300, guard = 0 }
Actor { kind: 8 }
Stats { hp: 120, maxhp: 120, mp: 0, maxmp: 0, atk: 18, def: 10, lvl: 5, xp: 300, guard: 0 }
}
setreg(R_MODE, 1)
setreg(R_PHASE, 0)
@ -96,14 +96,14 @@ system Control phase Input {
setreg(R_ETYPE, et)
if et == 0 {
spawn Enemy {
Actor = { kind = 6 }
Stats = { hp = 18, maxhp = 18, mp = 0, maxmp = 0, atk = 8, def = 2, lvl = 1, xp = 7, guard = 0 }
Actor { kind: 6 }
Stats { hp: 18, maxhp: 18, mp: 0, maxmp: 0, atk: 8, def: 2, lvl: 1, xp: 7, guard: 0 }
}
}
if et == 1 {
spawn Enemy {
Actor = { kind = 7 }
Stats = { hp = 30, maxhp = 30, mp = 0, maxmp = 0, atk = 11, def = 5, lvl = 1, xp = 15, guard = 0 }
Actor { kind: 7 }
Stats { hp: 30, maxhp: 30, mp: 0, maxmp: 0, atk: 11, def: 5, lvl: 1, xp: 15, guard: 0 }
}
}
setreg(R_MODE, 1)

View file

@ -6,12 +6,12 @@ game Hello {
system Boot phase Start {
spawn Mob {
Pos = { x = 3, y = 4 }
Vel = { dx = 1, dy = 0 }
Pos { x: 3, y: 4 }
Vel { dx: 1, dy: 0 }
}
spawn Mob {
Pos = { x = 10, y = 2 }
Vel = { dx = 0, dy = 1 }
Pos { x: 10, y: 2 }
Vel { dx: 0, dy: 1 }
}
}

View file

@ -18,8 +18,8 @@ game Arena {
component Fighter { hp: int = 30, attack: int = 9, armour: int = 2 }
system Boot phase Start {
spawn Hero { Fighter = { hp = 40, attack = 12, armour = 3 } }
spawn Orc { Fighter = { hp = 24, attack = 7, armour = 1 } }
spawn Hero { Fighter { hp: 40, attack: 12, armour: 3 } }
spawn Orc { Fighter { hp: 24, attack: 7, armour: 1 } }
}
system Fight phase Update {

View file

@ -15,9 +15,9 @@ game QueryDecl {
archetype Foe { Battle, Pos }
system Seed phase Start {
spawn Foe { Battle = { hp = 3, side = 1 } }
spawn Foe { Battle = { hp = 0, side = 1 } }
spawn Foe { Battle = { hp = -2, side = 0 } }
spawn Foe { Battle { hp: 3, side: 1 } }
spawn Foe { Battle { hp: 0, side: 1 } }
spawn Foe { Battle { hp: -2, side: 0 } }
}
system CleanBattle phase LateUpdate

View file

@ -26,9 +26,9 @@ game Snake {
fn reset(x: int) -> int {
for (p, s) in query [Pos, Seg] { despawn self() }
spawn S0 { Seg = { order = 0 }; Pos = { x = 10, y = 7 } }
spawn S1 { Seg = { order = 1 }; Pos = { x = 9, y = 7 } }
spawn S2 { Seg = { order = 2 }; Pos = { x = 8, y = 7 } }
spawn S0 { Seg { order: 0 }; Pos { x: 10, y: 7 } }
spawn S1 { Seg { order: 1 }; Pos { x: 9, y: 7 } }
spawn S2 { Seg { order: 2 }; Pos { x: 8, y: 7 } }
setreg(R_DIR, 3)
setreg(R_LEN, 3)
setreg(R_TICK, 0)
@ -101,7 +101,7 @@ game Snake {
}
if reg(R_ATE) == 1 {
setreg(R_SCORE, reg(R_SCORE) + 1)
spawn Body { Seg = { order = len }; Pos = { x = reg(R_TX), y = reg(R_TY) } }
spawn Body { Seg { order: len }; Pos { x: reg(R_TX), y: reg(R_TY) } }
setreg(R_LEN, len + 1)
setreg(R_FX, rng_range(0, GW - 1))
setreg(R_FY, rng_range(0, GH - 1))

File diff suppressed because it is too large Load diff

View file

@ -116,12 +116,13 @@ fn p_or() -> Node {
return l
}
# a record literal `{ field = value, ... }` — used by spawn component inits
# a record literal `{ field: value, ... }` — used by spawn component inits.
# Rule A: a named part uses `:` (`=` is assignment/binding only).
fn record() -> Node {
eat_op("{")
let r = node(E_REC)
while true { skipnl(); if is_op("}") { break }
let fi = node(E_FINIT); fi.s = eat_id(); eat_op("="); fi.a = expr(); push(r.kids, fi)
let fi = node(E_FINIT); fi.s = eat_id(); eat_op(":"); fi.a = expr(); push(r.kids, fi)
if is_op(",") { pi = pi + 1 } }
eat_op("}"); return r
}

View file

@ -89,8 +89,8 @@ fn parse_spawn() -> Node {
pi = pi + 1; let n = node(S_SPAWN); n.s = eat_id(); skipnl(); eat_op("{")
while true {
skipnl(); if is_op("}") { break }
let ci = node(E_FINIT); ci.s = eat_id(); eat_op("=")
ci.a = record() # { field = val, ... }
let ci = node(E_FINIT); ci.s = eat_id()
ci.a = record() # Comp { field: val, ... } — no `=` before the record
push(n.kids, ci)
if is_op(",") { pi = pi + 1 }
}

View file

@ -0,0 +1,59 @@
/* migrate_records.c — one-time migration for Rule A (named fields use ':').
* Records appear ONLY inside `spawn` blocks, so we track spawn context and, for
* every single '=' inside one:
* - '=' followed by '{' is a component init `Comp = { … }` -> delete it (`Comp { … }`)
* - otherwise is a field binding `field = value` -> rewrite to ':'
* A '=' at a real boundary is a pure re-spelling; the driver's IR-identity check
* (migrated program compiles to the same IR) is the proof.
* usage: migrate_records <file> -> migrated source to stdout
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ludic_syntax.h"
int main(int argc, char** argv){
if (argc < 2){ fprintf(stderr, "usage: migrate_records <file>\n"); return 2; }
FILE* f = fopen(argv[1], "rb");
if (!f){ fprintf(stderr, "cannot open %s\n", argv[1]); return 2; }
fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET);
char* src = malloc(n + 1); fread(src, 1, n, f); src[n] = 0; fclose(f);
LLex L; lud_lex(&L, src);
char* rep = calloc(L.n, 1); /* '=' -> ':' */
char* del = calloc(L.n, 1); /* drop the '=' (component init) */
int sp_stack[1024]; int bp = 0; int spawn_active = 0; int pendingSpawn = 0;
for (int i = 0; i < L.n; i++){
int k = L.v[i].kind; if (k == LT_EOF) break;
if (k == LT_NL || k == LT_COMMENT) continue;
if (k == LT_KW && ltok_is(&L, i, "spawn")) pendingSpawn = 1;
int op1 = (k == LT_OP && ltok_len(&L.v[i]) == 1);
char c0 = L.src[L.v[i].start];
if (op1 && c0 == '{'){
if (bp < 1024){ sp_stack[bp] = (pendingSpawn || spawn_active > 0) ? 1 : 0; if (sp_stack[bp]) spawn_active++; bp++; }
pendingSpawn = 0;
} else if (op1 && c0 == '}'){
if (bp > 0){ bp--; if (sp_stack[bp] && spawn_active > 0) spawn_active--; }
} else if (op1 && c0 == '='){
if (spawn_active > 0){
int nx = ltok_next_sig(&L, i);
if (nx >= 0 && L.v[nx].kind == LT_OP && ltok_len(&L.v[nx]) == 1 && L.src[L.v[nx].start] == '{') del[i] = 1;
else rep[i] = 1;
}
}
}
/* map flags onto byte positions so the rebuild can peek one char ahead */
char* repb = calloc(n + 1, 1); char* delb = calloc(n + 1, 1);
for (int i = 0; i < L.n; i++){ if (rep[i]) repb[L.v[i].start] = 1; if (del[i]) delb[L.v[i].start] = 1; }
for (int b = 0; b < n; ){
if (repb[b]){ putchar(':'); b++; continue; } /* '=' -> ':' */
if (delb[b]){ b++; if (b < n && src[b] == ' ') b++; continue; } /* drop '=' and one space */
if (src[b] == ' ' && b + 1 < n && repb[b + 1]){ b++; continue; } /* drop the space before ':' -> `field: v` */
putchar(src[b]); b++;
}
free(repb); free(delb);
lud_lex_free(&L); free(rep); free(del); free(src);
return 0;
}