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

254 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.ludic),
[selfhost/parse_game.ludic](selfhost/parse_game.ludic), [selfhost/lex.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](selfhost/lex.ludic)) 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](LANGUAGE.md)). *(✅ fixed in Phase 1)*
5. `pure fn` — parses, `pure` silently discarded ([parse.ludic:272](selfhost/parse.ludic)); undocumented.
6. `@anno` + `reads/writes/needs/uses [..]` — parsed then thrown away
([parse_game.ludic:15-31](selfhost/parse_game.ludic)); four synonyms, two undocumented.
7. `scene`/`layer`/`on enter` — full LANGUAGE.md section + [examples/scenes.ludic](examples/scenes.ludic),
**does not compile** (`expected declaration`).
8. `query (v) [..]` in a system signature — two LANGUAGE.md sections +
[examples/qdecl.ludic](examples/qdecl.ludic), **does not compile** (`parse error: {`). *(✅ implemented in Phase 1)*
9. `when cond {}` — documented ([LANGUAGE.md:329](LANGUAGE.md)) + 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](selfhost/main.ludic)).
**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](selfhost/parse.ludic)).
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
```ludic
# 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
```ludic
# 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
```ludic
# 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
```ludic
# 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
```ludic
# 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:
```ludic
# 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](selfhost/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](selfhost/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](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](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](selfhost/parse.ludic)):
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.