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 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 22:22:05 +03:00
parent edc94e49e8
commit 2161f28766
5 changed files with 185 additions and 0 deletions

8
changes/bake-expand.md Normal file
View file

@ -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.

View file

@ -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
}

View file

@ -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
}

View file

@ -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"

View file

@ -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`)
}
}