Self-hosted compiler (selfhost/*.ludic), runtime, examples, editor tooling, and docs. Phase 1 of the syntax-redesign cohesion pass has landed: edge-system fix, signature-query, when-alias, and the documentation truth-pass. Suite green (14/14), C-free bootstrap fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Ludic Syntax Redesign — Cohesion Pass
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: Phase 1 landed (see below). Phases 2→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+ludicbinaries) via serialized reseeds ofselfhost/ludicc.seed.ll; Phase 1 rode in alongside theiremit_*/main.ludicwork, combined suite 14/14 green.
Why (the findings)
Verified against the self-hosted compiler (selfhost/parse.ludic, selfhost/parse_game.ludic, selfhost/lex.ludic):
Structural incoherence
- Five micro-syntaxes for named parts —
name: type = d(fields),name: type(params),Field = { k = v }(spawn),[Name, {Tag}](query), whitespacephase X reads [..](system clauses),key=value(ui props). =means seven things,:means one — assignment, default, record init, ui prop, extern symbol, const value,state X = Nall use=.- No statement terminators —
\nand;both lex toTK_NL(lex.ludic:45,121) but the parser never requires a separator, sot.kind = k t.text = x t.ival = v(three statements, spaces only) is idiomatic.
Broken / dead syntax (compiler-verified)
4. edge system — hard parse error (documented at LANGUAGE.md:188). (✅ fixed in Phase 1)
5. pure fn — parses, pure silently discarded (parse.ludic:272); undocumented.
6. @anno + reads/writes/needs/uses [..] — parsed then thrown away
(parse_game.ludic:15-31); four synonyms, two undocumented.
7. scene/layer/on enter — full LANGUAGE.md section + examples/scenes.ludic,
does not compile (expected declaration).
8. query (v) [..] in a system signature — two LANGUAGE.md sections +
examples/qdecl.ludic, does not compile (parse error: {). (✅ implemented in Phase 1)
9. when cond {} — documented (LANGUAGE.md:329) + in all three editor
highlighters, never parsed. (✅ implemented in Phase 1 as an if-without-else alias)
10. CLI --emit-llvm/-o/--shared/--fmt — documented, but ludicc only
accepts --windowed/--headless (main.ludic:8-14).
Philosophical splits
11. Operators are words (and/or/not), symbols (==/<=), and functions
(band/shl) at once.
12. Three overlapping control families — if/when, match, machine/become
— and enter reuses become's AST node (parse.ludic:176-177).
13. Typed components/structs exist, but real state lives in 64 untyped int
registers (reg/setreg), so machine/match dispatch on magic numbers.
The two rules everything converges on
Rule A — : associates, = binds.
:introduces a named part and its type or value in a declarative structure: component/struct fields' types, record initializers, ui props, (future) named call arguments.=binds a value to a storage location or a constant:let, assignment (+=…),constvalue, a field's default, and the extern symbol.- A field declaration uses both, unambiguously:
x: int = 0reads "xhas typeint(:), defaulting to0(=)" — same shape as Rust/TypeScript. - A record/spawn initializer is declarative, so it uses
:—Pos { x: 10 }.
Rule B — a statement ends at a newline (or ; or }).
- Newlines become significant. Two statements on one line require an explicit
;.ludic-fmtnormalizes one statement per line and inserts/removes;.
Everything below is these two rules applied construct by construct.
Target grammar (before → after)
Records / spawn initializers
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before
spawn Hero { Pos = { x = 10, y = 5 } Player = { } }
# after
spawn Hero {
Pos { x: 10, y: 5 }
Player {}
}
Field = { k = v } → Field { k: v }. The component name is followed directly
by a record; fields use :. (Record literals elsewhere read the same:
{ x: 10, y: 5 }.)
UI props → named-argument form
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before
panel id=Root w=288 pad=16 gap=6 align=center { label text="HI" size=26 }
# after
panel(id: Root, w: 288, pad: 16, gap: 6, align: center) {
label(text: "HI", size: 26)
}
A widget becomes "a constructor with named args, then an optional child block."
This deletes the bespoke key=value dialect and reuses : + commas. (Lower-churn
alternative if the paren form is disliked: keep whitespace separation but colonize
— panel id: Root w: 288 — still removes the = overload.)
System clauses & modifiers → one annotation channel
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before
edge system Move @deterministic reads [Vel] writes [Pos] phase FixedUpdate
query (p, v) [Pos, Vel] where a.x > 0 { … }
# after
@edge @deterministic
system Move
phase FixedUpdate
reads [Vel] writes [Pos]
query (p, v) [Pos, Vel] where p.x > 0
{ … }
- Prefix modifier words (
edge,pure,export) are retired; all modifiers become@annotations, parsed into a real list on the node (not skipped). This fixes theedge systemparse bug (#4) by construction. needs/usesare dropped;reads/writesstay as the two structural clauses and are stored (even if analysis is future work) rather than discarded.- The
query (v) [..]signature clause is actually implemented inparse_system(#8), lowering to the sameS_QUERYnode as the inlinefor.
extern
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
extern fn c_hypot(a: fixed, b: fixed) -> fixed = "hypot_fx" # unchanged
The = "symbol" is a binding under Rule A — it stays.
Statements
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before (legal today)
t.kind = kind t.text = text t.ival = ival
# after
t.kind = kind
t.text = text
t.ival = ival
# or, explicitly, on one line:
t.kind = kind; t.text = text; t.ival = ival
Control flow (Phase 4)
whenvsif—whenis now a workingif-without-else alias (Phase 1). Phase 4 decides whether to keep both spellings or collapse to one; if collapsed, removewhenfrom docs, the parser, and all editor highlighters together.- Typed states replace magic-int machines. Introduce
enum, and letmachinedispatch on a typed variable instead of a register:
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before # after
const R_PHASE: int = 0 enum Phase { KnightMenu, KnightResolve, MageMenu, EnemyTurn }
machine R_PHASE { var phase: Phase = Phase.KnightMenu
state KnightMenu = 0 { … become … } machine phase {
state KnightResolve = 1 { … } state KnightMenu { … become KnightResolve }
} state KnightResolve { … }
}
state X = N loses the magic = N (ordinal comes from the enum). become
and enter (scenes) keep one shared lowering but read from a typed slot.
Phase sequence
Each phase is independently shippable and ends green on ./test.sh +
./selfhost/test.sh (fixpoint).
Phase 0 — Doctrine (done here)
Rules A and B above; colon-everywhere; @-annotations as the single modifier
channel; typed enums for state. No code.
Phase 1 — Truth-in-documentation ✅ DONE
Made spec ⇄ compiler agree before any grammar change. What landed:
- ✅
edge systemcrash fixed (#4) —parse_systemnow consumes an optionaledgemarker beforesystem(parse_game.ludic). (edgeis a pure marker; the emitter never lowered it differently.) - ✅ Signature-
queryimplemented (#8) —query (vars) [terms] where cin a system header desugars to the sameS_QUERYnode the inlineforbuilds, soexamples/qdecl.ludiccompiles and runs. Also fixed multi-line clause parsing (clauses may now span lines). - ✅
when c { }implemented (#9) — as anif-without-else alias in parse.ludic. Docs + editors already listed it; now the compiler agrees, so no editor-vocab churn was needed. - ✅
scene/layermarked not-yet-implemented (#7) — prominent note in LANGUAGE.md §"Scenes & layers" + a header on examples/scenes.ludic. Full scene front-end + emission deferred (real work, out of Phase 1 scope). - ✅
reads/writeshonesty (#6) + the stale "Not yet implemented" section updated in LANGUAGE.md; scenes/reads-writes/dropped-CLI-flags now listed there. - ✅
test.shguards drift — added aqsmoke qdeclcompile check. Suite green (14/14 incl. the toolchain agent's CLI smoke tests). - ✅ CLI flags (#10) —
--shared/--fmt/wasm noted as dropped-with-the-C-driver in LANGUAGE.md;-o/--emit-llvmwere being re-added by the toolchain agent (now real, verified intest.sh); COMPILING.md updated by that agent. - Deferred (intentionally):
pure-is-ignored (#5) is undocumented and harmless; it will be folded into@purein Phase 3 rather than churned now. - Not done / by design:
scenes.ludicis not added totest.sh(it can't compile yet — a positive test would fail; the header note + LANGUAGE.md warning cover the drift instead).
Phase 2 — Statement separation (Rule B)
- Enforce a separator in
block()/stmt()(parse.ludic:130-199): after a statement, requireTK_NLor}. - Teach
ludic-fmtto normalize one-statement-per-line and insert;where two share a line. Reformat the whole corpus with it. - Risk: the self-host sources themselves use space-juxtaposed statements heavily — reformat them in the same commit, re-seed, re-verify fixpoint.
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/exportinto@-annotations; parse annotations into a list. - Ship
ludic-fmt --migrate: a mechanical codemod that rewrites old syntax to new, run overexamples/,runtime/, andselfhost/. - Gate: compiler still self-hosts; every golden render is byte-identical.
Phase 4 — Control-flow & operator consolidation (largest)
- Remove
when; confirmifcovers all uses in the corpus. - Introduce
enum+ typedmachine/state; migratecombat.ludic's R_PHASE machine and the register-drivenmatch reg(…)sites. - Decide the bitwise story: keep
band/shl/…as functions (document as a deliberate "one spelling" choice) or promote to operators — pick one and state it, don't leave it implicit.
Phase 5 — Single source of truth for keywords/grammar
- Generate every editor plugin keyword list,
ludic_syntax.h, and the LSP's token set from one canonical list so a keyword can never again be highlighted but unparsed. - Add a CI check (extend
tools/check-docs.py/check-vocabulary.py) that every```ludicfence in the docs compiles, closing the doc-drift loop permanently.
Decision log
- Scope: full redesign, Phases 0–5. (chosen)
- Named fields: colon everywhere;
=is binding-only. (chosen) - Open — Phase 4 detail: typed
enumstate vs. keep integer registers. Recommended: typed enums (fixes #13), but it's the deepest change; can be deferred without blocking Phases 1–3. - Open — ui props: paren named-args (
panel(id: Root)) vs. colonized whitespace (panel id: Root). Recommended: paren form for full cohesion. - Open — bitwise ops: functions (status quo, documented) vs. operators.