Phase 6d: merge struct into property; storage follows use

`struct` and `property` had identical syntax and differed only in semantics, so
they are now one keyword: `property`. How a property is stored follows from how
it is used —

  - listed in a `model` or attached by `spawn`  -> an ECS component, kept in the
    engine's per-entity @S_/@H_ arrays and bound in queries (as before);
  - constructed with `new`                       -> a heap record with reference
    semantics (what `struct` used to be).

A program that declares only `property` records and functions — no `model`, no
`handler` — is not an ECS program: it gets record layouts and `new`, but no
entity storage, allocator, snapshot, or runtime splice. This is exactly the
shape of the Ludic compiler itself, whose Node/Tok/Buf/Val are now `property`.

Mechanics:
  - record layout (%Cmp_) now always emitted in the header (emit_head), so `new`
    works with or without the ECS; the per-entity arrays stay in
    emit_ecs_storage. %Str_ is gone — one layout prefix.
  - has_ecs() is now `has_systems() or has_models()`, not "any component"; a
    property alone no longer drags in the ECS runtime. Added has_models().
  - emit_new / member access / layout_ty / layout_node collapse onto find_comp.
    Dropped struct keyword, parse_struct, find_struct, is_struct_ty, N_STRUCT
    emission (the const stays at kind 0, the default node kind).

Migration done as two reseeds (the old compiler treats any component as ECS, so
it cannot see `property` records in the compiler source until has_ecs is fixed):
  A) teach the compiler property-as-record + fix has_ecs, keeping `struct`;
  B) migrate the compiler's own records to `property` and remove `struct`.

