A pack root is packed wholesale, and that is the right default - a game writes `pack "assets"` and everything it opens is in the pack. What also goes in is everything the game does NOT open: the preview renders a model pipeline leaves beside its meshes, the intermediate a texture bake writes and never reads again, the .blend the .gltf came out of. Nothing errors, nothing looks wrong, and the app is simply bigger than the game. The only way out the manifest offered was naming every file by hand, which is worse - a list that goes stale the day someone adds a texture. So `.packignore`, with gitignore's rules, because that is the file everyone already knows. Anchored and floating patterns, `preview/` for directories only, `*` and `?` stopping at a separator where `**` crosses one, `[a-z]` classes, `!` re-includes with the last line winning, a deeper file beating a shallower one, and no re-including out of an ignored directory. The semantics are not claimed, they are checked: the implementation was diffed against git itself over two fixtures - 35 paths, 19 patterns, nested ignore files, directory negation, `[!0-9]` and `\#` escaping - and `git check-ignore` and `ludic pack` agree on every path. Two rules of its own, because a pack is not a working tree. `.packignore` is never packed (nothing reads one at run time, and --no-ignore does not bring it back). And it governs the project's own roots only: a package's resources - the renderer's shaders above all - are added after the gather, so a stray `*.frag` in a game's ignore file cannot quietly un-ship what it needs to draw anything. `ludic pack` reports what it left out; `--no-ignore` packs everything so you can see what a rule is costing. `ludic bundle` gathers through the same path, so the two agree by construction.
311 lines
10 KiB
Text
311 lines
10 KiB
Text
# packignore.ludic — `.packignore`: what the shipped asset pack leaves out.
|
|
#
|
|
# A pack root is packed WHOLESALE, and that is the right default: a game says
|
|
# `pack "assets"` and everything the game opens is in the pack. What it also puts in
|
|
# the pack is everything the game does NOT open — the preview renders a model builder
|
|
# writes beside its meshes, the intermediate a texture bake leaves behind, the `.blend`
|
|
# the `.gltf` came out of. None of it errors, none of it is visible, and all of it
|
|
# ships. The alternative the manifest offered was naming every file one by one, which
|
|
# is worse: it is a list that goes stale the day someone adds a texture.
|
|
#
|
|
# So the ignore file, and deliberately the one everybody already knows:
|
|
#
|
|
# # a comment
|
|
# preview/ # a directory, anywhere under here
|
|
# *.blend # by extension, at any depth
|
|
# /scratch # only at the root of this ignore file's directory
|
|
# **/tmp # any depth, said explicitly
|
|
# !keep/important.png # re-include something an earlier line excluded
|
|
#
|
|
# The semantics are gitignore's, and the ones that matter are the ones people rely on
|
|
# without thinking about them:
|
|
#
|
|
# - Blank lines and `#` comments are skipped. A literal `#` or `!` at the start of a
|
|
# pattern is written `\#` / `\!`.
|
|
# - A pattern with NO slash matches a name at ANY depth. `*.blend` catches
|
|
# `a/b/c.blend`.
|
|
# - A pattern WITH a slash is anchored to the directory holding the `.packignore`.
|
|
# `/scratch` is that directory's own `scratch`; `doc/tmp` is `doc/tmp` under it.
|
|
# - A trailing slash means "directory only": `preview/` ignores the directory and
|
|
# everything under it, and never a FILE called `preview`.
|
|
# - `*` and `?` do not cross a `/`; `**` does. `**/tmp` is any `tmp`, `a/**/b` is `b`
|
|
# under `a` at any depth, `a/b` included.
|
|
# - `[abc]`, `[a-z]` and `[!abc]` are character classes.
|
|
# - `!` re-includes. Within one file the LAST matching line decides.
|
|
# - A `.packignore` deeper in the tree beats a shallower one for the paths it covers.
|
|
# - A file under an ignored DIRECTORY cannot be re-included, exactly as in git: once
|
|
# `preview/` is out, `!preview/keep.png` does not bring it back. Ignore the files
|
|
# rather than the directory if that is what you meant.
|
|
#
|
|
# Two rules of its own, both because this is a pack and not a working tree:
|
|
#
|
|
# - `.packignore` files are never packed - nothing reads one at run time. That is
|
|
# done in the gather (`pack.ludic`), not by a rule here, so `--no-ignore` does not
|
|
# bring them back either.
|
|
# - It applies to the PROJECT's own roots. The resources a package contributes to a
|
|
# bundle - the renderer's shaders, say - are that package's business and are added
|
|
# after this runs, so a stray `*.frag` in a game's ignore file cannot silently
|
|
# un-ship the shaders it needs to draw anything.
|
|
|
|
const PI_UNKNOWN: int = -1
|
|
const PI_INCLUDE: int = 0
|
|
const PI_IGNORE: int = 1
|
|
|
|
property PackIgnore {
|
|
base: []pointer, # directory the rule was read in, relative to the pack cwd ("" = root)
|
|
depth: []int, # segments in `base`; a deeper file wins over a shallower one
|
|
pat: []pointer, # the pattern, with `!`, a leading `/` and a trailing `/` stripped
|
|
negate: []int,
|
|
dironly: []int,
|
|
anchored: []int
|
|
}
|
|
|
|
function pi_new() -> PackIgnore {
|
|
let g = new PackIgnore
|
|
g.base = new []pointer
|
|
g.depth = new []int
|
|
g.pat = new []pointer
|
|
g.negate = new []int
|
|
g.dironly = new []int
|
|
g.anchored = new []int
|
|
return g
|
|
}
|
|
|
|
function pi_count(g: PackIgnore) -> int { return len(g.pat) }
|
|
|
|
# how many segments deep a directory is; "" is 0
|
|
function pi_depth_of(dir: pointer) -> int {
|
|
if dir == "" { return 0 }
|
|
var n = 1
|
|
var i = 0
|
|
let e = slen(dir)
|
|
while i < e {
|
|
if dir[i] == '/' { n += 1 }
|
|
i += 1
|
|
}
|
|
return n
|
|
}
|
|
|
|
# ---- reading one file -------------------------------------------------------
|
|
|
|
# Parse `{dir}/.packignore` into rules tagged with `dir`. Missing file: nothing.
|
|
function pi_load_dir(g: PackIgnore, dir: pointer) -> void {
|
|
var path = ".packignore"
|
|
if dir != "" { path = `{dir}/.packignore` }
|
|
if not file_exists(path) { return }
|
|
let text = read_file(path)
|
|
if text == null { return }
|
|
let n = slen(text)
|
|
let d = pi_depth_of(dir)
|
|
var i = 0
|
|
while i < n {
|
|
let line = line_at(text, i)
|
|
i = i + slen(line) + 1
|
|
pi_add_rule(g, dir, d, s_trim(line))
|
|
}
|
|
}
|
|
|
|
function pi_add_rule(g: PackIgnore, dir: pointer, depth: int, raw: pointer) -> void {
|
|
var p = raw
|
|
if slen(p) == 0 { return }
|
|
if p[0] == '#' { return }
|
|
var neg = 0
|
|
if p[0] == '!' { neg = 1; p = sslice(p, 1, slen(p)) }
|
|
# an escaped leading marker is a literal one
|
|
if slen(p) > 1 and p[0] == '\\' and (p[1] == '#' or p[1] == '!') { p = sslice(p, 1, slen(p)) }
|
|
if slen(p) == 0 { return }
|
|
var dironly = 0
|
|
while slen(p) > 0 and p[slen(p) - 1] == '/' { dironly = 1; p = sslice(p, 0, slen(p) - 1) }
|
|
if slen(p) == 0 { return }
|
|
# `**/x` is the explicit spelling of "x at any depth", so it is NOT anchored
|
|
var anchored = 1
|
|
if s_starts(p, "**/") { p = sslice(p, 3, slen(p)); anchored = 0 }
|
|
else if p[0] == '/' { p = sslice(p, 1, slen(p)) }
|
|
else if not s_contains(p, "/") { anchored = 0 }
|
|
if slen(p) == 0 { return }
|
|
push(g.base, dir)
|
|
push(g.depth, depth)
|
|
push(g.pat, p)
|
|
push(g.negate, neg)
|
|
push(g.dironly, dironly)
|
|
push(g.anchored, anchored)
|
|
}
|
|
|
|
# Every `.packignore` that can bear on `root`: the one at the pack's working
|
|
# directory, then one per directory inside it. Call once per pack, for each root.
|
|
function pi_load_root(g: PackIgnore, root: pointer) -> void {
|
|
let listing = capture(`find -L {root} -name .packignore -type f 2>/dev/null | LC_ALL=C sort`)
|
|
let n = slen(listing)
|
|
var i = 0
|
|
while i < n {
|
|
let line = line_at(listing, i)
|
|
i = i + slen(line) + 1
|
|
let rel = s_trim(line)
|
|
if slen(rel) == 0 { continue }
|
|
pi_load_dir(g, pi_dirname(rel))
|
|
}
|
|
}
|
|
|
|
# the directory part of a path, "" when there is none
|
|
function pi_dirname(path: pointer) -> pointer {
|
|
var cut = -1
|
|
var i = 0
|
|
let e = slen(path)
|
|
while i < e {
|
|
if path[i] == '/' { cut = i }
|
|
i += 1
|
|
}
|
|
if cut < 0 { return "" }
|
|
return sslice(path, 0, cut)
|
|
}
|
|
|
|
function pi_basename(path: pointer) -> pointer {
|
|
var cut = -1
|
|
var i = 0
|
|
let e = slen(path)
|
|
while i < e {
|
|
if path[i] == '/' { cut = i }
|
|
i += 1
|
|
}
|
|
if cut < 0 { return path }
|
|
return sslice(path, cut + 1, e)
|
|
}
|
|
|
|
# ---- matching ---------------------------------------------------------------
|
|
|
|
# One glob against one string. `*` and `?` stop at a `/`; `**` does not.
|
|
function pi_glob(pat: pointer, txt: pointer) -> bool {
|
|
return pi_glob_at(pat, 0, txt, 0)
|
|
}
|
|
|
|
function pi_glob_at(p: pointer, pi: int, t: pointer, ti: int) -> bool {
|
|
var i = pi
|
|
var j = ti
|
|
while true {
|
|
if p[i] == 0 { return t[j] == 0 }
|
|
# `**` — crosses separators
|
|
if p[i] == '*' and p[i + 1] == '*' {
|
|
var rest = i + 2
|
|
if p[rest] == '/' {
|
|
# `a/**/b` also matches `a/b`: the zero-directory case
|
|
if pi_glob_at(p, rest + 1, t, j) { return true }
|
|
var k = j
|
|
while t[k] != 0 {
|
|
if t[k] == '/' { if pi_glob_at(p, rest + 1, t, k + 1) { return true } }
|
|
k += 1
|
|
}
|
|
return false
|
|
}
|
|
var k2 = j
|
|
if pi_glob_at(p, rest, t, k2) { return true }
|
|
while t[k2] != 0 {
|
|
k2 += 1
|
|
if pi_glob_at(p, rest, t, k2) { return true }
|
|
}
|
|
return false
|
|
}
|
|
if p[i] == '*' {
|
|
var k = j
|
|
if pi_glob_at(p, i + 1, t, k) { return true }
|
|
while t[k] != 0 and t[k] != '/' {
|
|
k += 1
|
|
if pi_glob_at(p, i + 1, t, k) { return true }
|
|
}
|
|
return false
|
|
}
|
|
if p[i] == '?' {
|
|
if t[j] == 0 or t[j] == '/' { return false }
|
|
i += 1; j += 1; continue
|
|
}
|
|
if p[i] == '[' {
|
|
if t[j] == 0 or t[j] == '/' { return false }
|
|
let after = pi_class(p, i, t[j] & 255)
|
|
if after < 0 { return false }
|
|
i = after; j += 1; continue
|
|
}
|
|
if p[i] == '\\' and p[i + 1] != 0 {
|
|
if t[j] != p[i + 1] { return false }
|
|
i += 2; j += 1; continue
|
|
}
|
|
if t[j] != p[i] { return false }
|
|
if p[i] == 0 { return true }
|
|
i += 1; j += 1
|
|
}
|
|
return false
|
|
}
|
|
|
|
# A `[...]` class at p[at]. Returns the index just past the `]` on a match, or -1.
|
|
# A `]` straight after the `[` or the `!` is a literal one, as in a shell.
|
|
function pi_class(p: pointer, at: int, c: int) -> int {
|
|
var i = at + 1
|
|
var neg = false
|
|
if p[i] == '!' or p[i] == '^' { neg = true; i += 1 }
|
|
var hit = false
|
|
var first = true
|
|
while p[i] != 0 and (p[i] != ']' or first) {
|
|
first = false
|
|
var lo = p[i] & 255
|
|
if p[i] == '\\' and p[i + 1] != 0 { i += 1; lo = p[i] & 255 }
|
|
if p[i + 1] == '-' and p[i + 2] != 0 and p[i + 2] != ']' {
|
|
let hi = p[i + 2] & 255
|
|
if c >= lo and c <= hi { hit = true }
|
|
i += 3
|
|
} else {
|
|
if c == lo { hit = true }
|
|
i += 1
|
|
}
|
|
}
|
|
if p[i] != ']' { return -1 } # unterminated: not a class at all
|
|
if neg { hit = not hit }
|
|
if not hit { return -1 }
|
|
return i + 1
|
|
}
|
|
|
|
# Does one rule bear on `path`, and does it match?
|
|
function pi_rule_hits(g: PackIgnore, r: int, path: pointer, is_dir: bool) -> bool {
|
|
if g.dironly[r] == 1 and not is_dir { return false }
|
|
let base = g.base[r]
|
|
var sub = path
|
|
if base != "" {
|
|
if not s_starts(path, `{base}/`) { return false }
|
|
sub = sslice(path, slen(base) + 1, slen(path))
|
|
}
|
|
if g.anchored[r] == 1 { return pi_glob(g.pat[r], sub) }
|
|
return pi_glob(g.pat[r], pi_basename(sub))
|
|
}
|
|
|
|
# The decision for one path: the DEEPEST `.packignore` with something to say wins,
|
|
# and within one file the LAST line that matches.
|
|
function pi_decide(g: PackIgnore, path: pointer, is_dir: bool) -> int {
|
|
var best = -1
|
|
var out = PI_UNKNOWN
|
|
var r = 0
|
|
let n = len(g.pat)
|
|
while r < n {
|
|
if pi_rule_hits(g, r, path, is_dir) and g.depth[r] >= best {
|
|
best = g.depth[r]
|
|
if g.negate[r] == 1 { out = PI_INCLUDE } else { out = PI_IGNORE }
|
|
}
|
|
r += 1
|
|
}
|
|
return out
|
|
}
|
|
|
|
# Is this file kept out of the pack?
|
|
#
|
|
# An ignored DIRECTORY takes everything under it and a `!` cannot bring any of it back
|
|
# - git's rule, and the one that makes `preview/` mean what people expect. So the
|
|
# ancestors are tested first, shallowest down, and the file itself only if they all
|
|
# passed.
|
|
function pi_ignored(g: PackIgnore, path: pointer) -> bool {
|
|
if len(g.pat) == 0 { return false }
|
|
var i = 0
|
|
let e = slen(path)
|
|
while i < e {
|
|
if path[i] == '/' {
|
|
if pi_decide(g, sslice(path, 0, i), true) == PI_IGNORE { return true }
|
|
}
|
|
i += 1
|
|
}
|
|
return pi_decide(g, path, false) == PI_IGNORE
|
|
}
|