feat(lang): 0.S - components take states in their header; migrate writes them, never inside a name, never outside the programs given, never into a package's state

- component Name (a: mut A, b: B) { ... }: every getter, default, function and event takes the
  header's states before the instance; a member's call to another passes them on; the glue is
  supplied them; the template never sees them; a read-only one is read-only in every member
- ludic migrate state: a component's members' needs go into its header (added to an existing one,
  mut added where now changed); a field read or a member call inside a component is the compiler's,
  so nothing is written inside a name and no ', )' is left; an entry point that declares states
  already gets the rest after them
- a program's module named like a package gets <Name>AppState; a program's own file its own state;
  a friend module's files go by directory; a package's settable var stays state
- it writes only under the programs and directories given (and runtime/ with --runtime), and
  refuses the whole run naming any other file that would have to change
- a name a package already moved into its state is rewritten through it; a read of the runtime's
  var through the runtime function that answers it (gl_w: gl_width())
- a state's instance supplied by the runtime is not a uses reference
- tests: state/component, rejected/state_component_ro, rendering/ui_render3d, migrate component
  and foreign cases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 17:11:15 +03:00
parent c167ecc714
commit ce80e64b24
23 changed files with 67967 additions and 61444 deletions

View file

@ -13,6 +13,7 @@ var mgx_len: []int = null
var mgx_text: []pointer = null
var mgx_texts: []pointer = null # the files' texts, read once
var mgx_names: []pointer = null
var mgx_roots: []pointer = null # where it may write: the programs and directories it was given
function mgx_unesc(s: pointer) -> pointer {
var out = ""
@ -99,32 +100,85 @@ function mgx_close(t: pointer, at: int) -> int {
}
return -1
}
# a directory as the plans spell a path (relative to here, `..` resolved), with a trailing '/'
function mgx_dir(d: pointer) -> pointer {
var p = mgx_norm(d)
if len(p) == 0 or (p == ".") { return "" }
if p[len(p) - 1] != '/' { p = p + "/" }
return p
}
function mgx_norm(p0: pointer) -> pointer {
var p = p0
let cwd = getenv_or("PWD", "")
if len(cwd) > 0 and s_starts(p, cwd + "/") { p = p[len(cwd) + 1 .. len(p)] }
let abs = len(p) > 0 and p[0] == '/'
let segs = new []pointer
var n = 0
var a = 0
for i in 0 .. len(p) + 1 {
if i == len(p) or p[i] == '/' {
let seg = p[a .. i]
if (seg == "..") and n > 0 and not (segs[n - 1] == "..") { n -= 1 }
else if not (seg == ".") and not (seg == "") {
if n < len(segs) { segs[n] = seg } else { push(segs, seg) }
n += 1
}
a = i + 1
}
}
var out = ""
if abs { out = "/" }
for i in 0 .. n {
if i > 0 { out = out + "/" }
out = out + segs[i]
}
return out
}
# may it write this file: it is under one of the programs' directories (a package the program only
# imports - installed, or a neighbour's - is not the migration's to change)
function mgx_mine(path: pointer) -> bool {
let p = mgx_norm(path)
for i in 0 .. len(mgx_roots) {
if mgx_roots[i] == "" and not (p[0] == '/') and not s_starts(p, "../") { return true }
if not (mgx_roots[i] == "") and s_starts(p, mgx_roots[i]) { return true }
}
return false
}
function cmd_migrate() -> int {
if arg_count() < 3 or not (arg(2) == "state") {
err("usage: ludic migrate state [file|dir...] [--runtime] [--unsafe] [--dry-run]\n")
return 2
}
let srcs = new []pointer
mgx_roots = new []pointer
var runtime = ""
var dry = false
var ai = 3
while ai < arg_count() {
let a = arg(ai)
if a == "--runtime" { runtime = " --migrate-runtime" }
if a == "--runtime" {
runtime = " --migrate-runtime"
push(mgx_roots, "runtime/")
}
else if a == "--unsafe" { g_unsafe_build = true }
else if a == "--dry-run" { dry = true }
else if a[0] == '-' { err(`ludic migrate: unknown option {a}\n`); return 2 }
else if shq(`test -d {sh_single(a)}`) {
let found = test_files_in(a)
for k in 0 .. len(found) { push(srcs, found[k]) }
push(mgx_roots, mgx_dir(a))
}
else {
push(srcs, a)
push(mgx_roots, mgx_dir(dir_of_path(a)))
}
else { push(srcs, a) }
ai += 1
}
if len(srcs) == 0 {
let entry = find_entry("")
if entry == "" { return no_entry() }
push(srcs, entry)
push(mgx_roots, mgx_dir(dir_of_path(entry)))
}
ensure_ludicc()
# every program's plan from the source as it is, then one pass over the files: programs that
@ -190,11 +244,21 @@ function mgx_apply(lines: []pointer, dry: bool) -> int {
let sexp = new []int
let smore = new []int # 1: the state is already declared, and gains fields
let reports = new []pointer
let foreign = new []pointer # files a plan would change that the migration may not
let lfile = new []pointer
let lpos = new []int
for i in 0 .. len(lines) {
let w = dp_words(lines[i])
if len(w) == 0 { continue }
var at = ""
if (w[0] == "VAR") or (w[0] == "REF") or (w[0] == "INS") or (w[0] == "CLOSE") or (w[0] == "LET") { at = w[1] }
if (w[0] == "STATE") and len(w) > 2 { at = w[2] }
if len(at) > 0 and mgx_roots != null and not mgx_mine(at) {
var seen = false
for k in 0 .. len(foreign) { if foreign[k] == at { seen = true } }
if not seen { push(foreign, at) }
continue
}
if w[0] == "VAR" and len(w) == 6 {
push(vfile, w[1])
push(vpos, s_to_int(w[2]))
@ -228,6 +292,11 @@ function mgx_apply(lines: []pointer, dry: bool) -> int {
mgx_edit(w[1], s_to_int(w[2]), s_to_int(w[3]), mgx_unesc(w[4]))
} else if (w[0] == "INS") and len(w) == 4 {
mgx_edit(w[1], s_to_int(w[2]), 0, mgx_unesc(w[3]))
} else if (w[0] == "CLOSE") and len(w) == 4 {
let t = mgx_text_of(w[1])
var k = s_to_int(w[2])
while t[k] != 0 and t[k] != ')' { k += 1 }
mgx_edit(w[1], k, 0, mgx_unesc(w[3]))
} else if w[0] == "LET" and len(w) == 3 {
push(lfile, w[1])
push(lpos, s_to_int(w[2]))
@ -249,6 +318,23 @@ function mgx_apply(lines: []pointer, dry: bool) -> int {
}
}
}
# a change the programs need in a file it may not write (a package's, a neighbour's): refused,
# whole - that code is migrated where it lives, or given here too
if len(foreign) > 0 {
err("ludic migrate: these files would have to change too, and are not under a program or directory given; nothing was changed:\n")
for f in 0 .. len(foreign) { err(` {foreign[f]}\n`) }
err("migrate them first (a package: in its own checkout), or name their directory as well\n")
return 1
}
# a var whose state would be declared in a file it may not write: refused, whole
for v in 0 .. len(vfile) {
var ok = false
for k in 0 .. len(sat) { if sat[k] == vstate[v] { ok = true } }
if not ok {
err(`ludic migrate: {vfile[v]} has a var for the state of {vstate[v]}, which is declared where this migration may not write; nothing was changed\n`)
return 1
}
}
# the vars: each line goes, and the first of each state's becomes the state
let del_file = new []pointer
let del_a = new []int

View file

@ -531,6 +531,51 @@ function migrate_case() -> void {
if ran == "2 names, first a" { ok(lbl) } else { bad2(lbl, `ran [{ran}]`) }
}
# a program built headless with the toolchain and run: its first line
function headless_case(path: pointer, exp: pointer, label: pointer) -> void {
let out = `{tmp_dir()}/h_{flat(path)}`
if not shq(`bin/ludicc --headless examples/{path}.ludic -o {out} > {out}.log 2>&1`) { bad2(label, capture_line(`grep -i error {out}.log | head -1`)); return }
let got = capture_line(`{out} < /dev/null`)
if got == exp { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# ludic migrate state and components: the header names what every member needs, a field read in a
# member is not edited, a module named like a package keeps a state of its own, and the program
# runs as it did
function migrate_component_case() -> void {
let lbl = "ludic migrate state: a component's header, a module named like a package, the same output"
let work = `{tmp_dir()}/migcomp`
shell(`rm -rf {work} && mkdir -p {work}/game/parts {work}/game/fishing && cp examples/state/component_parts/Tally.xml {work}/game/parts/`)
write_file(`{work}/game/parts/Tally.ludic`, "component Tally {\n prop label: string\n total: int = points\n shown: string = `{prefix}{total}`\n function big() -> bool { return total > 7 }\n on add(n: int) {\n points += n\n if big() { bigs += 1 }\n fish_cast()\n }\n}\n")
write_file(`{work}/game/fishing/index.ludic`, "module fishing\nvar casts: int = 0\nexport function fish_cast() -> void { casts += 1 }\nexport function fish_casts() -> int { return casts }\n")
var m = "import \"ludic.ui\"\nimport \"fishing\"\nprogram Mig {\n numbers float\n var points: int = 0\n var bigs: int = 0\n var prefix: string = \"#\"\n import \"parts/Tally.ludic\"\n"
m = m + " function frame(ui_st: mut UiState) -> UiNode {\n let root: UiNode = ui_nodes(ui_st, \"Tally\", null)\n ui_place(ui_st, root, 0.0, 0.0, 300.0, 200.0)\n return root\n }\n"
m = m + " entry (ui_st: mut UiState) {\n var root = frame(ui_st)\n ui_press(ui_st, root.children[0].children[1])\n root = frame(ui_st)\n ui_press(ui_st, root.children[0].children[1])\n root = frame(ui_st)\n"
m = m + " print(`{root.children[0].children[0].text} {points} {bigs} {fish_casts()}`)\n }\n}\n"
write_file(`{work}/game/main.ludic`, m)
let root = capture_line("pwd")
if not shq(`cd {work} && {root}/bin/ludic migrate state game/main.ludic > out.txt 2>&1`) { bad2(lbl, capture_line(`tail -1 {work}/out.txt`)); return }
let tally = read_file(`{work}/game/parts/Tally.ludic`)
if not s_contains(tally, "component Tally (fishing_app_st: mut FishingAppState, mig_st: mut MigState) {") or not s_contains(tally, "if big() { mig_st.bigs += 1 }") { bad2(lbl, `rewrote the component as [{tally}]`); return }
if not s_contains(read_file(`{work}/game/fishing/index.ludic`), "export state FishingAppState {") { bad2(lbl, "module fishing's state is not its own"); return }
if not shq(`cd {work} && {root}/bin/ludicc --headless game/main.ludic -o mig > cc.txt 2>&1`) { bad2(lbl, capture_line(`grep -i error {work}/cc.txt | head -1`)); return }
let ran = capture_line(`{work}/mig < /dev/null`)
if ran == " #10 big 10 1 2" { ok(lbl) } else { bad2(lbl, `ran [{ran}]`) }
}
# ludic migrate state never writes outside what it was given: a module a program imports from
# elsewhere that needs changing refuses the whole run, and nothing is written
function migrate_foreign_case() -> void {
let lbl = "ludic migrate state refuses to change a file outside the programs it was given"
let work = `{tmp_dir()}/migforeign`
shell(`rm -rf {work} && mkdir -p {work}/lib/extra {work}/game`)
write_file(`{work}/lib/extra/index.ludic`, "module extra\nvar n: int = 0\nexport function bump() -> void { n += 1 }\n")
let main = "import \"../lib/extra\"\nprogram F {\n entry { bump() }\n}\n"
write_file(`{work}/game/main.ludic`, main)
let root = capture_line("pwd")
if shq(`cd {work} && {root}/bin/ludic migrate state game/main.ludic > out.txt 2>&1`) { bad2(lbl, "it went ahead"); return }
if not shq(`grep -q 'lib/extra/index.ludic' {work}/out.txt`) { bad2(lbl, capture(`cat {work}/out.txt`)); return }
if not (read_file(`{work}/game/main.ludic`) == main) { bad2(lbl, "the program was changed anyway"); return }
ok(lbl)
}
# every package's own tests, the way a package author runs them: `ludic test packages`
function packages_test_case() -> void {
let lbl = "ludic test packages: every package's tests pass"
@ -910,6 +955,11 @@ function cmd_dev_test() -> int {
reject_case("rejected/mut_not_state", "mut is for a state parameter", "mut is for a state parameter")
reject_case("rejected/state_alias_readonly", "r holds part of a read-only Table; take the state as mut Table", "a reference out of a read-only state is read-only too")
reject_case("rejected/module_let_alias", "l holds part of LIMITS, which is module-level and immutable all the way down", "a module-level let is immutable through a local that holds it")
reject_case("rejected/state_component_ro", "look is read-only here (look: Look)", "a component's state is read-only unless its header says mut")
controller_case("state/component", "", " #10 big 10 1", "component.ludic (0.S: a component's header names its states; its getters, functions and events take them and the template never sees them)")
headless_case("rendering/ui_render3d", "ok", "ui_render3d.ludic (ludic.ui's render3d backend builds against the renderer)")
migrate_component_case()
migrate_foreign_case()
reject_case("rejected/runtime_type_clash", "PadButton is the runtime's enum", "a program's type named like one of the runtime's is refused")
migrate_case()
vis_report_case("rejected/uses_missing", "uses: examples/rejected/uses_valley/items/index.ludic:3: items.inv_add used from", "LUDIC_VIS_REPORT=1 lists a uses violation and builds")