ludic/SYNTAX-REDESIGN.md
Orkuncakilkaya 985f9ad8f2 Baseline: Ludic compiler + toolchain, Phase 1 syntax fixes complete
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>
2026-08-27 15:15:35 +03:00

12 KiB
Raw Blame History

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+ludic binaries) via serialized reseeds of selfhost/ludicc.seed.ll; Phase 1 rode in alongside their emit_*/main.ludic work, 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

  1. Five micro-syntaxes for named parts — name: type = d (fields), name: type (params), Field = { k = v } (spawn), [Name, {Tag}] (query), whitespace phase X reads [..] (system clauses), key=value (ui props).
  2. = means seven things, : means one — assignment, default, record init, ui prop, extern symbol, const value, state X = N all use =.
  3. No statement terminators — \n and ; both lex to TK_NL (lex.ludic:45,121) but the parser never requires a separator, so t.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 (+= …), const value, a field's default, and the extern symbol.
  • A field declaration uses both, unambiguously: x: int = 0 reads "x has type int (:), defaulting to 0 (=)" — 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-fmt normalizes 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 the edge system parse bug (#4) by construction.
  • needs/uses are dropped; reads/writes stay 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 in parse_system (#8), lowering to the same S_QUERY node as the inline for.

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)

  • when vs if — when is now a working if-without-else alias (Phase 1). Phase 4 decides whether to keep both spellings or collapse to one; if collapsed, remove when from docs, the parser, and all editor highlighters together.
  • Typed states replace magic-int machines. Introduce enum, and let machine dispatch 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 system crash fixed (#4) — parse_system now consumes an optional edge marker before system (parse_game.ludic). (edge is a pure marker; the emitter never lowered it differently.)
  • ✅ Signature-query implemented (#8) — query (vars) [terms] where c in a system header desugars to the same S_QUERY node the inline for builds, so examples/qdecl.ludic compiles and runs. Also fixed multi-line clause parsing (clauses may now span lines).
  • ✅ when c { } implemented (#9) — as an if-without-else alias in parse.ludic. Docs + editors already listed it; now the compiler agrees, so no editor-vocab churn was needed.
  • ✅ scene/layer marked 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/writes honesty (#6) + the stale "Not yet implemented" section updated in LANGUAGE.md; scenes/reads-writes/dropped-CLI-flags now listed there.
  • ✅ test.sh guards drift — added a qsmoke qdecl compile 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-llvm were being re-added by the toolchain agent (now real, verified in test.sh); COMPILING.md updated by that agent.
  • Deferred (intentionally): pure-is-ignored (#5) is undocumented and harmless; it will be folded into @pure in Phase 3 rather than churned now.
  • Not done / by design: scenes.ludic is not added to test.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, require TK_NL or }.
  • Teach ludic-fmt to 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/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.

Phase 4 — Control-flow & operator consolidation (largest)

  • Remove when; confirm if covers all uses in the corpus.
  • Introduce enum + typed machine/state; migrate combat.ludic's R_PHASE machine and the register-driven match 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 ```ludic fence 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 enum state 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.