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