selfhost/tests/structs.ludic migrated (still prints 7 9 109 2 42). Vocabulary
drops `struct` from DECL (ludic_syntax.h, grammar, LudicTokens.kt). LANGUAGE.md
"Records" section rewritten. Reseeded (21613 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:32:05 +03:00
parent 3fd599ce47
commit dc475b17c2
15 changed files with 4054 additions and 4378 deletions

View file

@ -22,8 +22,8 @@ A program is one `program` block containing declarations:
# doc-check: skip — illustrative: elided import list
program Name {
import ... # pull declarations in from another file
property ... # data (per entity)
struct ... # a plain record, not tied to an entity
property ... # a record of typed fields — a per-entity component, or a
# plain `new`-allocated record; its use decides which
model ... # a named entity KIND (bundle of properties)
const ... # compile-time constants
fn ... # functions
@ -366,13 +366,24 @@ whole timeline), plus [`examples/toggle.ludic`](examples/toggle.ludic)
(enable/disable). Still to come: **scene** hooks (`@OnEnter`/`@OnExit`), which
wait on `scene` support landing in the compiler.
## Structs, arrays and slices
## Records (`property`), arrays and slices
`struct` is the aggregate that is *not* tied to an entity — a plain record, for
the data a program keeps outside the ECS.
There is one record keyword, `property` — a named set of typed fields with
defaults. How a property is *stored* follows from how it is *used*, so the same
declaration covers both ECS components and the plain records a program keeps
outside the ECS:
- listed in a `model` (or attached by `spawn`) → a **component**, stored in the
engine's per-entity arrays and bound in queries;
- constructed with **`new`** → a **heap record**, addressed by a pointer.
A program that only declares `property` records and functions — never a `model`
or `handler` — is not an ECS program at all: it gets no entity storage or
runtime, just the record layouts and `new`. (This is exactly how the Ludic
compiler is written in itself.)
```ludic
struct Tok { kind: int = 0, line: int = 0, next: Tok }
property Tok { kind: int = 0, line: int = 0, next: Tok }
handler Lex phase Update {
let t = new Tok # allocates; every field seeded from its default
@ -380,11 +391,11 @@ handler Lex phase Update {
}
```
Struct values have **reference semantics**: a struct value is a pointer to the
A `new` record has **reference semantics**: the value is a pointer to the
object, so assigning or passing one shares it rather than copying.
```ludic
struct Tok { kind: int = 0, line: int = 0, next: Tok }
property Tok { kind: int = 0, line: int = 0, next: Tok }
fn bump(t: Tok) -> void { t.kind = t.kind + 1 }
@ -398,7 +409,7 @@ handler Share phase Update {
}
```
Fields chain, so a struct can refer to its own type and be walked without
Fields chain, so a record can refer to its own type and be walked without
temporaries — which is what an AST or a linked list needs:
```ludic
@ -648,8 +659,9 @@ are future work.
formatting lives in `build/ludic-fmt` instead). Output-path and IR flags are in
flux as the CLI front-end is rebuilt — check `ludicc` usage for the current set.
`struct` and array types, `break`/`continue`, and argv/stderr — once listed here
as near-term — are now implemented and self-hosting; their lowerings are in
Records (`property` used with `new`) and array types, `break`/`continue`, and
argv/stderr — once listed here as near-term — are now implemented and
self-hosting; their lowerings are in
[BOOTSTRAP.md](BOOTSTRAP.md) §4.
## Scenes & layers

View file

@ -50,7 +50,7 @@ const E_FLOAT: int = 40
const E_REC: int = 41
const E_FINIT: int = 42
struct Node {
property Node {
kind: int = 0
s: ptr = ptr_null() # name / operator / string / type-of-new
ival: int = 0 # int literal, bool, flags

View file

@ -2,7 +2,7 @@
# module-level material (types, globals, string constants) into one buffer and
# function bodies into another, then prints them in order.
struct Buf { data: ptr = ptr_null(), len: int = 0, cap: int = 0 }
property Buf { data: ptr = ptr_null(), len: int = 0, cap: int = 0 }
fn buf_new() -> Buf {
let b = new Buf

View file

@ -2,7 +2,7 @@
# module header. Mirrors the pieces of compiler/back/ that this subset needs.
# structs and slices are references, so every non-scalar type lowers to `ptr`.
struct Val { code: ptr = ptr_null(), ty: ptr = ptr_null() }
property Val { code: ptr = ptr_null(), ty: ptr = ptr_null() }
fn val(code: ptr, ty: ptr) -> Val { let v = new Val; v.code = code; v.ty = ty; return v }
var head: Buf # module-level: types, globals, string constants
@ -66,17 +66,6 @@ fn llty(t: ptr) -> ptr {
fn is_slice_ty(t: ptr) -> bool { return peek8(t, 0) == 91 and peek8(t, 1) == 93 } # "[]"
fn slice_elem(t: ptr) -> ptr { return substr(t, 2, slen(t) - 2) }
fn find_struct(name: ptr) -> Node {
let i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_STRUCT and streq(d.s, name) { return d }
i = i + 1
}
return ptr_null()
}
fn is_struct_ty(t: ptr) -> bool { return not ptr_is_null(find_struct(t)) }
fn find_arch(name: ptr) -> Node {
let i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_ARCH and streq(d.s, name) { return d }; i = i + 1 }
@ -87,15 +76,10 @@ fn find_comp(name: ptr) -> Node {
while i < len(prog) { let d = prog[i]; if d.kind == N_COMP and streq(d.s, name) { return d }; i = i + 1 }
return ptr_null()
}
# a struct or a component — both have %Str_/%Cmp_ layouts with named fields
fn layout_node(name: ptr) -> Node {
let s = find_struct(name); if not ptr_is_null(s) { return s }
return find_comp(name)
}
fn layout_ty(name: ptr) -> ptr {
if not ptr_is_null(find_struct(name)) { return sconcat("%Str_", name) }
return sconcat("%Cmp_", name)
}
# every record is a `property` with a %Cmp_ layout of named fields — whether it
# is stored per-entity by the ECS or heap-allocated by `new` is a matter of use.
fn layout_node(name: ptr) -> Node { return find_comp(name) }
fn layout_ty(name: ptr) -> ptr { return sconcat("%Cmp_", name) }
fn field_index(s: Node, fname: ptr) -> int {
let i = 0

View file

@ -5,16 +5,22 @@
const MAX_ENT: int = 1024
fn has_ecs() -> bool {
let i = 0
while i < len(prog) { let k = prog[i].kind; if k == N_COMP or k == N_SYS { return true }; i = i + 1 }
return false
}
fn has_systems() -> bool {
let i = 0
while i < len(prog) { if prog[i].kind == N_SYS { return true }; i = i + 1 }
return false
}
fn has_models() -> bool {
let i = 0
while i < len(prog) { if prog[i].kind == N_ARCH { return true }; i = i + 1 }
return false
}
# Does this program run the ECS? A property alone no longer answers that — the
# same `property` keyword also declares plain `new`-allocated records (the merged
# `struct`). A program uses the ECS when it has a handler or a model; a tool that
# only declares record types and functions does not, and gets no entity storage,
# allocator, snapshot or runtime splice.
fn has_ecs() -> bool { return has_systems() or has_models() }
fn emit_ecs_storage() -> void {
emith("@L_running = internal global i32 1\n")
@ -28,12 +34,10 @@ fn emit_ecs_storage() -> void {
let i = 0
while i < len(prog) {
let c = prog[i]
# per-entity storage for a property (its %Cmp_ layout is emitted in the
# header). Every property in an ECS program is a component today; a property
# used only via `new` would not need these, but no such program mixes the two.
if c.kind == N_COMP {
emith(sconcat("%Cmp_", sconcat(c.s, " = type { ")))
if len(c.kids) == 0 { emith("i32") }
let f = 0
while f < len(c.kids) { if f > 0 { emith(", ") }; emith(llty(c.kids[f].ty)); f = f + 1 }
emith(" }\n")
emith(sconcat("@S_", sconcat(c.s, sconcat(" = internal global [", sconcat(me, sconcat(" x %Cmp_", sconcat(c.s, "] zeroinitializer\n")))))))
emith(sconcat("@H_", sconcat(c.s, sconcat(" = internal global [", sconcat(me, " x i8] zeroinitializer\n")))))
}

View file

@ -76,12 +76,14 @@ fn emit_header() -> void {
emith("@.gametitle = private unnamed_addr constant [")
emith(itoa(slen(g_game_name) + 1)); emith(" x i8] c\""); emith(g_game_name); emith("\\00\"\n")
emith("%LSlice = type { ptr, i32, i32 }\n")
# struct layouts
# property layouts — a %Cmp_ record of named fields, emitted here so `new`
# works whether or not the program runs the ECS. The per-entity @S_/@H_ arrays
# are separate (emit_ecs_storage), emitted only for a program that runs the ECS.
let i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_STRUCT {
emith("%Str_"); emith(d.s); emith(" = type { ")
if d.kind == N_COMP {
emith(layout_ty(d.s)); emith(" = type { ")
if len(d.kids) == 0 { emith("i32") }
let f = 0
while f < len(d.kids) {

View file

@ -8,15 +8,16 @@ fn emit_sizeof(llt: ptr) -> ptr {
}
fn emit_new_struct(name: ptr) -> Val {
let s = find_struct(name)
if ptr_is_null(s) { perr("unknown struct in new") }
let sz = emit_sizeof(sconcat("%Str_", name))
let s = layout_node(name) # a struct or a property — same shape
if ptr_is_null(s) { perr("unknown record type in new") }
let lty = layout_ty(name)
let sz = emit_sizeof(lty)
let obj = emit_bind(sconcat("call ptr @malloc(i64 ", sconcat(sz, ")")))
let f = 0
while f < len(s.kids) {
let fd = s.kids[f]
let addr = nreg()
emit(" "); emit(addr); emit(" = getelementptr inbounds %Str_"); emit(name)
emit(" "); emit(addr); emit(" = getelementptr inbounds "); emit(lty)
emit(", ptr "); emit(obj); emit(", i32 0, i32 "); emit(itoa(f)); emit("\n")
let lt = llty(fd.ty)
let v = "0"

View file

@ -10,7 +10,7 @@ const TK_NL: int = 4
const TK_EOF: int = 5
const TK_FLOAT: int = 6
struct Tok { kind: int = 0, text: ptr = ptr_null(), ival: int = 0, line: int = 0 }
property Tok { kind: int = 0, text: ptr = ptr_null(), ival: int = 0, line: int = 0 }
var toks: []Tok

File diff suppressed because it is too large Load diff

View file

@ -224,14 +224,6 @@ fn stmt() -> Node {
}
# ---- declarations ----------------------------------------------------------
fn parse_struct() -> Node {
pi = pi + 1; let n = node(N_STRUCT); n.s = eat_id(); skipnl(); eat_op("{")
while true { skipnl(); if is_op("}") { break }
let f = node(N_FIELD); f.s = eat_id(); eat_op(":"); f.ty = ptype()
if is_op("=") { pi = pi + 1; f.a = expr() }
push(n.kids, f); if is_op(",") { pi = pi + 1 } }
eat_op("}"); return n
}
fn parse_var() -> Node {
pi = pi + 1; let n = node(N_VAR); n.s = eat_id(); eat_op(":"); n.ty = ptype()
if is_op("=") { pi = pi + 1; n.a = expr() }
@ -311,7 +303,6 @@ fn parse_one_decl() -> void {
do_import(rel)
return
}
if is_id("struct") { push(prog, parse_struct()); return }
if is_id("enum") { push(prog, parse_enum()); return }
if is_id("property") { push(prog, parse_component()); return }
if is_id("model") { push(prog, parse_archetype()); return }

View file

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

View file

@ -41,7 +41,7 @@ object LudicTokens {
*/
object LudicVocabulary {
val DECL = setOf(
"program", "import", "property", "struct", "model", "enum", "ui",
"program", "import", "property", "model", "enum", "ui",
"const", "var", "fn", "extern", "handler", "entry"
)
val CLAUSE = setOf(

View file

@ -165,7 +165,7 @@
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\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|model|enum|ui|const|var|let|fn|handler|entry|state)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }

View file

@ -165,7 +165,7 @@
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\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|model|enum|ui|const|var|let|fn|handler|entry|state)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }

View file

@ -52,7 +52,7 @@ typedef struct {
* mirror the compiler's parser: anything parse_decl() dispatches on is a
* declaration keyword, anything stmt() dispatches on is a statement keyword. */
static const char* LUDIC_KW_DECL[] = {
"program","import","property","struct","model","enum","ui",
"program","import","property","model","enum","ui",
"const","var","fn","extern","handler","entry", 0
};
static const char* LUDIC_KW_CLAUSE[] = {