feat(base): 0.R4 - a queue keeps its own count, so every package verb takes only its own state; ludic migrate state --prune

ludic.base's Queue<T> carries a QueueTag (its name and pending count): queue_new(name), q_push(q, v),
q_drain(q), q_clear(q) take no BaseState, and core_undrained(tags) names the given queues still holding
facts. A reducer on a package's state can now call that package's verbs (wallet_earn(wallet_st, n)).

ludic migrate state --prune (ludicc --migrate-prune) takes out each state parameter a function no
longer uses, nor anything it calls, and the argument that fills it - including an argument for a
parameter the callee has dropped, which is taken out before the call is checked, so a generic's T is
told by the argument that says it. A reducer keeps its state. --dry-run now counts the edits it would
make. Every package was moved with it: 608 base_st parameters and their arguments, 1889 edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-26 04:19:56 +03:00
parent 8cf4b3fed6
commit 71735b10a2
151 changed files with 58668 additions and 54719 deletions

View file

@ -48,7 +48,7 @@ program Ludic {
print(" compile and run the project's tests (-v: every test's line)")
print(" deps [file] [--graph|--dot|--writes|--uses MOD|--check F|--baseline F]")
print(" the module graph as the compiler sees it, and how tangled it is")
print(" migrate state [file] [--runtime] module-level vars into states, passed as parameters (0.S)")
print(" migrate state [file] [--prune] [--runtime] module-level vars into states, passed as parameters (0.S); --prune drops states nothing uses")
print(" clean remove build/")
print("")
print("packages:")

View file

@ -3,7 +3,9 @@
# migrate.ludic, `ludicc --migrate-state <plan>`); this applies them to the source, file by file,
# and prints what it could not decide.
#
# ludic migrate state [file] [--runtime] [--unsafe] [--dry-run]
# ludic migrate state [file|dir...] [--prune] [--runtime] [--unsafe] [--dry-run]
# --prune also takes out every state parameter (and the argument that fills it) a function no
# longer uses, nor anything it calls
#
# Run it once per program that uses what changes (a package's test programs, the game's entry,
# its lab): a later run finds the states an earlier one made and passes them on.
@ -80,6 +82,30 @@ function mgx_rtrim(s: pointer) -> pointer {
while n > 0 and (s[n - 1] == 10 or s[n - 1] == 13 or s[n - 1] == 32) { n -= 1 }
return sslice(s, 0, n)
}
function mgx_merge_dels(f: []pointer, a: []int, b: []int) -> void {
let used = new []bool
for i in 0 .. len(f) { push(used, false) }
for i in 0 .. len(f) {
if not used[i] {
var lo = a[i]
var hi = b[i]
var grew = true
while grew {
grew = false
for j in 0 .. len(f) {
if not used[j] and j != i and f[j] == f[i] and a[j] <= hi and b[j] >= lo {
if a[j] < lo { lo = a[j] }
if b[j] > hi { hi = b[j] }
used[j] = true
grew = true
}
}
}
used[i] = true
mgx_edit(f[i], lo, hi - lo, "")
}
}
}
# the brace that closes the first block after `at` (strings skipped), or -1
function mgx_close(t: pointer, at: int) -> int {
var i = at
@ -146,13 +172,14 @@ function mgx_mine(path: pointer) -> bool {
}
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")
err("usage: ludic migrate state [file|dir...] [--prune] [--runtime] [--unsafe] [--dry-run]\n")
return 2
}
let srcs = new []pointer
mgx_roots = new []pointer
var runtime = ""
var dry = false
var prune = ""
var ai = 3
while ai < arg_count() {
let a = arg(ai)
@ -162,6 +189,7 @@ function cmd_migrate() -> int {
}
else if a == "--unsafe" { g_unsafe_build = true }
else if a == "--dry-run" { dry = true }
else if a == "--prune" { prune = " --migrate-prune" }
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)
@ -187,7 +215,7 @@ function cmd_migrate() -> int {
for i in 0 .. len(srcs) {
let plan = tmp_path(`migrate{string(i)}.plan`)
shell(`rm -f {plan}`)
shq(`{ludicc()} --headless{unsafe_flag()}{runtime} --migrate-state {plan} {sh_single(srcs[i])} > {tmp_path("migrate.err")} 2>&1`)
shq(`{ludicc()} --headless{unsafe_flag()}{runtime}{prune} --migrate-state {plan} {sh_single(srcs[i])} > {tmp_path("migrate.err")} 2>&1`)
let text = read_file(plan)
if text == null {
err(`ludic migrate: the compiler wrote no plan for {srcs[i]}:\n`)
@ -246,12 +274,15 @@ function mgx_apply(lines: []pointer, dry: bool) -> int {
let reports = new []pointer
let foreign = new []pointer # files a plan would change that the migration may not
let lfile = new []pointer
let dfile = new []pointer # DELITEM: ranges to take out, merged where they touch
let da = new []int
let db = new []int
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] == "VAR") or (w[0] == "REF") or (w[0] == "INS") or (w[0] == "CLOSE") or (w[0] == "LET") or (w[0] == "DELITEM") { 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
@ -292,6 +323,30 @@ 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] == "DELITEM") and len(w) == 3 {
# a parameter or an argument taken out, with the comma that goes with it
let t = mgx_text_of(w[1])
let p = s_to_int(w[2])
var e = p
var depth = 0
while t[e] != 0 and not (depth == 0 and (t[e] == ',' or t[e] == ')')) {
if t[e] == '(' or t[e] == '[' { depth += 1 }
if t[e] == ')' or t[e] == ']' { depth -= 1 }
e += 1
}
var a = p
var b = e
if t[e] == ',' {
b = e + 1
while t[b] == ' ' { b += 1 }
} else {
var k = p - 1
while k > 0 and t[k] == ' ' { k -= 1 }
if t[k] == ',' { a = k }
}
push(dfile, w[1])
push(da, a)
push(db, b)
} else if (w[0] == "CLOSE") and len(w) == 4 {
let t = mgx_text_of(w[1])
var k = s_to_int(w[2])
@ -302,6 +357,8 @@ function mgx_apply(lines: []pointer, dry: bool) -> int {
push(lpos, s_to_int(w[2]))
} else if w[0] == "?" { push(reports, lines[i]) }
}
# the taken-out ranges: two neighbours in one list overlap (each with its comma), so they merge
mgx_merge_dels(dfile, da, db)
# a var nothing writes is a let - unless another program's plan saw it written
var lets = 0
for l in 0 .. len(lfile) {
@ -425,7 +482,10 @@ function mgx_apply(lines: []pointer, dry: bool) -> int {
}
if len(idx) == 0 { continue }
files += 1
if dry { continue }
if dry {
edits += len(idx) # what would be made; nothing is written
continue
}
let out = file_open(path, "wb")
if out == null {
err(`ludic migrate: cannot write {path}\n`)

View file

@ -537,6 +537,33 @@ function migrate_case() -> void {
if ran == "2 names, first a" { ok(lbl) } else { bad2(lbl, `ran [{ran}]`) }
}
# ludic migrate state --prune: a state nothing uses leaves the functions that take it and the calls
# that pass it; an argument for a parameter the callee no longer has goes too (a package's verb that
# dropped a state), and the program then builds and runs
function migrate_prune_case() -> void {
let lbl = "ludic migrate state --prune: unused states and the arguments that fill them go, the same output"
let work = `{tmp_dir()}/migprune`
shell(`rm -rf {work} && mkdir -p {work}`)
var src = "program Prune {\n state Log { lines: []string = new []string }\n state Bag { items: []int = new []int }\n"
src = src + " function head<T>(xs: []T) -> T { return xs[0] }\n"
src = src + " function put(b: mut Bag, v: int) -> void { push(b.items, v) }\n"
src = src + " function add(log_st: mut Log, b: mut Bag, v: int) -> void { put(log_st, b, v) }\n"
src = src + " function outer(log_st: mut Log, b: mut Bag) -> void { add(log_st, b, 4) }\n"
src = src + " function top(log_st: Log, b: Bag) -> int { return head(log_st, b.items) }\n"
src = src + " function said(log_st: mut Log, b: Bag) -> int {\n push(log_st.lines, \"x\")\n return len(log_st.lines)\n }\n"
src = src + " entry (log_st: mut Log, b: mut Bag) {\n outer(log_st, b)\n print(`{top(log_st, b)} {said(log_st, b)}`)\n }\n}\n"
write_file(`{work}/prune.ludic`, src)
if not shq(`bin/ludic migrate state --prune {work}/prune.ludic > {work}/out.txt 2>&1`) { bad2(lbl, capture_line(`tail -1 {work}/out.txt`)); return }
let got = read_file(`{work}/prune.ludic`)
if not s_contains(got, "function add(b: mut Bag, v: int) -> void { put(b, v) }") or not s_contains(got, "function outer(b: mut Bag) -> void { add(b, 4) }") or not s_contains(got, "function top(b: Bag) -> int { return head(b.items) }") or not s_contains(got, "function said(log_st: mut Log) -> int") or not s_contains(got, "entry (log_st: mut Log, b: mut Bag)") or not s_contains(got, "{top(b)} {said(log_st)}") {
bad2(lbl, `rewrote it as [{got}]`)
return
}
if not shq(`bin/ludicc {work}/prune.ludic -o {work}/prune > {work}/cc.txt 2>&1`) { bad2(lbl, capture_line(`grep -i error {work}/cc.txt | head -1`)); return }
let ran = capture_line(`{work}/prune`)
if ran == "4 1" { ok(lbl) } else { bad2(lbl, `ran [{ran}]`) }
}
# expect_eq on strings: the text is compared, and a failure prints both (the spec fails on purpose)
function expect_str_case() -> void {
let lbl = "testing_strings.ludic (expect_eq compares strings by content, a null only equal to a null, and prints both on a failure)"
@ -995,6 +1022,7 @@ function cmd_dev_test() -> int {
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()
migrate_prune_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")
feat_case("lang/checked", "", "7.5 3 12 hi 2 ok", "checked.ludic (L4: literals take their slot's kind, string(p), []string as []pointer, null, named args)")
reject_case("rejected/wrong_arity", "this call to area leaves out h, which has no default", "a call with the wrong number of arguments is refused")