From 576891dc2ec4d917932a4c396471d4bbbad90c8e Mon Sep 17 00:00:00 2001 From: T Date: Wed, 27 May 2026 08:04:04 -0600 Subject: 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 --- src/luarocks_runtime.zig | 763 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 763 insertions(+) create mode 100644 src/luarocks_runtime.zig (limited to 'src/luarocks_runtime.zig') 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 `/include/` (from `@embedFile`) +//! so luarocks can compile C rocks against them. Idempotent: a +//! file is only rewritten if its checksum differs. +//! 3. Materialize `/etc/luarocks/config-.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 `/share/lua/` and +//! `/lib/lua/` are visible to `require`. +//! 7. Reconcile the batteries manifest: for each pinned rock, check +//! `/lib/luarocks/rocks-///` 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 +//! ` 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 ` 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 `/rocks/lua-/` +/// 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 `/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 `/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 `/bin/lua` that `exec`s +/// ` 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-.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/" \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 `/share/lua//?.lua` and `/share/lua//?/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 `/lib/luarocks/rocks-///` +/// (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 `/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 `/lib/luarocks/rocks-///` +/// 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 ` 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); +} -- cgit v1.3