# pkg.ludic — the Ludic package manager (issue #63), the `ludic add`/`get`/ # `update`/`verify`/`vendor` commands. # # It realises the v1 direction decided in the RFC: # # * URL-as-identity, no registry — the import path IS the git location # (git.workshopsoft.io/user/pkg). A `git tag` is publishing. # * Minimum Version Selection (MVS, Go-style) — a require is a *minimum*; the # resolver picks, per module, the greatest of the required minimums, then # the reachable closure at those versions. Deterministic, no SAT solver. # * A content-addressed global store + per-project symlinks (pnpm-style): one # immutable store keyed by a content hash, each project a light linked view # under ludic_modules/ (which the compiler resolves imports against). # * package.ludic (declared deps + provided namespaces + kind + targets) and # package.lock.ludic (resolved versions + content hashes) as the manifest # and lockfile. # # Fetching drives plain `git` through the `run`/`capture` intrinsics — no new # infrastructure. A source package's Ludic is compiled together with the # consumer via the module-root import fallback in the compiler (do_import); a # prebuilt lib declares its shipped targets and is resolved against the build # target. Everything is content-verified against the lockfile by `ludic verify`. # ---- the manifest / lock model ---------------------------------------------- property Dep { module: pointer = "", ver: pointer = "" } property Manifest { module: pointer = "", ver: pointer = "", kind: pointer = "source", # source | prebuilt hash: pointer = "", # content hash, filled in after a snapshot provides: []pointer, # the Foo.* namespace(s) this package registers targets: []pointer, # prebuilt: the targets it ships (native-arm64, wasm32, …) deps: []Dep, entry: pointer = "", # `entry "src/game.ludic"` — the program run/build/bundle compile packs: []pointer, # `pack ""` — asset roots that go into the .lpak app: []pointer, # `app ""` — flattened key, value, key, value… scripts: []pointer, # `script "" ""` — flattened name, command, … hooks: []pointer # `hook ""` — flattened triples } function manifest_new() -> Manifest { let m = new Manifest m.module = "" m.ver = "" m.kind = "source" m.hash = "" m.entry = "" m.provides = new []pointer m.targets = new []pointer m.deps = new []Dep m.packs = new []pointer m.app = new []pointer m.scripts = new []pointer m.hooks = new []pointer return m } # a bare newline string (the lexer has no reliable \n inside interpolation) function nl() -> pointer { let b = bytes(2); b[0] = '\n'; b[1] = 0; return b } # ---- version arithmetic (semver major.minor.patch, pre-release ignored) ------ # drop a leading 'v' (118) from a tag/version string function strip_v(s: pointer) -> pointer { if slen(s) > 0 and s[0] == 'v' { return sslice(s, 1, slen(s)) } return s } # major/minor/patch as three ints (missing fields are 0); stops at -/+ metadata function ver_nums(s: pointer) -> []int { let v = strip_v(s) var parts = new []int var cur = 0 let n = slen(v) var i = 0 while i < n { let c = v[i] if c >= '0' and c <= '9' { cur = cur * 10 + (c - 48) } else if c == '.' { push(parts, cur); cur = 0 } else { i = n } # a '-'/'+' ends the numeric core i += 1 } push(parts, cur) while len(parts) < 3 { push(parts, 0) } return parts } # -1 / 0 / 1 for a < b / a == b / a > b function ver_cmp(a: pointer, b: pointer) -> int { let pa = ver_nums(a) let pb = ver_nums(b) var i = 0 while i < 3 { if pa[i] < pb[i] { return -1 } if pa[i] > pb[i] { return 1 } i += 1 } return 0 } function ver_gt(a: pointer, b: pointer) -> bool { return ver_cmp(a, b) > 0 } # is `s` a version tag (optional leading v, then a digit)? function is_ver_tag(s: pointer) -> bool { let v = strip_v(s) if slen(v) == 0 { return false } return v[0] >= '0' and v[0] <= '9' } # ---- tiny line/token scanners for the manifest & lock ------------------------ # split a manifest/lock line into tokens: "quoted strings" (unquoted) or bare # words. A '#' outside a quote starts a comment that ends the line. function tok_line(line: pointer) -> []pointer { var toks = new []pointer let n = slen(line) var i = 0 while i < n { let c = line[i] if c == '#' { i = n } # '#' comment else if c == ' ' or c == '\t' { i += 1 } # whitespace else if c == '"' { # "quoted", with \" and \\ escapes i += 1 var word = "" var start = i while i < n and line[i] != '"' { if line[i] == 92 and i + 1 < n { word = word + sslice(line, start, i) + sslice(line, i + 1, i + 2) i += 2 start = i } else { i += 1 } } push(toks, word + sslice(line, start, i)) if i < n { i += 1 } } else { # bare word let start = i while i < n and line[i] != ' ' and line[i] != '\t' and line[i] != '#' { i += 1 } push(toks, sslice(line, start, i)) } } return toks } # split a captured multi-line string into trimmed non-empty lines function split_lines(s: pointer) -> []pointer { var out = new []pointer let n = slen(s) var i = 0 while i < n { let line = line_at(s, i) i = i + slen(line) + 1 let t = s_trim(line) if slen(t) > 0 { push(out, t) } } return out } # ---- manifest / lock parsing ------------------------------------------------- function parse_manifest(text: pointer) -> Manifest { let m = manifest_new() let n = slen(text) var i = 0 while i < n { let line = line_at(text, i) i = i + slen(line) + 1 let ts = tok_line(line) if len(ts) > 0 { let head = ts[0] if head == "package" { if len(ts) > 1 { m.module = ts[1] } } else if head == "version" { if len(ts) > 1 { m.ver = ts[1] } } else if head == "kind" { if len(ts) > 1 { m.kind = ts[1] } } else if head == "entry" { if len(ts) > 1 { m.entry = ts[1] } } else if head == "provides" { var k = 1; while k < len(ts) { push(m.provides, ts[k]); k += 1 } } else if head == "targets" { var k = 1; while k < len(ts) { push(m.targets, ts[k]); k += 1 } } else if head == "require" { if len(ts) > 2 { let d = new Dep; d.module = ts[1]; d.ver = ts[2]; push(m.deps, d) } } # `pack "assets/kit"` — an asset root that `ludic pack` walks into the # .lpak. Repeatable, and the order is the order they are walked. else if head == "pack" { var k = 1; while k < len(ts) { push(m.packs, ts[k]); k += 1 } } # `app name "Maroon Lake"` — the metadata a macOS bundle is built from. # Held as a flat key/value list rather than a property per key, so a new # Info.plist field is a line in the bundler and nothing here. else if head == "script" { if len(ts) > 2 { push(m.scripts, ts[1]); push(m.scripts, ts[2]) } } else if head == "hook" { if len(ts) > 3 { push(m.hooks, ts[1]); push(m.hooks, ts[2]); push(m.hooks, ts[3]) } } else if head == "app" { if len(ts) > 2 { push(m.app, ts[1]); push(m.app, ts[2]) } } } } return m } # the value of one `app ` line, or "" when the manifest does not set it function manifest_app(m: Manifest, key: pointer) -> pointer { var i = 0 while i + 1 < len(m.app) { if m.app[i] == key { return m.app[i + 1] } i += 2 } return "" } # read a package.lock.ludic body into a list of pinned entries function parse_lock(text: pointer) -> []Manifest { var out = new []Manifest let n = slen(text) var i = 0 while i < n { let line = line_at(text, i) i = i + slen(line) + 1 let ts = tok_line(line) if len(ts) > 1 and ts[0] == "module" { let m = manifest_new() m.module = ts[1] var k = 2 while k < len(ts) { let key = ts[k] if key == "version" and k + 1 < len(ts) { m.ver = ts[k + 1]; k += 2 } else if key == "hash" and k + 1 < len(ts) { m.hash = ts[k + 1]; k += 2 } else if key == "kind" and k + 1 < len(ts) { m.kind = ts[k + 1]; k += 2 } else if key == "provides" and k + 1 < len(ts) { push(m.provides, ts[k + 1]); k += 2 } else { k += 1 } } push(out, m) } } return out } # ---- the content-addressed store & git fetch layer -------------------------- # the global store root: $LUDIC_STORE, else ~/.ludic/store. Trailing slash. function store_root() -> pointer { let s = getenv("LUDIC_STORE") if s != null { return ensure_dir_slash(s) } return ensure_dir_slash(`{getenv_or("HOME", "/tmp")}/.ludic/store`) } function ensure_dir_slash(d: pointer) -> pointer { let n = slen(d) if n == 0 { return d } if d[n - 1] == '/' { return d } return d + "/" } # the git URL for a module: a local $LUDIC_PKG_PROXY tree (used by tests and for # mirrors/offline) keyed by the import path, else https://. function repo_url(module: pointer) -> pointer { let proxy = getenv("LUDIC_PKG_PROXY") if proxy != null { return `{proxy}/{module}` } return `https://{module}` } # ensure a clone cache for `module` under the store; refresh tags if present. # Returns the cache dir, or "" if the clone failed. function ensure_clone(module: pointer) -> pointer { let cache = `{store_root()}cache/{flat(module)}` if file_exists(cache) { shell(`git -C {cache} fetch -q --tags 2>/dev/null`) return cache } shell(`mkdir -p {store_root()}cache`) if not shq(`git clone -q {repo_url(module)} {cache} 2>/dev/null`) { err(`ludic: cannot fetch {module} (git clone {repo_url(module)} failed)\n`) return "" } return cache } # check out version `ver` in the cache — a v-prefixed tag first, then bare. function checkout_ver(cache: pointer, ver: pointer) -> bool { if shq(`git -C {cache} checkout -q v{ver} 2>/dev/null`) { return true } return shq(`git -C {cache} checkout -q {ver} 2>/dev/null`) } # the greatest published version tag of `module` (normalised, no leading v) function latest_version(module: pointer) -> pointer { let cache = ensure_clone(module) if cache == "" { return "" } let lines = split_lines(capture(`git -C {cache} tag`)) var best = "" var k = 0 while k < len(lines) { let tg = lines[k] if is_ver_tag(tg) { let cand = strip_v(tg) if best == "" or ver_gt(cand, best) { best = cand } } k += 1 } return best } # read a package's manifest at `ver`; a package may omit package.ludic, in which # case it is a leaf source package with no declared deps. function fetch_manifest(module: pointer, ver: pointer) -> Manifest { let cache = ensure_clone(module) var m = manifest_new() m.module = module m.ver = ver if cache == "" { return m } if not checkout_ver(cache, ver) { err(`ludic: {module} has no version {ver}\n`) return m } let txt = read_file(`{cache}/package.ludic`) if txt != null { m = parse_manifest(txt) } m.module = module m.ver = ver return m } # the sha256 pick — shasum on macOS, sha256sum elsewhere. Both print " f". function sha_cmd() -> pointer { if shq("command -v shasum >/dev/null 2>&1") { return "shasum -a 256" } return "sha256sum" } # a deterministic content hash of a directory tree (its files' names + bytes), # excluding .git. Independent of file metadata, so it is stable across machines. function dir_hash(dir: pointer) -> pointer { let sc = sha_cmd() return capture_line(`( cd {dir} && find . -type f -not -path './.git/*' | LC_ALL=C sort | while IFS= read -r f; do {sc} "$f"; done | {sc} | cut -d' ' -f1 )`) } # snapshot `module`@`ver` into the content-addressed store; returns its hash. # The store entry is immutable and shared: if the hash is already present the # copy is skipped. The .git directory is never stored. function snapshot(module: pointer, ver: pointer) -> pointer { let cache = ensure_clone(module) if cache == "" { return "" } if not checkout_ver(cache, ver) { return "" } let h = dir_hash(cache) if slen(h) == 0 { return "" } let dest = `{store_root()}{h}` if not file_exists(dest) { shell(`rm -rf {dest}.tmp`) shell(`cp -R {cache} {dest}.tmp`) shell(`rm -rf {dest}.tmp/.git`) shell(`mv {dest}.tmp {dest}`) } return h } # link a project's ludic_modules/ view at the store entry `hash` function link_module(module: pointer, hash: pointer) -> void { let dest = `{store_root()}{hash}` let link = `ludic_modules/{module}` shell(`mkdir -p "$(dirname {link})"`) shell(`rm -rf {link}`) shell(`ln -s {dest} {link}`) } # ---- MVS resolution ---------------------------------------------------------- function find_mod(mods: []pointer, m: pointer) -> int { var i = 0 while i < len(mods) { if mods[i] == m { return i }; i += 1 } return -1 } # resolve a root manifest to the selected build list (one Manifest per module, # each at its chosen version). MVS: a module's version is the greatest of every # required minimum; the returned list is the reachable closure at those versions. function resolve(root: Manifest) -> []Manifest { var mods = new []pointer # module -> selected version (parallel arrays) var vers = new []pointer var fm = new []pointer # the current requirement frontier var fv = new []pointer var di = 0 while di < len(root.deps) { push(fm, root.deps[di].module); push(fv, root.deps[di].ver); di += 1 } while len(fm) > 0 { var nm = new []pointer var nv = new []pointer var i = 0 while i < len(fm) { let m = fm[i] let v = fv[i] let idx = find_mod(mods, m) var take = false if idx < 0 { push(mods, m); push(vers, v); take = true } else if ver_gt(v, vers[idx]) { vers[idx] = v; take = true } if take { let man = fetch_manifest(m, v) # expand the chosen version's requires var k = 0 while k < len(man.deps) { push(nm, man.deps[k].module); push(nv, man.deps[k].ver); k += 1 } } i += 1 } fm = nm fv = nv } # reachability closure: keep only modules reachable from the root, each pinned # to its selected version (a module required only by a version later bumped # away drops out — the minimal MVS build list). var out = new []Manifest var seen = new []pointer var q = new []pointer var d2 = 0 while d2 < len(root.deps) { push(q, root.deps[d2].module); d2 += 1 } var qi = 0 while qi < len(q) { let m = q[qi] qi += 1 if find_mod(seen, m) < 0 { push(seen, m) let idx = find_mod(mods, m) if idx >= 0 { let ver = vers[idx] let man = fetch_manifest(m, ver) push(out, man) var k = 0 while k < len(man.deps) { push(q, man.deps[k].module); k += 1 } } } } return out } # sort a build list by module path (stable enough — insertion sort) function sort_mans(a: []Manifest) -> []Manifest { var i = 1 while i < len(a) { let x = a[i] var j = i - 1 while j >= 0 and (a[j].module > x.module) { a[j + 1] = a[j]; j -= 1 } a[j + 1] = x i += 1 } return a } # ---- namespace-collision policy (v1: hard error) ---------------------------- # Two packages may not both register the same Foo.* namespace. Returns "" when # clean, else a human message naming the conflicting modules. function collision(sels: []Manifest) -> pointer { var ns = new []pointer var owner = new []pointer var i = 0 while i < len(sels) { var p = 0 while p < len(sels[i].provides) { let name = sels[i].provides[p] let at = find_mod(ns, name) if at >= 0 { return `namespace {name} provided by both {owner[at]} and {sels[i].module}` } push(ns, name) push(owner, sels[i].module) p += 1 } i += 1 } return "" } # ---- lockfile writing -------------------------------------------------------- function write_lock(sels: []Manifest) -> bool { var body = "# package.lock.ludic — generated by `ludic get`. Do not edit by hand." + nl() body = body + "lock 1" + nl() let sorted = sort_mans(sels) var i = 0 while i < len(sorted) { let m = sorted[i] var line = `module "{m.module}" version "{m.ver}" hash "{m.hash}" kind "{m.kind}"` var p = 0 while p < len(m.provides) { line += ` provides "{m.provides[p]}"`; p += 1 } body = body + line + nl() i += 1 } return write_file("package.lock.ludic", body) } # ---- the shared get/install pipeline ---------------------------------------- # resolve -> collision check -> snapshot each into the store -> link the project # view -> write the lockfile. Returns 0 on success. function do_install(root: Manifest) -> int { let sels = resolve(root) if len(sels) == 0 { print("no dependencies to resolve") write_lock(sels) return 0 } let clash = collision(sels) if slen(clash) > 0 { err(`ludic: namespace collision — {clash}\n`); return 1 } var i = 0 while i < len(sels) { let m = sels[i] if m.kind == "prebuilt" { let t = target_id() if not has_target(m, t) { err(`ludic: {m.module}@{m.ver} is a prebuilt lib and ships no artifact for target {t}\n`) return 1 } } let h = snapshot(m.module, m.ver) if slen(h) == 0 { err(`ludic: failed to snapshot {m.module}@{m.ver}\n`); return 1 } m.hash = `sha256:{h}` link_module(m.module, h) print(` {m.module} {m.ver} ({m.kind}) sha256:{sslice(h, 0, 12)}…`) i += 1 } if not write_lock(sels) { err("ludic: cannot write package.lock.ludic\n"); return 1 } print(`resolved {string(len(sels))} package(s) — see package.lock.ludic; linked under ludic_modules/`) return 0 } # the build target id a prebuilt lib is matched against function target_id() -> pointer { let t = getenv("LUDIC_TARGET") if t != null { return t } let os = capture_line("uname -s") let arch = capture_line("uname -m") if os == "Darwin" { return `native-{arch}` } return `native-{arch}` } function has_target(m: Manifest, t: pointer) -> bool { var i = 0 while i < len(m.targets) { if m.targets[i] == t { return true }; i += 1 } return false } # ---- reading / editing the project manifest --------------------------------- # the project manifest in the current directory, or a fresh default one function read_root_manifest() -> Manifest { let txt = read_file("package.ludic") if txt == null { return manifest_new() } return parse_manifest(txt) } # split "module@version" into a 2-element list [module, version]; version is "" # when absent function split_spec(spec: pointer) -> []pointer { var out = new []pointer let n = slen(spec) var at = -1 var i = 0 while i < n { if spec[i] == '@' { at = i }; i += 1 } # '@' = 64 if at < 0 { push(out, spec); push(out, "") } else { push(out, sslice(spec, 0, at)); push(out, sslice(spec, at + 1, n)) } return out } # rewrite package.ludic so `module` requires exactly `ver`, creating the file # with sane defaults when it does not exist yet. function set_require(module: pointer, ver: pointer) -> void { var txt = read_file("package.ludic") if txt == null { txt = "# package.ludic — Ludic package manifest" + nl() txt = txt + `package "app"` + nl() txt = txt + `version "0.0.0"` + nl() txt = txt + "kind source" + nl() } var out = "" let n = slen(txt) var i = 0 var replaced = false while i < n { let line = line_at(txt, i) i = i + slen(line) + 1 let ts = tok_line(line) if len(ts) >= 2 and ts[0] == "require" and ts[1] == module { out = out + `require "{module}" "{ver}"` + nl() replaced = true } else { out = out + line + nl() } } if not replaced { out = out + `require "{module}" "{ver}"` + nl() } write_file("package.ludic", out) } # ---- the commands ------------------------------------------------------------ # ludic add [@version] — add/update a dependency then install function cmd_pkg_add() -> int { if arg_count() < 3 { err("usage: ludic add [@version]\n"); return 1 } let spec = split_spec(arg(2)) let module = spec[0] var ver = spec[1] if slen(ver) == 0 { ver = latest_version(module) if slen(ver) == 0 { err(`ludic: {module} has no published version tags (git tag vX.Y.Z to publish)\n`); return 1 } print(`ludic add: {module} -> latest v{ver}`) } set_require(module, ver) return do_install(read_root_manifest()) } # ludic get — resolve + fetch + link every dependency in package.ludic, write lock function cmd_pkg_get() -> int { let txt = read_file("package.ludic") if txt == null { err("ludic: no package.ludic in the current directory (ludic add to start one)\n"); return 1 } print("resolving dependencies (MVS)…") return do_install(parse_manifest(txt)) } # ludic update [module] — bump a dep (or all) to its latest published version, relock function cmd_pkg_update() -> int { let root = read_root_manifest() if len(root.deps) == 0 { err("ludic: package.ludic declares no dependencies\n"); return 1 } let only = argn(2, "") var i = 0 while i < len(root.deps) { let d = root.deps[i] if only == "" or only == d.module { let latest = latest_version(d.module) if slen(latest) > 0 and ver_gt(latest, d.ver) { print(`ludic update: {d.module} {d.ver} -> {latest}`) set_require(d.module, latest) } } i += 1 } return do_install(read_root_manifest()) } # ludic verify — check every locked package against the store by content hash and # confirm the project view links to it function cmd_pkg_verify() -> int { let txt = read_file("package.lock.ludic") if txt == null { err("ludic: no package.lock.ludic (run ludic get first)\n"); return 1 } let locked = parse_lock(txt) if len(locked) == 0 { print("lockfile lists no packages"); return 0 } var bad_count = 0 var i = 0 while i < len(locked) { let m = locked[i] let want = m.hash let raw = strip_prefix(want, "sha256:") let dest = `{store_root()}{raw}` if not file_exists(dest) { print(` MISSING {m.module}@{m.ver} (store entry {want} absent)`) bad_count += 1 } else { let got = dir_hash(dest) if got == raw { let link = `ludic_modules/{m.module}` if file_exists(link) { print(` ok {m.module}@{m.ver}`) } else { print(` UNLINKED {m.module}@{m.ver} (ludic_modules view missing — run ludic get)`); bad_count += 1 } } else { print(` TAMPERED {m.module}@{m.ver} (want sha256:{sslice(raw, 0, 12)}… got sha256:{sslice(got, 0, 12)}…)`) bad_count += 1 } } i += 1 } if bad_count == 0 { print(`verified {string(len(locked))} package(s) against the store`); return 0 } err(`ludic: {string(bad_count)} package(s) failed verification\n`) return 1 } # ludic vendor — copy the resolved packages into ./vendor for hermetic/offline # builds. Build against them with LUDIC_MODULES=vendor. function cmd_pkg_vendor() -> int { let txt = read_file("package.lock.ludic") if txt == null { err("ludic: no package.lock.ludic (run ludic get first)\n"); return 1 } let locked = parse_lock(txt) shell("rm -rf vendor") var i = 0 while i < len(locked) { let m = locked[i] let raw = strip_prefix(m.hash, "sha256:") let dest = `{store_root()}{raw}` if not file_exists(dest) { err(`ludic: {m.module}@{m.ver} not in the store — run ludic get\n`); return 1 } let vdir = `vendor/{m.module}` shell(`mkdir -p "$(dirname {vdir})"`) shell(`cp -R {dest} {vdir}`) print(` vendored {m.module}@{m.ver}`) i += 1 } print(`vendored {string(len(locked))} package(s) into ./vendor — build offline with LUDIC_MODULES=vendor`) return 0 } # ---- prebuilt binary packages (issue #64) ----------------------------------- # ludic build-lib [module.ludic] — compile a package's module to a prebuilt dylib for # the current target, under lib//. The developer runs this in the package # repo, commits the artifact, and `git tag`s — consumers fetch and link it. # The compiler is $LUDICC (default bin/ludicc) so this works both in-repo and, # with the flag set, from an installed toolchain. function cmd_pkg_build_lib() -> int { var src = argn(2, "") # the manifest and the lockfile are not modules: match their names exactly, # rather than as a regex where `.` also matched package.lock.ludic if src == "" { src = capture_line("ls *.ludic 2>/dev/null | grep -vxF -e package.ludic -e package.lock.ludic | head -1") } if src == "" or not file_exists(src) { err("usage: ludic build-lib (run in the package directory)\n"); return 1 } let man = read_root_manifest() var name = "" if man.module != "" { name = capture_line(`basename {man.module}`) } if name == "" { name = capture_line(`basename {src} .ludic`) } let t = target_id() shell(`mkdir -p lib/{t}`) let ludicc = getenv_or("LUDICC", "bin/ludicc") let ll = `{tmp_dir()}/buildlib.ll` let blerr = tmp_path("bl.err") if not shq(`{ludicc} --emit-module {src} -o {ll} 2>{blerr}`) { let why = capture_line(`tail -1 {blerr}`) err(`ludic build-lib: compile failed — {why}\n`); return 1 } let out = `lib/{t}/lib{name}.dylib` # @rpath install name so a consumer resolves it via -rpath to the store dir if not shq(`{cc()} -O2 -Wno-override-module -dynamiclib -undefined dynamic_lookup -Wl,-install_name,@rpath/lib{name}.dylib {ll} -o {out} 2>{blerr}`) { let why2 = capture_line(`tail -1 {blerr}`) err(`ludic build-lib: link failed — {why2}\n`); return 1 } shell(`rm -f {ll}`) print(`built {out} (target {t})`) print(`add to package.ludic: kind prebuilt / targets "{t}" — then commit lib/ and git tag`) return 0 } # ludic link-flags — print the clang link flags a consumer build needs to pull in the # project's prebuilt module dylibs (for the current target). Portable escape from # `ludic build` (which is in-repo only): any build system can splice `$(ludic link-flags)` # into its link step. function cmd_pkg_link_flags() -> int { let f = prebuilt_link_flags() print(s_trim(f)) return 0 } # clang link flags that pull every locked prebuilt package's dylib (for the # current target) into a consumer build: the dylib itself, an rpath to its store # directory, and -export_dynamic so the module resolves the host's ludic_* ABI at # load. Empty when the project has no prebuilt dependencies. function prebuilt_link_flags() -> pointer { let txt = read_file("package.lock.ludic") if txt == null { return "" } let locked = parse_lock(txt) let t = target_id() var flags = "" var any = false var i = 0 while i < len(locked) { let m = locked[i] if m.kind == "prebuilt" { let raw = strip_prefix(m.hash, "sha256:") let dir = `{store_root()}{raw}/lib/{t}` let libs = split_lines(capture(`ls {dir}/*.dylib 2>/dev/null`)) var k = 0 while k < len(libs) { flags = flags + " " + libs[k]; any = true; k += 1 } if len(libs) > 0 { flags += ` -Wl,-rpath,{dir}` } } i += 1 } if any { flags += " -Wl,-export_dynamic" } return flags } # drop a leading `prefix` from `s` if present function strip_prefix(s: pointer, prefix: pointer) -> pointer { let pn = slen(prefix) if pn <= slen(s) and s_starts(s, prefix) { return sslice(s, pn, slen(s)) } return s }