summaryrefslogtreecommitdiff
path: root/src/luarocks_runtime.zig
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-27 08:04:04 -0600
committerT <t@tjp.lol>2026-05-27 11:46:52 -0600
commit576891dc2ec4d917932a4c396471d4bbbad90c8e (patch)
tree0662d629cf15a2e9cbb51353f6d3abe6d2c6edb5 /src/luarocks_runtime.zig
parentb72a405534d6be019573ee0a806014e2713fe55e (diff)
Finish the hard parts of the lua makeover
- bundle luarocks source in the panto binary - bootstrap process (intended for first `panto` run): - make ~/.local/share/panto/... - write out luarocks sources into it - run luarocks to install luv - new `panto bootstrap` command just runs the bootstrap - `panto bootstrap --force` removes everything and re-bootstraps - new `panto lua` command just runs panto's embedded lua
Diffstat (limited to 'src/luarocks_runtime.zig')
-rw-r--r--src/luarocks_runtime.zig763
1 files changed, 763 insertions, 0 deletions
diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig
new file mode 100644
index 0000000..b67a8ad
--- /dev/null
+++ b/src/luarocks_runtime.zig
@@ -0,0 +1,763 @@
+//! Embedded-luarocks runtime bootstrap.
+//!
+//! Responsibilities at startup (per LUA_MAKEOVER.md steps 3-5 and Q1-Q5):
+//!
+//! 1. Resolve `$PANTO_HOME` and the per-Lua-version rocks tree
+//! (`panto_home.zig`). Create the directory layout if missing.
+//! 2. Stage Lua headers under `<tree>/include/` (from `@embedFile`)
+//! so luarocks can compile C rocks against them. Idempotent: a
+//! file is only rewritten if its checksum differs.
+//! 3. Materialize `<tree>/etc/luarocks/config-<short>.lua` with the
+//! pinned interpreter, rock trees, and toolchain variables.
+//! 4. Install a `package.searcher` that serves `require("luarocks.*")`
+//! and `require("compat53.*")` from embedded sources \u2014 the
+//! luarocks Lua libraries never touch disk.
+//! 5. Inject `luarocks.core.hardcoded` into `package.loaded` with the
+//! runtime-resolved `SYSCONFDIR`. Without this, `luarocks.core.cfg`
+//! can't find its config file.
+//! 6. Configure `package.path` / `package.cpath` so user rocks
+//! installed in `<tree>/share/lua/<short>` and
+//! `<tree>/lib/lua/<short>` are visible to `require`.
+//! 7. Reconcile the batteries manifest: for each pinned rock, check
+//! `<tree>/lib/luarocks/rocks-<short>/<name>/<version>/` and invoke
+//! `luarocks install` for anything missing. (Slow path; only the
+//! first run after a fresh `$PANTO_HOME` actually downloads.)
+//!
+//! Step 7 needs a usable `lua` executable on PATH from luarocks's point
+//! of view \u2014 it shells out for rockspec build scripts. We satisfy
+//! this via the `panto lua` subcommand, addressed by writing
+//! `<panto-binary> lua` (with the absolute path of the running `panto`
+//! binary) into the luarocks config as `variables.LUA`.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+
+const manifest = @import("manifest.zig");
+const panto_home = @import("panto_home.zig");
+const embedded_luarocks = @import("embedded_luarocks");
+const embedded_lua_headers = @import("embedded_lua_headers");
+const lua_bridge = @import("lua_bridge.zig");
+
+const c = lua_bridge.c;
+
+/// Owned state for the runtime side of luarocks. Holds onto the
+/// resolved layout, the `lua_State` we attached to, and a hash map
+/// used by the embedded-module searcher.
+pub const LuarocksRuntime = struct {
+ allocator: Allocator,
+ layout: panto_home.Layout,
+ L: *c.lua_State,
+ /// Module-name to source-bytes lookup for the embedded-source
+ /// `package.searcher` callback. Keys borrow from
+ /// `embedded_luarocks.files`; values likewise.
+ modules: std.StringHashMapUnmanaged([]const u8),
+
+ pub fn deinit(self: *LuarocksRuntime) void {
+ self.modules.deinit(self.allocator);
+ self.layout.deinit();
+ self.allocator.destroy(self);
+ }
+};
+
+/// Errors surfaced by the bootstrap pipeline. The `Luarocks*` variants
+/// indicate that we were able to invoke luarocks but it exited non-zero
+/// (or otherwise failed); the message is in stderr at that point.
+pub const BootstrapError = error{
+ HeadersMissing,
+ LuarocksInjectionFailed,
+ LuarocksInstallFailed,
+ LuarocksSearcherInstallFailed,
+ PathConfigFailed,
+ PantoExecutablePathUnknown,
+} || Allocator.Error;
+
+/// Full startup-time bootstrap. Walks the entire setup pipeline against
+/// the given `lua_State`, leaving it ready for callers to `require`
+/// luarocks modules and for any user code to find rocks under the
+/// configured tree.
+///
+/// `panto_executable_path` is the absolute path of the currently
+/// running `panto` binary. Used to construct the `<panto> lua`
+/// invocation that luarocks will use as its interpreter when running
+/// rockspec build scripts.
+///
+/// Wipe the current Lua-version's rocks tree before bootstrapping.
+/// Used by `panto bootstrap --force` to recover from a corrupted or
+/// outdated installation. Only the `<home>/rocks/lua-<version>/`
+/// subtree is removed; sibling trees from other Lua versions stay
+/// untouched, matching the rationale in Q3 (each Lua version owns
+/// its own tree for clean rollback).
+pub fn wipeTree(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+) !void {
+ var layout = try panto_home.resolve(allocator, environ_map);
+ defer layout.deinit();
+
+ // `deleteTree` is the cwd-relative entrypoint; we want absolute.
+ // Open the parent and delete by basename to avoid path-traversal
+ // surprises if `layout.tree` ever contains a symlink.
+ const parent = std.fs.path.dirname(layout.tree) orelse {
+ std.log.warn("panto bootstrap --force: tree has no parent? '{s}'", .{layout.tree});
+ return;
+ };
+ const base = std.fs.path.basename(layout.tree);
+
+ var parent_dir = Io.Dir.cwd().openDir(io, parent, .{}) catch |err| switch (err) {
+ error.FileNotFound => return, // already gone; nothing to wipe
+ else => return err,
+ };
+ defer parent_dir.close(io);
+
+ // `deleteTree` does not raise `FileNotFound` — a missing leaf is
+ // treated as success, matching our "force wipe is idempotent"
+ // intent here.
+ try parent_dir.deleteTree(io, base);
+
+ std.log.info("panto bootstrap --force: removed {s}", .{layout.tree});
+}
+
+/// Every `panto` invocation runs through this one pipeline — `panto
+/// lua`, `panto bootstrap`, and the default agent loop are all just
+/// surfaces on top of "start the Lua runtime, install missing
+/// batteries, then do your thing."
+pub fn bootstrap(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ L: *c.lua_State,
+ panto_executable_path: []const u8,
+) !*LuarocksRuntime {
+ const layout = try panto_home.resolve(allocator, environ_map);
+ errdefer layout.deinit();
+
+ try panto_home.ensureDirsExist(layout, io);
+ try stageLuaHeaders(allocator, io, layout);
+ const lua_wrapper_path = try writeLuaWrapper(allocator, io, layout, panto_executable_path);
+ defer allocator.free(lua_wrapper_path);
+ try writeLuarocksConfig(allocator, io, layout, lua_wrapper_path);
+
+ // The embedded-source `package.searcher` keeps a pointer to the
+ // module map; we need the map's storage to live at a stable
+ // address for the process lifetime. Build the runtime first so
+ // `self.modules` is the address the singleton can capture, then
+ // install the searcher pointing at `self.modules` specifically.
+ const self = try allocator.create(LuarocksRuntime);
+ errdefer allocator.destroy(self);
+ self.* = .{
+ .allocator = allocator,
+ .layout = layout,
+ .L = L,
+ .modules = .empty,
+ };
+ try self.modules.ensureTotalCapacity(allocator, @intCast(embedded_luarocks.files.len + 2));
+ for (embedded_luarocks.files) |entry| {
+ self.modules.putAssumeCapacityNoClobber(entry.name, entry.contents);
+ }
+
+ try installEmbeddedSearcher(L, &self.modules);
+ try injectHardcoded(L, layout);
+ try configurePackagePaths(allocator, L, layout);
+
+ // Reconcile the batteries manifest. This may take a while on a
+ // fresh install; subsequent runs no-op. Runs in-process — we
+ // already have luarocks loaded in `L` via the embedded searcher,
+ // so there's no need (and no reason) to spawn a child Lua.
+ //
+ // `PANTO_BOOTSTRAP_NO_RECONCILE` is a re-entry guard. When the
+ // reconcile loop is already running in an ancestor process (a
+ // luarocks build step shells out to `<tree>/bin/lua`, which is
+ // our `panto lua` wrapper), we don't want the child to start its
+ // own reconcile. The variable is set by `reconcileBatteries`
+ // before any potentially-recursive work and cleared afterward.
+ if (environ_map.get("PANTO_BOOTSTRAP_NO_RECONCILE") == null) {
+ try reconcileBatteries(allocator, io, self);
+ }
+
+ return self;
+}
+
+// ---------------------------------------------------------------------------
+// Step 2: stage Lua headers
+// ---------------------------------------------------------------------------
+
+/// Write every embedded Lua header to `<tree>/include/`. Skips any file
+/// whose existing on-disk contents already match \u2014 keeps file mtimes
+/// stable across reruns and lets luarocks's mtime-based caching work.
+fn stageLuaHeaders(
+ allocator: Allocator,
+ io: Io,
+ layout: panto_home.Layout,
+) !void {
+ var dir = try Io.Dir.cwd().openDir(io, layout.include_dir, .{});
+ defer dir.close(io);
+
+ for (embedded_lua_headers.files) |entry| {
+ try writeIfDifferent(allocator, io, dir, entry.name, entry.contents);
+ }
+}
+
+/// Write `contents` into `dir/name` only if the existing file differs.
+/// Creates the file if absent.
+fn writeIfDifferent(
+ allocator: Allocator,
+ io: Io,
+ dir: Io.Dir,
+ name: []const u8,
+ contents: []const u8,
+) !void {
+ if (dir.readFileAlloc(io, name, allocator, .limited(1 << 22))) |existing| {
+ defer allocator.free(existing);
+ if (std.mem.eql(u8, existing, contents)) return;
+ } else |err| switch (err) {
+ error.FileNotFound => {},
+ else => return err,
+ }
+ try dir.writeFile(io, .{ .sub_path = name, .data = contents });
+}
+
+// ---------------------------------------------------------------------------
+// Step 3: write luarocks config
+// ---------------------------------------------------------------------------
+
+/// Write a tiny shell wrapper at `<tree>/bin/lua` that `exec`s
+/// `<panto> lua "$@"`. luarocks invokes its configured `LUA` variable
+/// as a real executable when running rockspec build scripts; we can't
+/// give it `"panto lua"` directly because it splits argv naively, and
+/// we can't give it an absolute path to the panto binary because then
+/// it'd run panto's agent loop instead of `lua.c`'s REPL.
+///
+/// The wrapper makes panto's lua subcommand visible to luarocks as if
+/// it were a standalone `lua` binary. Returns the wrapper path; caller
+/// frees.
+fn writeLuaWrapper(
+ allocator: Allocator,
+ io: Io,
+ layout: panto_home.Layout,
+ panto_executable_path: []const u8,
+) ![]u8 {
+ const bin_dir = try std.fs.path.join(allocator, &.{ layout.tree, "bin" });
+ defer allocator.free(bin_dir);
+ Io.Dir.cwd().createDirPath(io, bin_dir) catch |err| switch (err) {
+ error.PathAlreadyExists => {},
+ else => return err,
+ };
+
+ const wrapper_path = try std.fs.path.join(allocator, &.{ bin_dir, "lua" });
+ errdefer allocator.free(wrapper_path);
+
+ var aw: std.Io.Writer.Allocating = .init(allocator);
+ defer aw.deinit();
+ const w = &aw.writer;
+ try w.writeAll("#!/bin/sh\n");
+ try w.writeAll("# Auto-generated by panto. Bridges luarocks's external\n");
+ try w.writeAll("# `lua` invocations to `panto lua`.\n");
+ try w.writeAll("exec ");
+ try writeShellQuoted(w, panto_executable_path);
+ try w.writeAll(" lua \"$@\"\n");
+
+ var bin = try Io.Dir.cwd().openDir(io, bin_dir, .{});
+ defer bin.close(io);
+ try bin.writeFile(io, .{
+ .sub_path = "lua",
+ .data = aw.written(),
+ .flags = .{ .permissions = .executable_file },
+ });
+ return wrapper_path;
+}
+
+fn writeShellQuoted(w: anytype, s: []const u8) !void {
+ try w.writeByte('\'');
+ for (s) |ch| {
+ if (ch == '\'') {
+ try w.writeAll("'\\''");
+ } else {
+ try w.writeByte(ch);
+ }
+ }
+ try w.writeByte('\'');
+}
+
+/// Materialize a luarocks config-<short>.lua under `layout.sysconfdir`.
+/// This is the file luarocks reads from `SYSCONFDIR`; we point every
+/// path-typed variable at the in-tree directories so installs land in
+/// `$PANTO_HOME/rocks/lua-X.Y.Z/`.
+///
+/// Format reference: docs/config_file_format.md in the luarocks repo.
+fn writeLuarocksConfig(
+ allocator: Allocator,
+ io: Io,
+ layout: panto_home.Layout,
+ lua_wrapper_path: []const u8,
+) !void {
+ var aw: std.Io.Writer.Allocating = .init(allocator);
+ defer aw.deinit();
+ const w = &aw.writer;
+
+ try w.writeAll("-- Auto-generated by panto. Do not edit.\n");
+ try w.writeAll("-- This file is rewritten on every panto startup.\n\n");
+
+ try w.writeAll("rocks_trees = {\n { name = \"user\", root = ");
+ try writeLuaString(w, layout.tree);
+ try w.writeAll(" },\n}\n\n");
+
+ try w.writeAll("lua_interpreter = \"panto\"\n\n");
+
+ const bin_dir = std.fs.path.dirname(lua_wrapper_path) orelse layout.tree;
+ try w.writeAll("variables = {\n");
+ try writeLuaKV(w, "LUA", lua_wrapper_path);
+ try writeLuaKV(w, "LUA_BINDIR", bin_dir);
+ try writeLuaKV(w, "LUA_INCDIR", layout.include_dir);
+ try writeLuaKV(w, "LUA_LIBDIR", layout.lib_lua_dir);
+ try writeLuaKV(w, "LUA_DIR", layout.tree);
+ try writeLuaKV(w, "CURL", "curl");
+ try w.writeAll("}\n\n");
+
+ // We do not run luarocks's external `lua` invocation as a
+ // separate Lua install; the panto binary itself answers to
+ // `panto lua` and behaves like upstream lua.c.
+ try w.writeAll("lua_version = ");
+ try writeLuaString(w, manifest.lua_short_version);
+ try w.writeAll("\n\n");
+
+ // Default deps mode: only consider the panto tree, ignore any
+ // system-wide luarocks installations. Keeps reproducibility.
+ try w.writeAll("deps_mode = \"one\"\n");
+
+ // Write atomically: stage as `.tmp`, rename into place. luarocks
+ // reads the config eagerly on every invocation; an interrupted
+ // write would corrupt the next startup.
+ var sysconf = try Io.Dir.cwd().openDir(io, layout.sysconfdir, .{});
+ defer sysconf.close(io);
+
+ const tmp_name = "config-staging.lua";
+ try sysconf.writeFile(io, .{ .sub_path = tmp_name, .data = aw.written() });
+ try sysconf.rename(tmp_name, sysconf, std.fs.path.basename(layout.config_file), io);
+}
+
+// ---------------------------------------------------------------------------
+// Step 4: embedded-source `package.searcher`
+// ---------------------------------------------------------------------------
+
+fn buildEmbeddedModuleMap(allocator: Allocator) !std.StringHashMapUnmanaged([]const u8) {
+ var modules: std.StringHashMapUnmanaged([]const u8) = .empty;
+ try modules.ensureTotalCapacity(allocator, @intCast(embedded_luarocks.files.len + 2));
+ for (embedded_luarocks.files) |entry| {
+ modules.putAssumeCapacityNoClobber(entry.name, entry.contents);
+ }
+ return modules;
+}
+
+/// Process-singleton handle the C searcher uses to find module sources.
+/// Populated at bootstrap, read on every `require`.
+var module_map_singleton: ?*const std.StringHashMapUnmanaged([]const u8) = null;
+
+/// Install our embedded-source searcher as `package.searchers[2]`,
+/// after the preload searcher (slot 1) but before path-based searchers.
+/// Slot 2 is conventional for embedded code (luarocks's own all_in_one
+/// does the same).
+fn installEmbeddedSearcher(
+ L: *c.lua_State,
+ modules: *std.StringHashMapUnmanaged([]const u8),
+) !void {
+ module_map_singleton = modules;
+
+ // Push package.searchers onto the stack.
+ _ = c.lua_getglobal(L, "package");
+ if (c.lua_type(L, -1) != lua_bridge.T_TABLE) {
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ return BootstrapError.LuarocksSearcherInstallFailed;
+ }
+ _ = c.lua_getfield(L, -1, "searchers");
+ if (c.lua_type(L, -1) != lua_bridge.T_TABLE) {
+ c.lua_settop(L, c.lua_gettop(L) - 2);
+ return BootstrapError.LuarocksSearcherInstallFailed;
+ }
+
+ // We want to insert our searcher at slot 2 \u2014 push every existing
+ // entry from slot 2 onward up by one, then assign our function.
+ const n = c.lua_rawlen(L, -1);
+ // Shift slots upward: searchers[i+1] = searchers[i] for i in [n..2].
+ var i: c.lua_Integer = @intCast(n);
+ while (i >= 2) : (i -= 1) {
+ _ = c.lua_rawgeti(L, -1, i);
+ c.lua_rawseti(L, -2, i + 1);
+ }
+ c.lua_pushcfunction(L, embeddedSearcher);
+ c.lua_rawseti(L, -2, 2);
+
+ c.lua_settop(L, c.lua_gettop(L) - 2); // pop searchers + package
+}
+
+/// Lua C function: given a module name, look it up in the embedded map
+/// and return a loader closure if found; otherwise push an explanatory
+/// string and return 1 \u2014 standard `package.searchers` contract.
+fn embeddedSearcher(L: ?*c.lua_State) callconv(.c) c_int {
+ const Lst = L.?;
+ var name_len: usize = 0;
+ const name_ptr = c.lua_tolstring(Lst, 1, &name_len);
+ if (name_ptr == null) {
+ _ = c.lua_pushlstring(Lst, "\n\t[panto: invalid require argument]", 35);
+ return 1;
+ }
+ const name = name_ptr[0..name_len];
+
+ const map = module_map_singleton orelse {
+ _ = c.lua_pushlstring(Lst, "\n\t[panto: embedded modules not initialized]", 43);
+ return 1;
+ };
+
+ // Resolve `name` first, then `name.init` if missing — matches
+ // stock Lua path-searcher behavior where `require("foo")` checks
+ // both `foo.lua` and `foo/init.lua`.
+ var contents_opt = map.get(name);
+ var resolved_name: []const u8 = name;
+ var init_buf: [256]u8 = undefined;
+ if (contents_opt == null) {
+ const init_name = std.fmt.bufPrint(&init_buf, "{s}.init", .{name}) catch name;
+ if (map.get(init_name)) |contents2| {
+ contents_opt = contents2;
+ resolved_name = init_name;
+ }
+ }
+ if (contents_opt) |contents| {
+ // Push loader: a Lua function that, when called, executes the
+ // chunk and returns its result. `luaL_loadbufferx` with mode
+ // "t" forbids binary chunks so we can't be tricked into loading
+ // pre-compiled bytecode through this path.
+ const chunkname_buf = blk: {
+ // "=panto/<name>" \u2014 the `=` prefix makes Lua print the name
+ // verbatim in stack traces instead of prefixing with `[string]`.
+ var sbuf: [256]u8 = undefined;
+ const s = std.fmt.bufPrintZ(&sbuf, "=panto/{s}", .{resolved_name}) catch "=panto/embedded";
+ break :blk s;
+ };
+ const status = c.luaL_loadbufferx(
+ Lst,
+ contents.ptr,
+ contents.len,
+ chunkname_buf.ptr,
+ "t",
+ );
+ if (status != c.LUA_OK) {
+ // Error message left on stack by luaL_loadbufferx \u2014 return
+ // it as the searcher's diagnostic.
+ return 1;
+ }
+ return 1;
+ }
+
+ _ = c.lua_pushlstring(Lst, "\n\t[panto: no embedded match]", 27);
+ return 1;
+}
+
+// ---------------------------------------------------------------------------
+// Step 5: inject luarocks.core.hardcoded
+// ---------------------------------------------------------------------------
+
+/// Construct a `luarocks.core.hardcoded` table with the runtime-resolved
+/// SYSCONFDIR (and FORCE_CONFIG = true, so luarocks doesn't go hunting
+/// for system configs) and stash it in `package.loaded` so that the
+/// later `require("luarocks.core.hardcoded")` returns our table instead
+/// of going to disk. This is exactly the trick the upstream GNUmakefile
+/// uses.
+fn injectHardcoded(L: *c.lua_State, layout: panto_home.Layout) !void {
+ const snippet =
+ \\local sysconfdir = ...
+ \\package.loaded["luarocks.core.hardcoded"] = {
+ \\ SYSCONFDIR = sysconfdir,
+ \\ FORCE_CONFIG = true,
+ \\}
+ ;
+ if (c.luaL_loadstring(L, snippet) != 0) {
+ return BootstrapError.LuarocksInjectionFailed;
+ }
+ _ = c.lua_pushlstring(L, layout.sysconfdir.ptr, layout.sysconfdir.len);
+ if (c.lua_pcallk(L, 1, 0, 0, 0, null) != 0) {
+ return BootstrapError.LuarocksInjectionFailed;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Step 6: package.path / package.cpath
+// ---------------------------------------------------------------------------
+
+/// Add `<tree>/share/lua/<short>/?.lua` and `<tree>/share/lua/<short>/?/init.lua`
+/// to `package.path`, and the matching `.so`/`.dylib` patterns to
+/// `package.cpath`, so rocks installed under our tree are visible to
+/// `require`. The original path entries follow ours \u2014 we shadow the
+/// system in case anything matches by accident.
+fn configurePackagePaths(
+ allocator: Allocator,
+ L: *c.lua_State,
+ layout: panto_home.Layout,
+) !void {
+ const so_suffix = comptime soSuffix();
+
+ const path_segment = try std.fmt.allocPrint(
+ allocator,
+ "{0s}/?.lua;{0s}/?/init.lua",
+ .{layout.share_lua_dir},
+ );
+ defer allocator.free(path_segment);
+
+ const cpath_segment = try std.fmt.allocPrint(
+ allocator,
+ "{0s}/?{1s}",
+ .{ layout.lib_lua_dir, so_suffix },
+ );
+ defer allocator.free(cpath_segment);
+
+ // Snippet: prepend the segments to package.path/cpath.
+ const snippet =
+ \\local path_seg, cpath_seg = ...
+ \\package.path = path_seg .. ";" .. package.path
+ \\package.cpath = cpath_seg .. ";" .. package.cpath
+ ;
+ if (c.luaL_loadstring(L, snippet) != 0) {
+ return BootstrapError.PathConfigFailed;
+ }
+ _ = c.lua_pushlstring(L, path_segment.ptr, path_segment.len);
+ _ = c.lua_pushlstring(L, cpath_segment.ptr, cpath_segment.len);
+ if (c.lua_pcallk(L, 2, 0, 0, 0, null) != 0) {
+ return BootstrapError.PathConfigFailed;
+ }
+}
+
+fn soSuffix() []const u8 {
+ return switch (@import("builtin").os.tag) {
+ .macos, .ios, .watchos, .tvos => ".so",
+ .windows => ".dll",
+ else => ".so",
+ };
+}
+
+/// Write `value` as a quoted Lua string. Uses the `"..."` short-string
+/// form, escaping backslashes, double-quotes, newlines, and any other
+/// control characters via `\NNN` decimal escapes. Suitable for any
+/// path on any platform.
+fn writeLuaString(w: anytype, value: []const u8) !void {
+ try w.writeByte('"');
+ for (value) |ch| {
+ switch (ch) {
+ '\\' => try w.writeAll("\\\\"),
+ '"' => try w.writeAll("\\\""),
+ '\n' => try w.writeAll("\\n"),
+ '\r' => try w.writeAll("\\r"),
+ '\t' => try w.writeAll("\\t"),
+ 0...8, 11, 12, 14...31, 127 => try w.print("\\{d}", .{ch}),
+ else => try w.writeByte(ch),
+ }
+ }
+ try w.writeByte('"');
+}
+
+fn writeLuaKV(w: anytype, key: []const u8, value: []const u8) !void {
+ try w.print(" {s} = ", .{key});
+ try writeLuaString(w, value);
+ try w.writeAll(",\n");
+}
+
+// ---------------------------------------------------------------------------
+// Step 7: reconcile manifest
+// ---------------------------------------------------------------------------
+
+/// For each pinned battery, check whether the version-stamped install
+/// metadata exists under `<tree>/lib/luarocks/rocks-<short>/<name>/<version>/`
+/// (luarocks's standard metadata location). If absent, call
+/// `luarocks.cmd.run("install", name, version)` directly against our
+/// `lua_State` — no subprocess.
+///
+/// Subsequent panto runs hit the fast path: the metadata directory
+/// exists, no luarocks invocation happens.
+fn reconcileBatteries(
+ allocator: Allocator,
+ io: Io,
+ rt: *LuarocksRuntime,
+) !void {
+ var any_missing = false;
+ for (manifest.batteries) |battery| {
+ if (try batteryInstalled(allocator, io, rt.layout, battery)) continue;
+ any_missing = true;
+ break;
+ }
+ if (!any_missing) return;
+
+ // Tell any child processes that go through panto (luarocks's
+ // CMake/make subprocesses ultimately invoke `<tree>/bin/lua`,
+ // which is our `panto lua` wrapper) to skip their own reconcile
+ // step — we're already in the middle of it.
+ //
+ // Set process-wide via the C `setenv` so spawn calls inherit it.
+ // Cleared after the loop so subsequent panto invocations of this
+ // process see a clean environment.
+ _ = c_setenv("PANTO_BOOTSTRAP_NO_RECONCILE", "1", 1);
+ defer _ = c_unsetenv("PANTO_BOOTSTRAP_NO_RECONCILE");
+
+ for (manifest.batteries) |battery| {
+ if (try batteryInstalled(allocator, io, rt.layout, battery)) continue;
+ std.log.info(
+ "panto: installing battery {s} {s} via luarocks (first run; this may take a minute)",
+ .{ battery.name, battery.version },
+ );
+ try installBattery(allocator, rt.L, battery);
+ }
+}
+
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn unsetenv(name: [*:0]const u8) c_int;
+const c_setenv = setenv;
+const c_unsetenv = unsetenv;
+
+/// Check whether `<tree>/lib/luarocks/rocks-<short>/<name>/<version>/`
+/// exists. luarocks creates this directory atomically as part of an
+/// install, so its presence is a reliable signal that the rock landed.
+fn batteryInstalled(
+ allocator: Allocator,
+ io: Io,
+ layout: panto_home.Layout,
+ battery: manifest.Battery,
+) !bool {
+ const subpath = try std.fs.path.join(
+ allocator,
+ &.{ layout.rocks_metadata_dir, battery.name, battery.version },
+ );
+ defer allocator.free(subpath);
+
+ var dir = Io.Dir.cwd().openDir(io, subpath, .{}) catch |err| switch (err) {
+ error.FileNotFound, error.NotDir => return false,
+ else => return err,
+ };
+ dir.close(io);
+ return true;
+}
+
+/// Spawn `<panto> lua -e 'require("luarocks.cmd").run("install", name, version)'`
+/// inheriting stdout/stderr so the user sees compilation output.
+///
+/// We set `PANTO_BOOTSTRAP_NO_RECONCILE=1` in the child so that the
+/// nested `panto lua` doesn't itself try to reconcile (which would
+/// recurse forever — installing luv would re-trigger installing luv).
+/// The child still runs the full filesystem-side bootstrap (searcher,
+/// hardcoded, package paths) — only the manifest reconcile is skipped.
+/// Run luarocks's `install` command directly against our `lua_State`.
+///
+/// We mirror what `src/bin/luarocks` does — it's a thin Lua driver
+/// that builds a command table and calls `cmd.run_command(...)`. We
+/// embed the driver's source (`embedded_luarocks.luarocks_main`) and
+/// load it as a chunk, passing `{"install", name, version}` as the
+/// vararg the chunk reads via `...`.
+///
+/// luarocks's `run_command` prints diagnostics to stdout/stderr as
+/// it goes, so the user sees compilation progress in real time.
+/// On failure it calls `os.exit(1)`, which our `pcall` wrapper
+/// intercepts before it can terminate the process.
+fn installBattery(
+ allocator: Allocator,
+ L: *c.lua_State,
+ battery: manifest.Battery,
+) !void {
+ // We wrap the embedded driver in a function that overrides
+ // `os.exit` for the duration of the call. luarocks's `die()`
+ // ultimately calls `os.exit(1)`, which would terminate panto
+ // entirely; we want to catch the failure as a regular error.
+ const driver = embedded_luarocks.luarocks_main;
+
+ const wrapper =
+ \\local orig_exit = os.exit
+ \\local args = { ... }
+ \\local fail_code = nil
+ \\os.exit = function(code) fail_code = code or 0; error({ panto_exit = code or 0 }) end
+ \\arg = arg or {}
+ \\arg[0] = arg[0] or "luarocks"
+ \\local driver = args[1]
+ \\local chunk, err = load(driver, "=panto/luarocks-driver", "t")
+ \\if not chunk then error("failed to load luarocks driver: " .. tostring(err)) end
+ \\local ok, err = pcall(chunk, table.unpack(args, 2))
+ \\os.exit = orig_exit
+ \\if not ok then
+ \\ if type(err) == "table" and err.panto_exit ~= nil then
+ \\ if err.panto_exit ~= 0 then
+ \\ error("luarocks exited with code " .. tostring(err.panto_exit))
+ \\ end
+ \\ else
+ \\ error(err)
+ \\ end
+ \\end
+ ;
+
+ if (c.luaL_loadstring(L, wrapper) != 0) {
+ return BootstrapError.LuarocksInstallFailed;
+ }
+
+ // Strip the shebang line if present. `luaL_loadfile` does this
+ // for files, but we're going through `load(...)` from Lua which
+ // doesn't — it'll choke on `#!/usr/bin/env lua` as a syntax error.
+ var driver_slice: []const u8 = driver;
+ if (driver_slice.len >= 2 and driver_slice[0] == '#' and driver_slice[1] == '!') {
+ if (std.mem.indexOfScalar(u8, driver_slice, '\n')) |nl| {
+ driver_slice = driver_slice[nl + 1 ..];
+ }
+ }
+
+ // Pass the driver source as the first arg, then the luarocks
+ // CLI args. We use `lua_pushlstring` to push everything as
+ // Lua strings (no NUL terminator needed, since lua_pushlstring
+ // takes an explicit length).
+ _ = c.lua_pushlstring(L, driver_slice.ptr, driver_slice.len);
+ _ = c.lua_pushlstring(L, "install", 7);
+ _ = c.lua_pushlstring(L, battery.name.ptr, battery.name.len);
+ _ = c.lua_pushlstring(L, battery.version.ptr, battery.version.len);
+
+ _ = allocator; // currently unused; reserved for future scratch needs
+
+ if (c.lua_pcallk(L, 4, 0, 0, 0, null) != 0) {
+ var len: usize = 0;
+ const msg = c.lua_tolstring(L, -1, &len);
+ if (msg != null) {
+ std.log.err(
+ "panto: luarocks install of {s} {s} failed: {s}",
+ .{ battery.name, battery.version, msg[0..len] },
+ );
+ }
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ return BootstrapError.LuarocksInstallFailed;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const testing = std.testing;
+
+test "embedded module map contains luarocks.core.cfg, luarocks.cmd, compat53.init" {
+ var modules = try buildEmbeddedModuleMap(testing.allocator);
+ defer modules.deinit(testing.allocator);
+
+ try testing.expect(modules.get("luarocks.core.cfg") != null);
+ try testing.expect(modules.get("luarocks.cmd") != null);
+ // `luarocks.cmd.init` is a real submodule (the `init` subcommand);
+ // luarocks.cmd is the module itself — distinct entries.
+ try testing.expect(modules.get("luarocks.cmd.init") != null);
+ try testing.expect(modules.get("compat53.init") != null);
+ try testing.expect(modules.get("compat53.module") != null);
+ // Not there:
+ try testing.expect(modules.get("luarocks.nonexistent") == null);
+}
+
+test "embedded searcher fallback resolves bare name via name.init" {
+ // Mirrors stock Lua searcher semantics: require("compat53") should
+ // succeed even though our map only stores `compat53.init`. We test
+ // the lookup logic directly here — the C-level installation is
+ // exercised by integration runs of the panto binary.
+ var modules = try buildEmbeddedModuleMap(testing.allocator);
+ defer modules.deinit(testing.allocator);
+
+ const bare = modules.get("compat53");
+ try testing.expect(bare == null);
+ const via_init = modules.get("compat53.init");
+ try testing.expect(via_init != null);
+}