From 2161f2876699d3885346aea6cc1ae200b5bb2405 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 22:22:05 +0300 Subject: [PATCH] ludic.base: bake_expand(inputs, map) and bake_maps(first_input) beside bake_inputs_hash - {map} put, globs expanded in whole-path byte order with dot-names out - so a bake's runner and the check expand a row one way; tests/baked_expand_test Co-Authored-By: Claude Opus 5.5 --- changes/bake-expand.md | 8 ++ packages/ludic.base/baked_expand.ludic | 74 +++++++++++++++++++ packages/ludic.base/baked_match.ludic | 53 +++++++++++++ packages/ludic.base/index.ludic | 2 + .../ludic.base/tests/baked_expand_test.ludic | 48 ++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 changes/bake-expand.md create mode 100644 packages/ludic.base/baked_expand.ludic create mode 100644 packages/ludic.base/baked_match.ludic create mode 100644 packages/ludic.base/tests/baked_expand_test.ludic diff --git a/changes/bake-expand.md b/changes/bake-expand.md new file mode 100644 index 00000000..b604ff29 --- /dev/null +++ b/changes/bake-expand.md @@ -0,0 +1,8 @@ +bump: minor +type: feature +**A bake's inputs can follow the data.** `bake_expand(inputs, map)` gives a Bakes row's inputs as they are +hashed: `{map}` put as the map's key, and each input with a `*` put as the paths it matches, sorted as whole +paths byte by byte, dot-names left out, a glob matching nothing gone. `bake_maps(first_input)` is the maps a +`{map}` row covers: each directory under assets/maps holding its first input (`bake_maps_in` under another +root). A runner hashes `bake_inputs_hash(bake_expand(row.inputs, map))`, the same path the check takes, so +the two cannot disagree on stale; a game's Python tools are its checked twin. diff --git a/packages/ludic.base/baked_expand.ludic b/packages/ludic.base/baked_expand.ludic new file mode 100644 index 00000000..63e9a2aa --- /dev/null +++ b/packages/ludic.base/baked_expand.ludic @@ -0,0 +1,74 @@ +# baked_expand.ludic - a Bakes row's inputs as they are hashed, following the data: `{map}` put as one +# map's key, and each input with a `*` put as the paths it matches, sorted as whole paths byte by byte, +# dot-names left out. tools/bake/baked.py `expand` is its checked twin, as baked_hash is the hash's: a +# runner and the check that expand alike agree on stale. A runner's line: bake_inputs_hash(bake_expand(...)). + +# the inputs with `{map}` as `map` (none when "") and every glob expanded, space-separated +@alloc_ok("a bake's inputs: once a bake or a check") +export function bake_expand(inputs: string, map: string) -> string { + var ins = inputs + if len(map) > 0 { ins = Text.replace(inputs, "{map}", map) } + let parts = Text.split(ins, " ") + var out = "" + for i in 0 .. len(parts) { + if len(parts[i]) == 0 { continue } + var hits = new []string + if Text.contains(parts[i], "*") { hits = bk_sorted(bk_glob(parts[i])) } else { push(hits, parts[i]) } + for k in 0 .. len(hits) { + if len(out) > 0 { out = out + " " } + out = out + hits[k] + } + } + return out +} + +# the maps a `{map}` row covers: each directory under assets/maps holding its first input, sorted +export function bake_maps(first_input: string) -> []string { return bake_maps_in("assets/maps", first_input) } +# ... under another directory of maps (a test's) +@alloc_ok("a bake's inputs: once a bake or a check") +export function bake_maps_in(root: string, first_input: string) -> []string { + let got = new []string + let names = bk_sorted(Fs.list(root)) + for i in 0 .. len(names) { + if len(names[i]) == 0 or names[i][0] == 46 { continue } + if Fs.exists(Text.replace(first_input, "{map}", names[i])) { push(got, names[i]) } + } + return got +} + +# every existing path a pattern matches, a directory level at a time +@alloc_ok("a bake's inputs: once a bake or a check") +function bk_glob(pat: string) -> []string { + let segs = Text.split(pat, "/") + var at = new []string + if len(pat) > 0 and pat[0] == 47 { push(at, "/") } else { push(at, "") } + for s in 0 .. len(segs) { + if len(segs[s]) == 0 { continue } + let next = new []string + for a in 0 .. len(at) { bk_glob_step(at[a], segs[s], next) } + at = next + } + let out = new []string + for i in 0 .. len(at) { if len(at[i]) > 0 and Fs.exists(at[i]) { push(out, at[i]) } } + return out +} +# one directory level of a glob: a literal segment joined on, a pattern matched against the listing +@alloc_ok("a bake's inputs: once a bake or a check") +function bk_glob_step(dir: string, seg: string, next: []string) -> void { + if not Text.contains(seg, "*") { + push(next, bk_join(dir, seg)) + return + } + var d = dir + if len(d) == 0 { d = "." } + let names = Fs.list(d) + if names == null { return } + for k in 0 .. len(names) { + if len(names[k]) > 0 and names[k][0] != 46 and bk_match(names[k], seg) { push(next, bk_join(dir, names[k])) } + } +} +function bk_join(dir: string, name: string) -> string { + if len(dir) == 0 { return name } + if dir[len(dir) - 1] == 47 { return dir + name } + return dir + "/" + name +} diff --git a/packages/ludic.base/baked_match.ludic b/packages/ludic.base/baked_match.ludic new file mode 100644 index 00000000..73b0f237 --- /dev/null +++ b/packages/ludic.base/baked_match.ludic @@ -0,0 +1,53 @@ +# baked_match.ludic - the two small questions a bake's glob asks (baked_expand.ludic): does a name match a +# pattern of literal bytes and `*`, and which of two paths comes first byte by byte (Python's str order) + +# `*` matches any run of bytes, including none; everything else only itself +function bk_match(name: string, pat: string) -> bool { + var n = 0 + var p = 0 + var star = -1 + var mark = 0 + while n < len(name) { + if p < len(pat) and pat[p] != 42 and pat[p] == name[n] { + n += 1 + p += 1 + } else if p < len(pat) and pat[p] == 42 { + star = p + mark = n + p += 1 + } else if star >= 0 { + p = star + 1 + mark += 1 + n = mark + } else { + return false + } + } + while p < len(pat) and pat[p] == 42 { p += 1 } + return p == len(pat) +} + +# a before b, byte by byte, the shorter first on a tie +function bk_before(a: string, b: string) -> bool { + var i = 0 + while i < len(a) and i < len(b) { + if a[i] != b[i] { return (a[i] & 255) < (b[i] & 255) } + i += 1 + } + return len(a) < len(b) +} +# a list sorted in place by bk_before (a directory's names: a few dozen); null is an empty list +@alloc_ok("a bake's inputs: once a bake or a check") +function bk_sorted(xs: []string) -> []string { + if xs == null { return new []string } + for i in 1 .. len(xs) { + let x = xs[i] + var j = i - 1 + while j >= 0 and bk_before(x, xs[j]) { + xs[j + 1] = xs[j] + j -= 1 + } + xs[j + 1] = x + } + return xs +} diff --git a/packages/ludic.base/index.ludic b/packages/ludic.base/index.ludic index 43904057..f5a1b4f5 100644 --- a/packages/ludic.base/index.ludic +++ b/packages/ludic.base/index.ludic @@ -12,6 +12,8 @@ import "text_intern.ludic" import "text_ring.ludic" import "baked.ludic" import "baked_hash.ludic" +import "baked_match.ludic" +import "baked_expand.ludic" import "baked_stream.ludic" import "rng.ludic" import "save.ludic" diff --git a/packages/ludic.base/tests/baked_expand_test.ludic b/packages/ludic.base/tests/baked_expand_test.ludic new file mode 100644 index 00000000..13bc90c0 --- /dev/null +++ b/packages/ludic.base/tests/baked_expand_test.ludic @@ -0,0 +1,48 @@ +# baked_expand_test.ludic - a Bakes row's inputs expanded as tools/bake/baked.py `expand` does: `{map}` over +# the map directories holding the first input (dot-directories out), a glob as its matches sorted as whole +# paths byte by byte (dot-names out), a glob that matches nothing gone, a literal kept even when missing +import "ludic.base" +program BakedExpandTest { + function root() -> string { return Os.temp_dir() + "/ludic-bake-expand" } + function put(path: string) -> void { Fs.write_text(path, "x") } + function dirs(ps: []string) -> void { for i in 0 .. len(ps) { Fs.mkdir(root() + ps[i]) } } + # maps a and b hold a layers file, c does not, .git is a dot-directory; b's ground has four PNGs + function fixture() -> void { + dirs(["", "/maps", "/maps/a", "/maps/a/ground", "/maps/b", "/maps/b/ground", "/maps/c", "/maps/.git", "/m", "/m-x"]) + put(root() + "/maps/a/ground_layers.lres") + put(root() + "/maps/a/ground/x.png") + put(root() + "/maps/b/ground_layers.lres") + put(root() + "/maps/b/ground/z.png") + put(root() + "/maps/b/ground/a.png") + put(root() + "/maps/b/ground/a-b.png") + put(root() + "/maps/b/ground/.hidden.png") + put(root() + "/maps/b/ground/notes.txt") + put(root() + "/maps/.git/ground_layers.lres") + put(root() + "/m/f.txt") + put(root() + "/m-x/f.txt") + } + + test "a {map} row covers each map directory holding its first input, sorted" { + fixture() + let maps = bake_maps_in(root() + "/maps", root() + "/maps/{map}/ground_layers.lres") + expect_eq(len(maps), 2) + expect_eq(maps[0], "a") + expect_eq(maps[1], "b") + } + + test "a map's inputs: {map} put, the glob's matches sorted byte by byte, dot-names out" { + fixture() + let r = root() + let row = r + "/maps/{map}/ground_layers.lres " + r + "/maps/{map}/ground/*.png tools/x.py" + let got = bake_expand(row, "b") + expect_eq(got, `{r}/maps/b/ground_layers.lres {r}/maps/b/ground/a-b.png {r}/maps/b/ground/a.png {r}/maps/b/ground/z.png tools/x.py`) + } + + test "whole paths sort across directories, a glob with no match is gone, a literal stays" { + fixture() + let r = root() + expect_eq(bake_expand(`{r}/m*/f.txt`, ""), `{r}/m-x/f.txt {r}/m/f.txt`) + expect_eq(bake_expand(`{r}/none/*.png`, ""), "") + expect_eq(bake_expand(`{r}/missing.lres {r}/maps/a/ground/*.png`, ""), `{r}/missing.lres {r}/maps/a/ground/x.png`) + } +}