From 442888c6c306cfff0708c3d4bbbeda685cec2e75 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 2 Jun 2026 19:41:56 -0600 Subject: lua /commands --- src/lua_runtime.zig | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) (limited to 'src/lua_runtime.zig') diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index 2cf3cf9..7d18fe9 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -46,6 +46,15 @@ pub const RuntimeError = error{ OutOfMemory, }; +/// A slash command declared by a Lua extension. Name and description +/// point into the runtime's `strings` pool; the handler lives in the Lua +/// registry, addressed by `handler_ref`. +pub const LuaCommand = struct { + name: []const u8, + description: []const u8, + handler_ref: c_int, +}; + /// Owned state for the runtime. pub const LuaRuntime = struct { allocator: Allocator, @@ -58,6 +67,14 @@ pub const LuaRuntime = struct { /// the Lua registry (`luaL_ref` index). handlers: std.StringHashMap(c_int), + /// Slash commands declared by extensions via `panto.register_command`. + /// Owned by this runtime; the CLI reads these to build its command + /// registry. Each entry's name/description point into `strings`. + commands: std.array_list.Managed(LuaCommand), + /// Map from command name (borrowed from `commands`) to its handler + /// ref in the Lua registry. + command_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 @@ -92,6 +109,8 @@ pub const LuaRuntime = struct { .decls = std.array_list.Managed(panto.ToolDecl).init(allocator), .strings = std.array_list.Managed([]u8).init(allocator), .handlers = std.StringHashMap(c_int).init(allocator), + .commands = std.array_list.Managed(LuaCommand).init(allocator), + .command_handlers = std.StringHashMap(c_int).init(allocator), }; return self; } @@ -123,6 +142,11 @@ pub const LuaRuntime = struct { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, entry.value_ptr.*); } self.handlers.deinit(); + var cit = self.command_handlers.iterator(); + while (cit.next()) |entry| { + c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, entry.value_ptr.*); + } + self.command_handlers.deinit(); if (self.wrapper_ref != 0) { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.wrapper_ref); } @@ -133,6 +157,7 @@ pub const LuaRuntime = struct { c.lua_close(self.L); self.decls.deinit(); + self.commands.deinit(); for (self.strings.items) |s| self.allocator.free(s); self.strings.deinit(); self.allocator.destroy(self); @@ -309,6 +334,105 @@ pub const LuaRuntime = struct { c.lua_settop(L, c.lua_gettop(L) - 1); // pop record } + + try self.harvestAndStoreCommands(); + } + + /// Walk the command registrations table populated by the script just + /// loaded. For each `{ name, description, handler }` record: copy the + /// strings, `luaL_ref` the handler, and append a `LuaCommand`. + /// Duplicate command names within the runtime are rejected. + fn harvestAndStoreCommands(self: *LuaRuntime) !void { + const L = self.L; + + _ = c.lua_rawgetp(L, lua_bridge.LUA_REGISTRYINDEX, &lua_bridge.command_registrations_key); + defer c.lua_settop(L, c.lua_gettop(L) - 1); + + const n: usize = @intCast(c.lua_rawlen(L, -1)); + var i: usize = 1; + while (i <= n) : (i += 1) { + _ = c.lua_rawgeti(L, -1, @intCast(i)); // push record + + const name = try self.readStringFieldOwned("name"); + const desc = try self.readStringFieldOwned("description"); + + _ = c.lua_getfield(L, -1, "handler"); + if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { + c.lua_settop(L, c.lua_gettop(L) - 2); // pop handler + record + return RuntimeError.LuaHandlerNotFound; + } + const ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); + // Stack: ..., regs_table, record + + const gop = try self.command_handlers.getOrPut(name); + if (gop.found_existing) { + c.luaL_unref(L, lua_bridge.LUA_REGISTRYINDEX, ref); + c.lua_settop(L, c.lua_gettop(L) - 1); // pop record + return error.DuplicateCommand; + } + gop.value_ptr.* = ref; + + try self.commands.append(.{ + .name = name, + .description = desc, + .handler_ref = ref, + }); + + c.lua_settop(L, c.lua_gettop(L) - 1); // pop record + } + } + + /// Run a Lua slash-command handler synchronously. `handler_ref` is a + /// registry ref to a `function(args)` closure; `args` is the trimmed + /// remainder of the command line. The handler runs under `pcall` with + /// a `debug.traceback` error handler so failures surface a readable + /// message instead of crashing the REPL. + /// + /// Slash-command handlers act by side effect (printing, mutating + /// state) and their return value is ignored. On Lua error, returns + /// `RuntimeError.LuaHandlerCrashed` after copying the error message + /// into `err_buf` (truncated to fit); `*err_len` is set to the number + /// of bytes written. + pub fn runCommand( + self: *LuaRuntime, + handler_ref: c_int, + args: []const u8, + err_buf: []u8, + err_len: *usize, + ) RuntimeError!void { + const L = self.L; + err_len.* = 0; + + // Install a traceback error handler beneath the call frame. + _ = c.lua_getglobal(L, "debug"); + _ = c.lua_getfield(L, -1, "traceback"); + c.lua_copy(L, -1, -2); + c.lua_settop(L, c.lua_gettop(L) - 1); // pop the `debug` table + const errfunc_idx = c.lua_gettop(L); + + // Push handler + args string. + _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref)); + _ = c.lua_pushlstring(L, args.ptr, args.len); + + const rc = c.lua_pcallk(L, 1, 0, errfunc_idx, 0, null); + if (rc != 0) { + var len: usize = 0; + const ptr = c.lua_tolstring(L, -1, &len); + if (ptr != null) { + const n = @min(len, err_buf.len); + @memcpy(err_buf[0..n], ptr[0..n]); + err_len.* = n; + } + c.lua_settop(L, c.lua_gettop(L) - 2); // pop error + errfunc + return RuntimeError.LuaHandlerCrashed; + } + c.lua_settop(L, c.lua_gettop(L) - 1); // pop errfunc + } + + /// Slash commands declared by loaded extensions, in registration + /// order. The CLI walks these to populate its command registry. + pub fn commandList(self: *const LuaRuntime) []const LuaCommand { + return self.commands.items; } fn readStringFieldOwned(self: *LuaRuntime, field_name: [:0]const u8) ![]const u8 { @@ -963,6 +1087,86 @@ test "loadExtension records tool decls" { try testing.expectEqualStrings("greet", rt.decls.items[0].name); } +test "loadExtension records command decls" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const source = + \\_G.last_args = nil + \\panto.register_command { + \\ name = "shout", description = "Uppercases its args.", + \\ handler = function(args) _G.last_args = args:upper() end, + \\} + ; + const path = try writeTempScript(tmp.dir, "shout.lua", source); + defer testing.allocator.free(path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + + try rt.loadExtension(path, null); + const cmds = rt.commandList(); + try testing.expectEqual(@as(usize, 1), cmds.len); + try testing.expectEqualStrings("shout", cmds[0].name); + try testing.expectEqualStrings("Uppercases its args.", cmds[0].description); + // No tool was registered. + try testing.expectEqual(@as(usize, 0), rt.toolCount()); + + // Invoke the handler and confirm the side effect ran with our args. + var err_buf: [256]u8 = undefined; + var err_len: usize = 0; + try rt.runCommand(cmds[0].handler_ref, "hi there", &err_buf, &err_len); + + _ = c.lua_getglobal(rt.L, "last_args"); + var len: usize = 0; + const got = c.lua_tolstring(rt.L, -1, &len); + try testing.expectEqualStrings("HI THERE", got[0..len]); + c.lua_settop(rt.L, c.lua_gettop(rt.L) - 1); +} + +test "runCommand surfaces a Lua error" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const source = + \\panto.register_command { + \\ name = "boom", description = "crashes", + \\ handler = function(args) error("kaboom") end, + \\} + ; + const path = try writeTempScript(tmp.dir, "boom.lua", source); + defer testing.allocator.free(path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + try rt.loadExtension(path, null); + + const cmds = rt.commandList(); + var err_buf: [4096]u8 = undefined; + var err_len: usize = 0; + try testing.expectError( + RuntimeError.LuaHandlerCrashed, + rt.runCommand(cmds[0].handler_ref, "", &err_buf, &err_len), + ); + try testing.expect(std.mem.indexOf(u8, err_buf[0..err_len], "kaboom") != null); +} + +test "duplicate command name within runtime is rejected" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const source = + \\panto.register_command { name = "dup", description = "a", handler = function() end } + \\panto.register_command { name = "dup", description = "b", handler = function() end } + ; + const path = try writeTempScript(tmp.dir, "dup.lua", source); + defer testing.allocator.free(path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + try testing.expectError(error.DuplicateCommand, rt.loadExtension(path, null)); +} + test "invokeBatch runs each call through a coroutine and returns the result" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); -- cgit v1.3