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/lua_runtime.zig | 522 +++++++++++++++++++++++++++++--- src/luarocks_runtime.zig | 763 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 46 +++ src/manifest.zig | 43 +++ src/panto_home.zig | 206 +++++++++++++ src/self_exe.zig | 90 ++++++ src/subcommand.zig | 231 ++++++++++++++ 7 files changed, 1865 insertions(+), 36 deletions(-) create mode 100644 src/luarocks_runtime.zig create mode 100644 src/manifest.zig create mode 100644 src/panto_home.zig create mode 100644 src/self_exe.zig create mode 100644 src/subcommand.zig (limited to 'src') diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index fb00951..71e4ae7 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -58,6 +58,23 @@ pub const LuaRuntime = struct { /// the Lua registry (`luaL_ref` index). handlers: std.StringHashMap(c_int), + /// Registry ref to the wrapper closure that runs a user handler + /// inside a `pcall` and reports the result back to Zig via + /// `panto._record_result`. Allocated once at `create`; reused for + /// every call. `0` until `installScheduler` runs. + wrapper_ref: c_int = 0, + /// Registry ref to `require("luv").run`, the function we call to + /// tick libuv between coroutine resumes. `0` until + /// `installScheduler` runs. + uv_run_ref: c_int = 0, + + /// Pointer to the in-flight batch, valid only for the duration of + /// one `invoke_batch` call. The `panto._record_result` C function + /// writes through this. `null` between batches; not concurrently + /// accessible (libpanto's source-grouped dispatch guarantees one + /// thread per source per turn). + current_batch: ?*BatchState = null, + /// Create a new runtime. The `lua_State` is opened, standard libs /// loaded, and the `panto.register_tool` bridge installed. pub fn create(allocator: Allocator) !*LuaRuntime { @@ -79,6 +96,22 @@ pub const LuaRuntime = struct { return self; } + /// Install the libuv-driven coroutine scheduler: + /// - Register `panto._record_result` (C function with `self` as + /// light-userdata upvalue) so the wrapper closure can hand + /// results back to Zig. + /// - Create the wrapper closure that runs a user handler in + /// `pcall` and reports the result. + /// - Cache `require("luv").run` for fast per-tick access. + /// + /// Must be called after luarocks bootstrap has installed luv, + /// otherwise the `require("luv")` step will fail. + pub fn installScheduler(self: *LuaRuntime) !void { + try installRecordResult(self); + try installWrapperClosure(self); + try cacheUvRun(self); + } + /// Tear down the runtime: free every owned string, unref every /// handler, close the Lua state. pub fn deinit(self: *LuaRuntime) void { @@ -90,6 +123,12 @@ pub const LuaRuntime = struct { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, entry.value_ptr.*); } self.handlers.deinit(); + if (self.wrapper_ref != 0) { + c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.wrapper_ref); + } + if (self.uv_run_ref != 0) { + c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_run_ref); + } c.lua_close(self.L); @@ -320,6 +359,52 @@ fn deinitSrc(_: *anyopaque, _: Allocator) void { // libpanto's source.deinit here is a no-op. } +// =========================================================================== +// Scheduler: libuv-driven cooperative coroutine dispatch +// =========================================================================== +// +// libpanto's `invoke_batch` delivers all of a turn's tool-call requests +// at once, on a single thread. We answer the contract by running each +// call as a Lua coroutine inside our long-lived `lua_State`, then +// driving `uv.run("once")` to wake any of those coroutines that are +// blocked on libuv-aware I/O. This is the entire scheduler — luv's +// libuv binding does the actual event-loop work; we just resume +// coroutines and call `run` between resumes. +// +// Capturing return values requires a wrapper. When a coroutine is +// resumed by a luv callback after yielding, the eventual return value +// of the coroutine flows back to *that callback*, not to us. So we +// install a Lua wrapper closure that does +// +// pcall(handler, input) → panto._record_result(idx, ok, val) +// +// before the handler returns. `_record_result` is a C function that +// stores into a per-runtime `BatchState`, accessed via a light-userdata +// upvalue carrying the runtime pointer. + +/// One coroutine's outcome, recorded by `_record_result` and read by +/// `invokeBatch` once the coroutine has terminated. +const Slot = struct { + /// Set true the moment `_record_result` writes a result for this + /// index. Used to detect coroutines that terminated without + /// calling the wrapper (a bug / API misuse). + recorded: bool = false, + /// `true` if the handler returned cleanly, `false` if it raised + /// via the `pcall` wrapping. + ok: bool = false, + /// Result payload as owned bytes. Allocated from `allocator`. + /// Caller frees. + value: ?[]u8 = null, + /// On `ok = false`, an owned copy of the error message. + err_msg: ?[]u8 = null, +}; + +/// State shared between Zig and the in-flight Lua wrapper closure. +const BatchState = struct { + allocator: Allocator, + slots: []Slot, +}; + fn invokeBatch( ctx: *anyopaque, calls: []const panto.ToolCall, @@ -327,21 +412,205 @@ fn invokeBatch( allocator: Allocator, ) anyerror!void { const self: *LuaRuntime = @ptrCast(@alignCast(ctx)); + return runBatch(self, calls, results, allocator); +} - // Step 2 of LUA_MAKEOVER.md: no batteries yet — each call is run - // as a coroutine, but the scheduler doesn't drive an event loop. - // A handler that yields with no batteries available has nothing - // to wake it; we surface that as `LuaHandlerYielded`. - // - // Once `luv` and the `coro-*` wrappers are installed, this loop - // becomes "drive uv.run() until every coroutine is dead/erroring, - // then collect results". +fn runBatch( + self: *LuaRuntime, + calls: []const panto.ToolCall, + results: []panto.ToolCallResult, + allocator: Allocator, +) !void { + if (self.wrapper_ref == 0 or self.uv_run_ref == 0) { + // Scheduler not installed. We can still run synchronous + // handlers — use the legacy path that drives one coroutine at + // a time without an event loop. + for (calls, 0..) |call, i| { + results[i] = runLegacySync(self, call, allocator); + } + return; + } + + var slots = try allocator.alloc(Slot, calls.len); + defer allocator.free(slots); + for (slots) |*s| s.* = .{}; + + var batch_state: BatchState = .{ .allocator = allocator, .slots = slots }; + self.current_batch = &batch_state; + defer self.current_batch = null; + + // Track each call's coroutine reference in the parent stack (we + // hold them in registry refs so they survive across `uv.run` + // ticks). `0` after a coroutine has been reaped. + var thread_refs = try allocator.alloc(c_int, calls.len); + defer allocator.free(thread_refs); + @memset(thread_refs, 0); + defer for (thread_refs) |r| { + if (r != 0) c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, r); + }; + + var pending: usize = 0; for (calls, 0..) |call, i| { - results[i] = runOneCall(self, call, allocator); + const handler_ref = self.handlers.get(call.tool_name) orelse { + // Synthesize a recorded "err" result; don't even bother + // spawning a coroutine. + slots[i] = .{ + .recorded = true, + .ok = false, + .err_msg = try allocator.dupe(u8, "panto: unknown tool name"), + }; + continue; + }; + const t = try startCoroutine(self, i, handler_ref, call.input, allocator); + thread_refs[i] = t.thread_ref; + if (t.still_pending) pending += 1; + } + + while (pending > 0) { + const active = try driveUvOnce(self); + // Reap any coroutines that terminated during the tick. + var reaped: usize = 0; + for (thread_refs, 0..) |tref, i| { + if (tref == 0) continue; + // Push the thread, check status, pop. + _ = c.lua_rawgeti(self.L, lua_bridge.LUA_REGISTRYINDEX, tref); + const co: *c.lua_State = @ptrCast(c.lua_tothread(self.L, -1).?); + const status = c.lua_status(co); + c.lua_settop(self.L, c.lua_gettop(self.L) - 1); + if (status != c.LUA_YIELD) { + // Terminated (LUA_OK or error). The wrapper should + // have called `_record_result` already; if not, synthesize. + if (!slots[i].recorded) { + slots[i] = .{ + .recorded = true, + .ok = false, + .err_msg = try allocator.dupe( + u8, + "panto: handler terminated without recording a result", + ), + }; + } + c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, tref); + thread_refs[i] = 0; + reaped += 1; + } + } + if (reaped > 0) { + if (reaped > pending) { + // Defensive: keep the counter sane. + pending = 0; + } else { + pending -= reaped; + } + continue; + } + if (active == 0) { + // No libuv handles are pending, but we still have alive + // coroutines. They yielded without arranging to be woken. + // Mark them as failed and break. + for (thread_refs, 0..) |tref, i| { + if (tref == 0) continue; + slots[i] = .{ + .recorded = true, + .ok = false, + .err_msg = try allocator.dupe( + u8, + "panto: handler yielded but no libuv handle is pending; " ++ + "did you forget to await with luv?", + ), + }; + c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, tref); + thread_refs[i] = 0; + } + break; + } + } + + // Translate slots into the libpanto-shaped results. + for (slots, 0..) |slot, i| { + if (!slot.recorded) { + results[i] = .{ .err = RuntimeError.BadHandlerReturn }; + continue; + } + if (slot.ok) { + results[i] = .{ .ok = slot.value orelse try allocator.dupe(u8, "") }; + // Free the err_msg if both ended up set somehow. + if (slot.err_msg) |m| allocator.free(m); + } else { + if (slot.value) |v| allocator.free(v); + // The error message was logged for the user; we still + // surface a typed error to libpanto so it can route the + // failure to the agent's error path. + std.log.warn( + "panto-lua: tool '{s}' failed: {s}", + .{ + calls[i].tool_name, + slot.err_msg orelse "(no message)", + }, + ); + if (slot.err_msg) |m| allocator.free(m); + results[i] = .{ .err = RuntimeError.LuaHandlerCrashed }; + } } } -fn runOneCall( +/// Start one coroutine: create a thread under the runtime's lua_State, +/// push the wrapper closure + (idx, handler, input), `lua_resume` once. +/// +/// If the coroutine returns immediately (sync handler), the wrapper +/// has already recorded its result via `panto._record_result` — +/// `still_pending` will be `false`. +fn startCoroutine( + self: *LuaRuntime, + idx: usize, + handler_ref: c_int, + input: []const u8, + allocator: Allocator, +) !struct { thread_ref: c_int, still_pending: bool } { + const L = self.L; + + const co = c.lua_newthread(L) orelse return RuntimeError.LuaInitFailed; + // luaL_ref pops the topmost value (the thread) and returns a + // registry ref to it. We keep the ref alive for the lifetime of + // the call so GC doesn't collect the thread mid-yield. + const thread_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); + + // Push the wrapper onto the coroutine's stack. + _ = c.lua_rawgeti(co, lua_bridge.LUA_REGISTRYINDEX, @intCast(self.wrapper_ref)); + // Push (idx, handler, input) as the resume args. + c.lua_pushinteger(co, @intCast(idx)); + _ = c.lua_rawgeti(co, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref)); + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + try lua_bridge.pushJsonAsLua(co, arena_state.allocator(), input); + + var nres: c_int = 0; + const status = c.lua_resume(co, L, 3, &nres); + return .{ + .thread_ref = thread_ref, + .still_pending = status == c.LUA_YIELD, + }; +} + +/// Call `uv.run("once")`. Returns the number of active handles luv +/// reports remain pending (0 means the loop is drained). +fn driveUvOnce(self: *LuaRuntime) !c_int { + const L = self.L; + _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(self.uv_run_ref)); + _ = c.lua_pushlstring(L, "once", 4); + if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) { + logTopAsError(L, "panto-lua: uv.run failed"); + c.lua_settop(L, c.lua_gettop(L) - 1); + return error.UvRunFailed; + } + const n = c.lua_tointegerx(L, -1, null); + c.lua_settop(L, c.lua_gettop(L) - 1); + return @intCast(n); +} + +/// Pre-scheduler fallback (used in unit tests and during early +/// startup before `installScheduler` has run). +fn runLegacySync( self: *LuaRuntime, call: panto.ToolCall, allocator: Allocator, @@ -349,62 +618,40 @@ fn runOneCall( const handler_ref = self.handlers.get(call.tool_name) orelse { return .{ .err = RuntimeError.LuaHandlerNotFound }; }; - - const out_bytes = invokeCoroutine(self.L, handler_ref, call.input, allocator) catch |e| { + const out_bytes = invokeCoroutineSync(self.L, handler_ref, call.input, allocator) catch |e| { return .{ .err = e }; }; return .{ .ok = out_bytes }; } -/// Create a fresh coroutine, push the handler + JSON-decoded input as -/// the resume args, then resume. Returns the handler's return value as -/// owned JSON bytes (the slot `ok` in CallResult). -/// -/// Resume outcomes: -/// - LUA_OK: coroutine returned. Read its top value as the result. -/// - LUA_YIELD: coroutine yielded. With no event loop installed, we -/// treat this as an error so the user sees that their handler is -/// trying to do async I/O that isn't yet supported. -/// - other (errors): error message is on the coroutine's stack; -/// copy it to a log line and return LuaHandlerCrashed. -fn invokeCoroutine( +fn invokeCoroutineSync( L: *c.lua_State, handler_ref: c_int, input: []const u8, allocator: Allocator, ) ![]u8 { - // Create the coroutine thread. After this, `co` is the child - // thread; the parent stack also gains a thread value at the top. const co = c.lua_newthread(L) orelse return RuntimeError.LuaInitFailed; - defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop the thread when done + defer c.lua_settop(L, c.lua_gettop(L) - 1); - // Push handler from the registry onto the coroutine's stack. _ = c.lua_rawgeti(co, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref)); if (c.lua_type(co, -1) != lua_bridge.T_FUNCTION) { return RuntimeError.LuaHandlerNotFound; } - // Push the parsed JSON input as the resume arg. var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); try lua_bridge.pushJsonAsLua(co, arena_state.allocator(), input); - // Resume with 1 arg. var nresults: c_int = 0; const status = c.lua_resume(co, L, 1, &nresults); switch (status) { c.LUA_OK => { - // Coroutine returned. We expect exactly one string return - // value (the tool result). If there are zero or extra - // values we still try to read top-of-stack. if (nresults < 1) return RuntimeError.BadHandlerReturn; return try lua_bridge.readHandlerResult(co, -1, allocator); }, c.LUA_YIELD => { - // Nothing to wake this coroutine without an event loop. - // Surface the situation so the user knows what's wrong. - const msg = "lua: tool handler yielded with no event loop installed (step 4+ of LUA_MAKEOVER.md not yet implemented)"; + const msg = "lua: tool handler yielded with no event loop installed; call installScheduler() before dispatching"; if (@import("builtin").is_test) { std.log.warn("{s}", .{msg}); } else { @@ -419,6 +666,124 @@ fn invokeCoroutine( } } +// --------------------------------------------------------------------------- +// Scheduler setup (called once at startup, after luarocks bootstrap) +// --------------------------------------------------------------------------- + +/// Register `panto._record_result(idx, ok, value)` on the `panto` +/// global. The C function carries the runtime pointer as an upvalue, +/// reaches the in-flight `BatchState` through `current_batch`, and +/// stores into the matching slot. +fn installRecordResult(self: *LuaRuntime) !void { + const L = self.L; + _ = c.lua_getglobal(L, "panto"); + if (c.lua_type(L, -1) != lua_bridge.T_TABLE) { + c.lua_settop(L, c.lua_gettop(L) - 1); + return RuntimeError.LuaInitFailed; + } + c.lua_pushlightuserdata(L, @ptrCast(self)); + c.lua_pushcclosure(L, recordResultC, 1); + c.lua_setfield(L, -2, "_record_result"); + c.lua_settop(L, c.lua_gettop(L) - 1); // pop `panto` +} + +fn recordResultC(L: ?*c.lua_State) callconv(.c) c_int { + const Lst = L.?; + const self_ptr = c.lua_touserdata(Lst, c.lua_upvalueindex(1)); + if (self_ptr == null) return 0; + const self: *LuaRuntime = @ptrCast(@alignCast(self_ptr.?)); + const batch = self.current_batch orelse return 0; + + const idx_i64 = c.lua_tointegerx(Lst, 1, null); + const ok = c.lua_toboolean(Lst, 2) != 0; + const idx: usize = @intCast(idx_i64); + if (idx >= batch.slots.len) return 0; + + if (ok) { + // Result value at index 3. The handler's return type is + // free-form; we serialize via the existing `readHandlerResult` + // helper which already knows how to JSON-encode any Lua value. + const value = lua_bridge.readHandlerResult(Lst, 3, batch.allocator) catch |e| { + // Allocation failure mid-callback is unrecoverable from + // Lua's POV; record a synthetic error and bail. + const msg = std.fmt.allocPrint( + batch.allocator, + "panto: failed to serialize handler result: {s}", + .{@errorName(e)}, + ) catch null; + batch.slots[idx] = .{ .recorded = true, .ok = false, .err_msg = msg }; + return 0; + }; + batch.slots[idx] = .{ .recorded = true, .ok = true, .value = value }; + } else { + // Error message at index 3. May be any Lua value; coerce to + // string via `tostring`-equivalent semantics. + var len: usize = 0; + const ptr = c.luaL_tolstring(Lst, 3, &len); + const owned = if (ptr != null) + batch.allocator.dupe(u8, ptr[0..len]) catch null + else + null; + c.lua_settop(Lst, c.lua_gettop(Lst) - 1); // pop tolstring's pushed string + batch.slots[idx] = .{ .recorded = true, .ok = false, .err_msg = owned }; + } + return 0; +} + +/// Create the per-call wrapper closure: +/// +/// local function wrapper(idx, handler, input) +/// local ok, val = pcall(handler, input) +/// panto._record_result(idx, ok, val) +/// end +/// +/// Stored in the Lua registry under `self.wrapper_ref`. +fn installWrapperClosure(self: *LuaRuntime) !void { + const L = self.L; + const snippet = + \\return function(idx, handler, input) + \\ local ok, val = pcall(handler, input) + \\ panto._record_result(idx, ok, val) + \\end + ; + if (c.luaL_loadstring(L, snippet) != 0) { + logTopAsError(L, "panto-lua: wrapper closure failed to compile"); + c.lua_settop(L, c.lua_gettop(L) - 1); + return RuntimeError.LuaInitFailed; + } + if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { + logTopAsError(L, "panto-lua: wrapper closure failed to evaluate"); + c.lua_settop(L, c.lua_gettop(L) - 1); + return RuntimeError.LuaInitFailed; + } + // Top of stack: the wrapper function. luaL_ref pops it. + self.wrapper_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); +} + +/// Cache `require("luv").run` in the registry so the scheduler can +/// invoke it cheaply per tick. +fn cacheUvRun(self: *LuaRuntime) !void { + const L = self.L; + const snippet = + \\return require("luv").run + ; + if (c.luaL_loadstring(L, snippet) != 0) { + logTopAsError(L, "panto-lua: failed to compile luv lookup"); + c.lua_settop(L, c.lua_gettop(L) - 1); + return RuntimeError.LuaInitFailed; + } + if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { + logTopAsError(L, "panto-lua: require('luv') failed (was the bootstrap successful?)"); + c.lua_settop(L, c.lua_gettop(L) - 1); + return RuntimeError.LuaInitFailed; + } + if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { + c.lua_settop(L, c.lua_gettop(L) - 1); + return RuntimeError.LuaInitFailed; + } + self.uv_run_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); +} + // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- @@ -715,6 +1080,91 @@ test "yielding handler with no event loop surfaces LuaHandlerYielded" { try testing.expectEqual(@as(anyerror, RuntimeError.LuaHandlerYielded), results[0].err); } +// Integration test: requires a `$PANTO_HOME` with luv already +// installed. Skipped if luv isn't on disk — unit tests stay offline. +test "scheduler: yielding handler is resumed by libuv" { + const home_z = std.c.getenv("PANTO_HOME") orelse return error.SkipZigTest; + const panto_home_env = std.mem.sliceTo(home_z, 0); + // Check for `/rocks/lua-/lib/lua/5.4/luv.so`. + const manifest = @import("manifest.zig"); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const so_path = try std.fmt.bufPrint( + &path_buf, + "{s}/rocks/lua-{s}/lib/lua/{s}/luv.so", + .{ panto_home_env, manifest.lua_version, manifest.lua_short_version }, + ); + std.Io.Dir.cwd().access(testing.io, so_path, .{}) catch return error.SkipZigTest; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const source = + \\local uv = require("luv") + \\panto.register_tool { + \\ name = "timer_say", description = "sleep then return", + \\ schema = { type = "object" }, + \\ handler = function(input) + \\ local co = coroutine.running() + \\ local timer = uv.new_timer() + \\ uv.timer_start(timer, 5, 0, function() + \\ uv.timer_stop(timer) + \\ uv.close(timer) + \\ coroutine.resume(co) + \\ end) + \\ coroutine.yield() + \\ return "awake" + \\ end, + \\} + ; + const path = try writeTempScript(tmp.dir, "timer.lua", source); + defer testing.allocator.free(path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + + // Bootstrap luarocks (so `require("luv")` works), then install + // the scheduler. We use the real environment so the test picks + // up the same PANTO_HOME the developer's machine has. + var env: std.process.Environ.Map = .init(testing.allocator); + defer env.deinit(); + try env.put("PANTO_HOME", panto_home_env); + + const luarocks_runtime = @import("luarocks_runtime.zig"); + // The bootstrap needs a panto executable path for the wrapper + // script; tests don't actually invoke it, so a placeholder is + // fine (the wrapper is only consulted when luarocks itself + // shells out, which the test never triggers). + const luarocks_rt = try luarocks_runtime.bootstrap( + testing.allocator, + testing.io, + &env, + rt.L, + "/usr/bin/true", + ); + defer luarocks_rt.deinit(); + + try rt.installScheduler(); + try rt.loadExtension(path, null); + + var src = rt.toolSource(); + const calls = [_]panto.ToolCall{ + .{ .tool_name = "timer_say", .input = "{}" }, + .{ .tool_name = "timer_say", .input = "{}" }, + }; + var results: [2]panto.ToolCallResult = .{ + .{ .err = error.SourceDroppedCall }, + .{ .err = error.SourceDroppedCall }, + }; + try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); + defer for (results) |r| switch (r) { + .ok => |b| testing.allocator.free(b), + .err => {}, + }; + + try testing.expectEqualStrings("awake", results[0].ok); + try testing.expectEqualStrings("awake", results[1].ok); +} + test "loadExtension: duplicate tool name from a second extension errors" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); 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); +} diff --git a/src/main.zig b/src/main.zig index 15b2dd2..6654c95 100644 --- a/src/main.zig +++ b/src/main.zig @@ -4,6 +4,10 @@ const ping_tool = @import("ping_tool.zig"); const lua_bridge = @import("lua_bridge.zig"); const lua_runtime = @import("lua_runtime.zig"); const extension_loader = @import("extension_loader.zig"); +const panto_home = @import("panto_home.zig"); +const luarocks_runtime = @import("luarocks_runtime.zig"); +const self_exe = @import("self_exe.zig"); +const subcommand = @import("subcommand.zig"); // Shorthand alias for the Lua C API. The bridge module owns the actual // `@cImport`; we re-use it here so the smoke check uses identical types. @@ -23,6 +27,10 @@ test { _ = lua_bridge; _ = lua_runtime; _ = extension_loader; + _ = panto_home; + _ = luarocks_runtime; + _ = self_exe; + _ = subcommand; } const Receiver = panto.provider.Receiver; @@ -231,6 +239,26 @@ pub fn main(init: std.process.Init) !void { // wired up yet, this just confirms the static lib comes through. luaSmokeCheck(); + // Resolve the absolute path of the running panto binary. Needed + // both by `panto lua` (we re-exec ourselves through a wrapper + // luarocks invokes) and by the agent's bootstrap. + const panto_path = try self_exe.selfExePathAlloc(alloc); + defer alloc.free(panto_path); + + // Subcommand dispatch: `panto lua` and `panto bootstrap` short + // out of the agent loop, but still run the same luarocks bootstrap + // pipeline so first-run setup happens consistently. + switch (try subcommand.dispatch( + alloc, + io, + init.environ_map, + init.minimal.args, + panto_path, + )) { + .done => return, + .agent => {}, + } + const config = try loadConfig(init.environ_map); var stdout_buffer: [4096]u8 = undefined; @@ -260,6 +288,24 @@ pub fn main(init: std.process.Init) !void { var rt = try lua_runtime.LuaRuntime.create(alloc); defer rt.deinit(); + // Bootstrap luarocks against the Lua runtime's lua_State — same + // pipeline as `panto lua` and `panto bootstrap`. After this, + // `require("luarocks.*")` works and any pinned batteries from the + // manifest are installed under $PANTO_HOME. + const luarocks_rt = try luarocks_runtime.bootstrap( + alloc, + io, + init.environ_map, + rt.L, + panto_path, + ); + defer luarocks_rt.deinit(); + + // luv is installed (or already present) at this point; wire the + // libuv-driven coroutine scheduler before any extensions get a + // chance to register tools that might want to yield. + try rt.installScheduler(); + // Discover Lua extensions from $XDG_CONFIG_HOME/panto/extensions (or // $HOME/.config/panto/extensions) and ./.panto/extensions. Project // entries shadow user entries with the same name; tool-name collisions diff --git a/src/manifest.zig b/src/manifest.zig new file mode 100644 index 0000000..b12b5c4 --- /dev/null +++ b/src/manifest.zig @@ -0,0 +1,43 @@ +//! Pinned versions of every Lua component shipped with panto: the +//! upstream Lua language version, the embedded luarocks version, and +//! the rocks ("batteries") panto requires for its own runtime to work. +//! +//! Bumping any of these is a deliberate edit + commit + version bump of +//! panto itself. Each panto release pins one consistent set; bootstrap +//! reconciles the installed rocks tree against this manifest on every +//! startup, installing missing rocks and removing stale ones (per Q5 of +//! LUA_MAKEOVER.md). +//! +//! The Lua and luarocks versions ride in via the `versions` build option +//! so build.zig stays the single source of truth. The batteries are +//! pinned here directly because they don't otherwise need to be visible +//! to the build. + +const versions = @import("versions"); + +pub const lua_version: []const u8 = versions.lua_version; +pub const lua_short_version: []const u8 = versions.lua_short_version; +pub const luarocks_version: []const u8 = versions.luarocks_version; + +pub const Battery = struct { + /// Rock name as published on luarocks.org. + name: []const u8, + /// Pinned rockspec version, e.g. `"1.52.1-1"`. luarocks uses these + /// MAJOR.MINOR.PATCH-REV strings throughout — we pass them straight + /// through to `luarocks install`. + version: []const u8, +}; + +/// Rocks installed automatically at bootstrap. Compiled into the panto +/// binary; users do not configure this list. +/// +/// We deliberately ship the smallest viable set: luv is the only +/// runtime dependency — it provides libuv's event loop, which panto's +/// coroutine scheduler drives, and gives extension authors a single +/// rich async I/O surface. Anything else (coro-* helpers, lua-cjson, +/// lpeg, etc.) is the user's responsibility, installable via +/// `panto lua -e 'arg[0]="luarocks"; require("luarocks.cmd").run_command(...)'` +/// or, eventually, a higher-level `panto rocks install` subcommand. +pub const batteries: []const Battery = &.{ + .{ .name = "luv", .version = "1.52.1-0" }, +}; diff --git a/src/panto_home.zig b/src/panto_home.zig new file mode 100644 index 0000000..b7c1799 --- /dev/null +++ b/src/panto_home.zig @@ -0,0 +1,206 @@ +//! Filesystem layout resolution for `$PANTO_HOME` and the per-Lua +//! version rocks tree. +//! +//! $PANTO_HOME = $XDG_DATA_HOME/panto +//! (or $HOME/.local/share/panto if XDG_DATA_HOME unset) +//! +//! $PANTO_HOME/ +//! rocks/ +//! lua-5.4.7/ ← current tree +//! include/ ← Lua headers staged by bootstrap +//! share/lua/5.4/ ← installed pure-Lua rocks +//! lib/lua/5.4/ ← installed C rocks (.so/.dylib) +//! lib/luarocks/rocks-5.4/ ← luarocks's own rock metadata +//! etc/luarocks/ ← luarocks config-5.4.lua +//! +//! See Q3 of LUA_MAKEOVER.md for the rationale behind the versioned +//! subdirectory. We never write to the tree of a different Lua version +//! — a panto upgrade that bumps Lua creates a fresh tree alongside the +//! old one, leaving the old one in place for rollback. + +const std = @import("std"); +const manifest = @import("manifest.zig"); + +const Allocator = std.mem.Allocator; +const Io = std.Io; + +/// Resolved set of filesystem paths panto uses for its rocks tree. All +/// fields are owned by the same allocator passed to `resolve`. +pub const Layout = struct { + allocator: Allocator, + /// `$PANTO_HOME` itself. + home: []u8, + /// `$PANTO_HOME/rocks/lua-/` — the versioned tree. + tree: []u8, + /// `/include/` — where Lua headers are staged. + include_dir: []u8, + /// `/share/lua//` — pure-Lua rocks. + share_lua_dir: []u8, + /// `/lib/lua//` — C rocks (.so/.dylib). + lib_lua_dir: []u8, + /// `/lib/luarocks/rocks-/` — luarocks's rock metadata + /// per the standard tree layout. + rocks_metadata_dir: []u8, + /// `/etc/luarocks/` — luarocks's own config dir, where the + /// per-version `config-.lua` lives. We set `SYSCONFDIR` to + /// this when constructing `luarocks.core.hardcoded`. + sysconfdir: []u8, + /// `/etc/luarocks/config-.lua` — the path of the config + /// file we materialize at bootstrap. + config_file: []u8, + + pub fn deinit(self: Layout) void { + const a = self.allocator; + a.free(self.home); + a.free(self.tree); + a.free(self.include_dir); + a.free(self.share_lua_dir); + a.free(self.lib_lua_dir); + a.free(self.rocks_metadata_dir); + a.free(self.sysconfdir); + a.free(self.config_file); + } +}; + +/// Resolve every path the runtime cares about. Environment-driven: +/// - `PANTO_HOME` (explicit override) +/// - `XDG_DATA_HOME` (XDG default) +/// - `HOME` (fallback) +/// +/// Returns `error.NoHomeDirectory` if none of those are available and +/// no `PANTO_HOME` was set. +pub fn resolve( + allocator: Allocator, + environ_map: *const std.process.Environ.Map, +) !Layout { + const home = try resolveHome(allocator, environ_map); + errdefer allocator.free(home); + + // `/rocks/lua-` + const tree_subdir = try std.fmt.allocPrint( + allocator, + "lua-{s}", + .{manifest.lua_version}, + ); + defer allocator.free(tree_subdir); + const tree = try std.fs.path.join(allocator, &.{ home, "rocks", tree_subdir }); + errdefer allocator.free(tree); + + const short = manifest.lua_short_version; + + const include_dir = try std.fs.path.join(allocator, &.{ tree, "include" }); + errdefer allocator.free(include_dir); + const share_lua_dir = try std.fs.path.join(allocator, &.{ tree, "share", "lua", short }); + errdefer allocator.free(share_lua_dir); + const lib_lua_dir = try std.fs.path.join(allocator, &.{ tree, "lib", "lua", short }); + errdefer allocator.free(lib_lua_dir); + const rocks_subdir = try std.fmt.allocPrint(allocator, "rocks-{s}", .{short}); + defer allocator.free(rocks_subdir); + const rocks_metadata_dir = try std.fs.path.join( + allocator, + &.{ tree, "lib", "luarocks", rocks_subdir }, + ); + errdefer allocator.free(rocks_metadata_dir); + const sysconfdir = try std.fs.path.join(allocator, &.{ tree, "etc", "luarocks" }); + errdefer allocator.free(sysconfdir); + const config_basename = try std.fmt.allocPrint(allocator, "config-{s}.lua", .{short}); + defer allocator.free(config_basename); + const config_file = try std.fs.path.join(allocator, &.{ sysconfdir, config_basename }); + errdefer allocator.free(config_file); + + return .{ + .allocator = allocator, + .home = home, + .tree = tree, + .include_dir = include_dir, + .share_lua_dir = share_lua_dir, + .lib_lua_dir = lib_lua_dir, + .rocks_metadata_dir = rocks_metadata_dir, + .sysconfdir = sysconfdir, + .config_file = config_file, + }; +} + +/// Resolve `$PANTO_HOME` honoring overrides in the documented order. +/// Returns owned bytes. +fn resolveHome( + allocator: Allocator, + environ_map: *const std.process.Environ.Map, +) ![]u8 { + if (environ_map.get("PANTO_HOME")) |explicit| { + return allocator.dupe(u8, explicit); + } + if (environ_map.get("XDG_DATA_HOME")) |xdg| { + return std.fs.path.join(allocator, &.{ xdg, "panto" }); + } + if (environ_map.get("HOME")) |hh| { + return std.fs.path.join(allocator, &.{ hh, ".local", "share", "panto" }); + } + return error.NoHomeDirectory; +} + +/// Create every directory referenced by `layout`, recursively. Idempotent +/// — existing directories are left alone. +pub fn ensureDirsExist(layout: Layout, io: Io) !void { + try makePathRecursive(io, layout.home); + try makePathRecursive(io, layout.tree); + try makePathRecursive(io, layout.include_dir); + try makePathRecursive(io, layout.share_lua_dir); + try makePathRecursive(io, layout.lib_lua_dir); + try makePathRecursive(io, layout.rocks_metadata_dir); + try makePathRecursive(io, layout.sysconfdir); +} + +fn makePathRecursive(io: Io, path: []const u8) !void { + Io.Dir.cwd().createDirPath(io, path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "resolve: PANTO_HOME explicit override wins" { + var env: std.process.Environ.Map = .init(testing.allocator); + defer env.deinit(); + try env.put("PANTO_HOME", "/tmp/some/home"); + try env.put("XDG_DATA_HOME", "/should/be/ignored"); + + var layout = try resolve(testing.allocator, &env); + defer layout.deinit(); + + try testing.expectEqualStrings("/tmp/some/home", layout.home); + try testing.expect(std.mem.indexOf(u8, layout.tree, "/tmp/some/home/rocks/lua-") != null); + try testing.expect(std.mem.endsWith(u8, layout.share_lua_dir, "/share/lua/" ++ manifest.lua_short_version)); +} + +test "resolve: XDG_DATA_HOME is honored before HOME" { + var env: std.process.Environ.Map = .init(testing.allocator); + defer env.deinit(); + try env.put("XDG_DATA_HOME", "/x/data"); + try env.put("HOME", "/h"); + + var layout = try resolve(testing.allocator, &env); + defer layout.deinit(); + try testing.expectEqualStrings("/x/data/panto", layout.home); +} + +test "resolve: HOME fallback used if neither override is present" { + var env: std.process.Environ.Map = .init(testing.allocator); + defer env.deinit(); + try env.put("HOME", "/home/user"); + + var layout = try resolve(testing.allocator, &env); + defer layout.deinit(); + try testing.expectEqualStrings("/home/user/.local/share/panto", layout.home); +} + +test "resolve: error when no environment hint at all" { + var env: std.process.Environ.Map = .init(testing.allocator); + defer env.deinit(); + try testing.expectError(error.NoHomeDirectory, resolve(testing.allocator, &env)); +} diff --git a/src/self_exe.zig b/src/self_exe.zig new file mode 100644 index 0000000..6eda92d --- /dev/null +++ b/src/self_exe.zig @@ -0,0 +1,90 @@ +//! Resolve the absolute path of the currently running executable. +//! +//! Used to find the panto binary so we can launch ourselves as `panto +//! lua` from inside the bootstrap (the embedded luarocks shells out to +//! its configured `LUA` interpreter, which we point at a wrapper script +//! that exec's panto's `lua` subcommand). +//! +//! Zig's standard library doesn't expose a portable `selfExePath` in +//! the current API, so this module wraps the per-OS syscall. +//! +//! Supported: +//! - macOS / iOS / tvOS / watchOS: `_NSGetExecutablePath` + realpath +//! - Linux: `readlink("/proc/self/exe")` +//! - FreeBSD / NetBSD / DragonFly: `readlink("/proc/curproc/file")` +//! +//! Other targets currently return `error.SelfExePathUnavailable`. + +const std = @import("std"); +const builtin = @import("builtin"); + +const Allocator = std.mem.Allocator; + +pub const ResolveError = error{ + SelfExePathUnavailable, + SelfExePathTooLong, + SelfExePathReadFailed, + OutOfMemory, +}; + +/// Return the absolute, symlink-resolved path of the current process's +/// executable. Caller owns the returned slice. +pub fn selfExePathAlloc(allocator: Allocator) ResolveError![]u8 { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try selfExePath(&buf); + return allocator.dupe(u8, path); +} + +/// Fill `buf` with the absolute path. Returns the slice that was used. +pub fn selfExePath(buf: []u8) ResolveError![]u8 { + return switch (builtin.os.tag) { + .macos, .ios, .tvos, .watchos => try macosSelfExePath(buf), + .linux => try readLinkSelfExe(buf, "/proc/self/exe"), + .freebsd, .netbsd, .dragonfly => try readLinkSelfExe(buf, "/proc/curproc/file"), + else => return ResolveError.SelfExePathUnavailable, + }; +} + +fn readLinkSelfExe(buf: []u8, link_path: []const u8) ResolveError![]u8 { + const link_z = std.posix.toPosixPath(link_path) catch return ResolveError.SelfExePathTooLong; + const n = posixReadlink(&link_z, buf) catch return ResolveError.SelfExePathReadFailed; + return buf[0..n]; +} + +/// Thin wrapper around the POSIX `readlink` syscall using libc directly, +/// since std.posix's surface here has been in flux. libc is linked into +/// panto already (Lua needs it), so this stays cheap. +extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, bufsize: usize) isize; + +fn posixReadlink(path: [*:0]const u8, buf: []u8) !usize { + const n = readlink(path, buf.ptr, buf.len); + if (n < 0) return error.SelfExePathReadFailed; + return @intCast(n); +} + +extern "c" fn _NSGetExecutablePath(buf: [*]u8, bufsize: *u32) c_int; + +fn macosSelfExePath(buf: []u8) ResolveError![]u8 { + var stage: [std.fs.max_path_bytes]u8 = undefined; + var size: u32 = @intCast(stage.len); + if (_NSGetExecutablePath(&stage, &size) != 0) return ResolveError.SelfExePathTooLong; + // _NSGetExecutablePath populates `size` on overflow; on success + // we need to find the NUL ourselves. + const len = std.mem.indexOfScalar(u8, &stage, 0) orelse return ResolveError.SelfExePathTooLong; + + // The returned path may include `..` and symlinks; canonicalize so + // downstream consumers don't have to. realpath(3) on POSIX, both + // libc-resident on macOS and Linux. + const path_z = std.posix.toPosixPath(stage[0..len]) catch return ResolveError.SelfExePathTooLong; + + // libc realpath: NULL second arg => malloc; we want stack buffer. + var real_buf: [std.fs.max_path_bytes]u8 = undefined; + if (libc_realpath(&path_z, &real_buf) == null) return ResolveError.SelfExePathReadFailed; + const real_len = std.mem.indexOfScalar(u8, &real_buf, 0) orelse return ResolveError.SelfExePathTooLong; + if (real_len > buf.len) return ResolveError.SelfExePathTooLong; + @memcpy(buf[0..real_len], real_buf[0..real_len]); + return buf[0..real_len]; +} + +extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*]u8; +const libc_realpath = realpath; diff --git a/src/subcommand.zig b/src/subcommand.zig new file mode 100644 index 0000000..97eb977 --- /dev/null +++ b/src/subcommand.zig @@ -0,0 +1,231 @@ +//! Subcommand dispatch for the `panto` CLI. +//! +//! Routes argv[1] to one of: +//! - `lua` — drop into the embedded standalone Lua interpreter +//! (panto's `lua.c` build), with luarocks's runtime +//! bootstrap completed first so `require("luarocks.*")` +//! and the configured rocks tree work the same as in +//! the agent process. +//! - `bootstrap` — run the luarocks runtime bootstrap pipeline only; +//! exit before entering any agent loop. Lets users +//! do first-run setup on a fresh machine without +//! starting a chat session. +//! - anything else (or absent) — fall through to the agent REPL. +//! +//! Both `lua` and `bootstrap` end up calling `luarocks_runtime.bootstrap` +//! before doing their thing. The agent path does the same; the only +//! difference is whether the agent loop runs afterward. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; + +const lua_bridge = @import("lua_bridge.zig"); +const luarocks_runtime = @import("luarocks_runtime.zig"); +const self_exe = @import("self_exe.zig"); + +const c = lua_bridge.c; + +pub const Action = enum { + /// Continue with the default agent REPL. + agent, + /// Bootstrap is already done; the dispatcher consumed the subcommand. + /// `main` should exit immediately. + done, +}; + +/// Inspect `argv[1]`, run the appropriate subcommand, and return what +/// the caller should do next. On `.agent`, the dispatcher leaves argv +/// untouched and `main` continues as before. On `.done`, the caller +/// must return promptly (the subcommand has already produced output). +/// +/// `panto_executable_path` is the absolute path of the running panto +/// binary, used both to wire up the embedded luarocks `LUA` variable +/// and to `exec` ourselves where needed. +pub fn dispatch( + allocator: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + args: std.process.Args, + panto_executable_path: []const u8, +) !Action { + var it = args.iterate(); + defer it.deinit(); + _ = it.next(); // argv[0] + + const sub = it.next() orelse return .agent; + + if (std.mem.eql(u8, sub, "lua")) { + try runLuaSubcommand(allocator, io, environ_map, args, panto_executable_path); + return .done; + } + if (std.mem.eql(u8, sub, "bootstrap")) { + var force = false; + while (it.next()) |flag| { + if (std.mem.eql(u8, flag, "--force")) { + force = true; + } else { + std.log.err("panto bootstrap: unknown flag '{s}'", .{flag}); + return error.UnknownFlag; + } + } + try runBootstrapSubcommand(allocator, io, environ_map, panto_executable_path, .{ .force = force }); + return .done; + } + return .agent; +} + +pub const BootstrapOptions = struct { + /// Wipe the per-Lua-version tree before reinstalling everything. + /// Surfaced as `panto bootstrap --force`. Equivalent to deleting + /// `$PANTO_HOME/rocks/lua-X.Y.Z/` by hand and then running + /// `panto bootstrap`. + force: bool = false, +}; + +// --------------------------------------------------------------------------- +// `panto lua` +// --------------------------------------------------------------------------- + +extern "c" fn panto_lua_pmain(L: *c.lua_State, argc: c_int, argv: [*]?[*:0]u8) c_int; + +/// Drop into the embedded Lua standalone interpreter, with the +/// luarocks runtime bootstrap completed so `require("luarocks.*")` +/// and rocks installed under `$PANTO_HOME` are visible. +/// +/// argv is rewritten so the interpreter sees `lua [...args]` rather +/// than `panto lua [...args]` — matching upstream behavior. The first +/// argument visible to `pmain` is the program name; this matters for +/// `arg[0]` and error reporting. +fn runLuaSubcommand( + allocator: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + args: std.process.Args, + panto_executable_path: []const u8, +) !void { + // Build a fresh lua_State that we own, configure it like luarocks + // expects, then hand it to `pmain`. + const L = c.luaL_newstate() orelse return error.LuaInitFailed; + defer c.lua_close(L); + + // Run bootstrap against this state. This installs the embedded + // searcher, configures package.path/cpath, and stages on-disk + // resources. We deliberately do NOT call `luaL_openlibs` here — + // `pmain` does that itself, and we want exactly the upstream + // ordering for everything that runs inside the REPL. + // + // The searcher install only requires `package.searchers` to be + // present; the stock libs ship it. We open libs once here just + // to satisfy that, then pmain's own `luaL_openlibs` is idempotent. + c.luaL_openlibs(L); + const rt = try luarocks_runtime.bootstrap( + allocator, + io, + environ_map, + L, + panto_executable_path, + ); + defer rt.deinit(); + + // Re-create the argv the standalone interpreter expects. argv[0] + // is the program name; argv[1..] are the user's args. + var raw_args = args.iterate(); + defer raw_args.deinit(); + _ = raw_args.next(); // panto + _ = raw_args.next(); // lua + + var argv_list: std.array_list.Managed([:0]u8) = .init(allocator); + defer { + for (argv_list.items) |s| allocator.free(s); + argv_list.deinit(); + } + + // Program name first. + try argv_list.append(try allocator.dupeZ(u8, "lua")); + while (raw_args.next()) |a| { + try argv_list.append(try allocator.dupeZ(u8, a)); + } + + // Build a `[*]?[*:0]u8` argv pointer array. lua.c expects a + // NULL-terminated array (it uses `argv[i]` indexed access through + // argc; the trailing NULL is conventional for C `main`). + var argv_c: std.array_list.Managed(?[*:0]u8) = .init(allocator); + defer argv_c.deinit(); + for (argv_list.items) |s| { + try argv_c.append(s.ptr); + } + try argv_c.append(null); + + const exit_code = panto_lua_pmain(L, @intCast(argv_list.items.len), argv_c.items.ptr); + if (exit_code != 0) std.process.exit(@intCast(exit_code)); +} + +// --------------------------------------------------------------------------- +// `panto bootstrap` +// --------------------------------------------------------------------------- + +/// Run the luarocks bootstrap and exit. Useful for first-run setup on +/// a clean machine (downloads + compiles batteries, stages headers, +/// materializes config) and for CI/scripted installs. +/// +/// Idempotent: subsequent invocations no-op fast, unless `force` was +/// passed — then the entire per-Lua-version tree is wiped before the +/// regular bootstrap pipeline runs. +fn runBootstrapSubcommand( + allocator: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + panto_executable_path: []const u8, + opts: BootstrapOptions, +) !void { + if (opts.force) { + try luarocks_runtime.wipeTree(allocator, io, environ_map); + } + + const L = c.luaL_newstate() orelse return error.LuaInitFailed; + defer c.lua_close(L); + c.luaL_openlibs(L); + + const rt = try luarocks_runtime.bootstrap( + allocator, + io, + environ_map, + L, + panto_executable_path, + ); + defer rt.deinit(); + + // Pleasant single-line confirmation. The interesting bits (rock + // installs etc.) print their own progress. + std.log.info( + "panto bootstrap: tree ready at {s}", + .{rt.layout.tree}, + ); +} + +// --------------------------------------------------------------------------- +// `panto lua` argv plumbing — sketched against the older Args API for +// reference (kept here so the design notes survive the implementation). +// --------------------------------------------------------------------------- +// +// Because we own the `lua_State` end-to-end, the subcommand can also +// expose extra panto-specific globals to user code (e.g. surface the +// resolved $PANTO_HOME) without disturbing upstream `lua.c` behavior. +// Step out of scope for the current makeover; add when needed. + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +// Note: `dispatch` reads from the process's real argv, which isn't +// controllable from a unit test. The behavior is exercised by +// integration runs of the panto binary. We test the smaller pieces. +// +// Suppress dead-code warnings for `self_exe` (it's used by main, not +// by tests in this module). +test { + _ = self_exe; +} -- cgit v1.3