//! Lua C-API bridge for the panto CLI. //! //! Exposes a `panto` global table inside any `lua_State` we construct, //! with a single function: //! //! panto.register_tool { //! name = "...", //! description = "...", //! schema = { ... }, -- JSON Schema as a Lua table //! handler = function(input) ... end, //! } //! //! The single-table-argument form is idiomatic Lua "named arguments". //! It's also forward-compatible: future optional fields (examples, //! version, etc.) can be added without breaking existing extensions. //! //! Each call records a registration in a Lua-side table at a fixed //! registry slot (`registrations_key`). The Zig side reads that table //! to decide what to do with the entries: //! //! - The long-lived `LuaRuntime` (`src/lua_runtime.zig`) loads each //! extension into a single `lua_State`, then walks the registrations //! table once per loaded script (between loads it calls //! `resetRegistrations`). For each entry it copies name / //! description / schema, `luaL_ref`s the handler function into the //! Lua registry, and records the ref under the tool name. //! //! - On dispatch, the runtime spawns a coroutine, pushes the handler //! onto it via `lua_rawgeti(LUA_REGISTRYINDEX, ref)`, pushes the //! parsed JSON input, and `lua_resume`s. Top-level extension code //! never runs again — only handler bodies. //! //! This bridge module itself is stateless beyond the public //! `registrations_key` address; it cooperates with whatever runtime //! owns the `lua_State`. const std = @import("std"); const Allocator = std.mem.Allocator; const panto = @import("panto"); pub const c = @cImport({ @cInclude("lua.h"); @cInclude("lauxlib.h"); @cInclude("lualib.h"); }); // Lua type constants are #defines that translate_c surfaces as inline // functions returning the literal; using them in switch prongs needs // explicit `c_int` constants. Define our own clean aliases. pub const T_NIL: c_int = 0; pub const T_BOOLEAN: c_int = 1; pub const T_NUMBER: c_int = 3; pub const T_STRING: c_int = 4; pub const T_TABLE: c_int = 5; pub const T_FUNCTION: c_int = 6; pub const LUA_MULTRET: c_int = -1; pub const LUA_REGISTRYINDEX: c_int = -1001000; // matches lua.h with LUAI_MAXSTACK=1000000 /// Errors the bridge can produce when talking to a Lua state. pub const BridgeError = error{ LuaInitFailed, LuaLoadFailed, LuaRunFailed, LuaHandlerCrashed, LuaHandlerNotFound, BadRegistration, BadHandlerReturn, InputNotJsonObject, OutOfMemory, }; /// The key under which we stash the registrations table in /// `LUA_REGISTRYINDEX`. Any unique pointer works — we use the address of a /// module-level `u8` so multiple states all use the same key value. /// /// Public so the long-lived runtime can poke at it directly when it /// needs to harvest entries. pub var registrations_key: u8 = 0; /// The key under which we stash the *command* registrations table in /// `LUA_REGISTRYINDEX`. Distinct from `registrations_key` so tool and /// slash-command registrations never collide. Holds an array of records /// shaped `{ name=, description=, handler= }`. pub var command_registrations_key: u8 = 0; /// A single declared tool, as harvested from a script's top-level call to /// `panto.register_tool`. All slices reference Lua-owned strings on the /// state's stack/registry; copy them before closing the state. pub const Registration = struct { name: []const u8, description: []const u8, /// Serialized JSON Schema for the tool's input. schema_json: []const u8, }; /// A single declared slash command, harvested from a script's top-level /// call to `panto.register_command`. Slices reference Lua-owned strings; /// copy them before closing the state. pub const CommandRegistration = struct { name: []const u8, description: []const u8, }; // --------------------------------------------------------------------------- // Public bridge API // --------------------------------------------------------------------------- /// Install the `panto.register_tool` global into the given state. /// /// Also creates the registry table that holds harvested registrations and /// the per-name handler references. pub fn install(L: *c.lua_State) void { // Create the registrations table: an array of records, each shaped // { name=, description=, schema_json=, handler= }. c.lua_createtable(L, 0, 0); // Stash under our registry key. c.lua_rawsetp(L, LUA_REGISTRYINDEX, ®istrations_key); // Create the command registrations table (parallel to the tool one). c.lua_createtable(L, 0, 0); c.lua_rawsetp(L, LUA_REGISTRYINDEX, &command_registrations_key); // Build the `panto` global table with `register_tool` and // `register_command`. c.lua_createtable(L, 0, 2); c.lua_pushcclosure(L, registerToolThunk, 0); c.lua_setfield(L, -2, "register_tool"); c.lua_pushcclosure(L, registerCommandThunk, 0); c.lua_setfield(L, -2, "register_command"); c.lua_setglobal(L, "panto"); } /// Replace the registrations table with a fresh empty one. Used by the /// long-lived runtime between loading distinct extension scripts so it /// can harvest only the registrations made by the script just loaded /// (not accumulated from prior loads). pub fn resetRegistrations(L: *c.lua_State) void { c.lua_createtable(L, 0, 0); c.lua_rawsetp(L, LUA_REGISTRYINDEX, ®istrations_key); c.lua_createtable(L, 0, 0); c.lua_rawsetp(L, LUA_REGISTRYINDEX, &command_registrations_key); } /// Load and execute a Lua source file in the given state. The file's /// top-level code typically calls `panto.register_tool(...)` one or more /// times, populating the registrations table. /// /// On Lua error, the error message is left on the stack — callers that /// want to surface it can read the top with `lua_tolstring`. pub fn loadFile(L: *c.lua_State, path: [:0]const u8) BridgeError!void { if (c.luaL_loadfilex(L, path.ptr, null) != 0) return BridgeError.LuaLoadFailed; if (c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) return BridgeError.LuaRunFailed; } /// Walk the registrations table and copy each entry's name, description, /// and schema_json into freshly-allocated bytes owned by `arena`. The /// returned slice (and every byte slice inside each `Registration`) lives /// as long as the arena does. /// /// The handler field is ignored — discovery mode doesn't care about it. pub fn harvestRegistrations( L: *c.lua_State, arena: Allocator, ) BridgeError![]Registration { // Push the registrations table. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, ®istrations_key); defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop the table when done const n: usize = @intCast(c.lua_rawlen(L, -1)); if (n == 0) return arena.alloc(Registration, 0) catch BridgeError.OutOfMemory; var out = arena.alloc(Registration, n) catch return BridgeError.OutOfMemory; var i: usize = 1; while (i <= n) : (i += 1) { _ = c.lua_rawgeti(L, -1, @intCast(i)); // record table on top defer c.lua_settop(L, c.lua_gettop(L) - 1); const name = try readStringField(L, -1, "name", arena); const desc = try readStringField(L, -1, "description", arena); const schema = try readStringField(L, -1, "schema_json", arena); out[i - 1] = .{ .name = name, .description = desc, .schema_json = schema, }; } return out; } /// Walk the *command* registrations table and copy each entry's name and /// description into arena-owned bytes. The handler field is ignored; /// callers that need to invoke handlers `luaL_ref` them separately. pub fn harvestCommandRegistrations( L: *c.lua_State, arena: Allocator, ) BridgeError![]CommandRegistration { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &command_registrations_key); defer c.lua_settop(L, c.lua_gettop(L) - 1); const n: usize = @intCast(c.lua_rawlen(L, -1)); if (n == 0) return arena.alloc(CommandRegistration, 0) catch BridgeError.OutOfMemory; var out = arena.alloc(CommandRegistration, n) catch return BridgeError.OutOfMemory; var i: usize = 1; while (i <= n) : (i += 1) { _ = c.lua_rawgeti(L, -1, @intCast(i)); defer c.lua_settop(L, c.lua_gettop(L) - 1); out[i - 1] = .{ .name = try readStringField(L, -1, "name", arena), .description = try readStringField(L, -1, "description", arena), }; } return out; } /// In an *invocation-mode* state (registrations table populated by re- /// running the script), push the handler function for `tool_name` onto the /// stack. Caller is responsible for popping it after use. /// /// Returns LuaHandlerNotFound if no registration with that name exists. pub fn pushHandler(L: *c.lua_State, tool_name: []const u8) BridgeError!void { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, ®istrations_key); 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)); _ = c.lua_getfield(L, -1, "name"); var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); const matched = ptr != null and std.mem.eql(u8, ptr[0..len], tool_name); c.lua_settop(L, c.lua_gettop(L) - 1); // pop name if (matched) { // Replace the record with its handler field. _ = c.lua_getfield(L, -1, "handler"); // Stack: ..., regs_table, record, handler. Remove record, regs_table. c.lua_copy(L, -1, -3); c.lua_settop(L, c.lua_gettop(L) - 2); return; } c.lua_settop(L, c.lua_gettop(L) - 1); // pop record } c.lua_settop(L, c.lua_gettop(L) - 1); // pop regs table return BridgeError.LuaHandlerNotFound; } /// Convert raw JSON bytes into a Lua value and push it onto the stack. /// Top-level value must be a JSON object (matches our schema convention /// that tool input is always an object). pub fn pushJsonAsLua( L: *c.lua_State, arena: Allocator, input: []const u8, ) BridgeError!void { var parsed = std.json.parseFromSlice(std.json.Value, arena, input, .{}) catch { return BridgeError.InputNotJsonObject; }; defer parsed.deinit(); if (parsed.value != .object) return BridgeError.InputNotJsonObject; pushJsonValue(L, parsed.value) catch return BridgeError.OutOfMemory; } /// Read a tool handler's return value at `idx` and convert it into an /// owned `[]panto.ResultPart`. Two accepted shapes: /// /// * a plain string -> one `.text` part; /// * a table `{ text = "...", attachments = { { media_type = "...", /// data = "..." }, ... } }` -> an optional `.text` part followed by /// one `.media` part per attachment (`data` is base64-encoded bytes). /// /// Every returned slice/part owns its bytes (allocated with `allocator`); /// the caller frees via `panto.freeResultParts`. pub fn readHandlerResult( L: *c.lua_State, idx: c_int, allocator: Allocator, ) BridgeError![]panto.ResultPart { const ty = c.lua_type(L, idx); if (ty == T_STRING) { var len: usize = 0; const ptr = c.lua_tolstring(L, idx, &len); if (ptr == null) return BridgeError.BadHandlerReturn; return (panto.textResult(allocator, ptr[0..len]) catch BridgeError.OutOfMemory); } if (ty != T_TABLE) return BridgeError.BadHandlerReturn; return readHandlerResultTable(L, idx, allocator); } /// Read a string field `name` from the table at `tbl_idx`. Returns null /// if absent/nil, an error if present-but-not-a-string. The returned /// slice borrows from Lua's internal buffer — copy before popping. fn optStringField(L: *c.lua_State, tbl_idx: c_int, name: [*:0]const u8) BridgeError!?[]const u8 { const t = c.lua_getfield(L, tbl_idx, name); if (t == T_NIL) { c.lua_settop(L, c.lua_gettop(L) - 1); return null; } if (t != T_STRING) { c.lua_settop(L, c.lua_gettop(L) - 1); return BridgeError.BadHandlerReturn; } var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); if (ptr == null) { c.lua_settop(L, c.lua_gettop(L) - 1); return BridgeError.BadHandlerReturn; } // Note: value still on stack; caller pops after copying. return ptr[0..len]; } fn readHandlerResultTable( L: *c.lua_State, idx: c_int, allocator: Allocator, ) BridgeError![]panto.ResultPart { var parts: std.ArrayList(panto.ResultPart) = .empty; errdefer { for (parts.items) |p| p.deinit(allocator); parts.deinit(allocator); } // Optional `text` field. { const maybe = try optStringField(L, idx, "text"); if (maybe) |s| { const owned = allocator.dupe(u8, s) catch return BridgeError.OutOfMemory; c.lua_settop(L, c.lua_gettop(L) - 1); // pop field value parts.append(allocator, .{ .text = owned }) catch { allocator.free(owned); return BridgeError.OutOfMemory; }; } } // Optional `attachments` array. { const t = c.lua_getfield(L, idx, "attachments"); defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop attachments if (t == T_TABLE) { 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 attachment table defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop attachment if (c.lua_type(L, -1) != T_TABLE) return BridgeError.BadHandlerReturn; // `media_type` is an optional hint; libpanto detects the // type from the raw bytes when it's absent. const media_type: ?[]const u8 = if (try optStringField(L, -1, "media_type")) |mt_slice| blk: { const owned = allocator.dupe(u8, mt_slice) catch return BridgeError.OutOfMemory; c.lua_settop(L, c.lua_gettop(L) - 1); // pop media_type value break :blk owned; } else null; errdefer if (media_type) |mt| allocator.free(mt); const data_slice = (try optStringField(L, -1, "data")) orelse return BridgeError.BadHandlerReturn; const data = allocator.dupe(u8, data_slice) catch return BridgeError.OutOfMemory; c.lua_settop(L, c.lua_gettop(L) - 1); // pop data value errdefer allocator.free(data); parts.append(allocator, .{ .media = .{ .media_type = media_type, .data = data, } }) catch return BridgeError.OutOfMemory; } } else if (t != T_NIL) { return BridgeError.BadHandlerReturn; } } return parts.toOwnedSlice(allocator) catch BridgeError.OutOfMemory; } // --------------------------------------------------------------------------- // Lua-callable C functions // --------------------------------------------------------------------------- /// Implementation of `panto.register_tool { name=, description=, schema=, handler= }`. /// /// Expects a single table argument with the four named fields. Validates /// each field type, serializes `schema` to JSON, and appends a record to /// the registrations table at `registry[®istrations_key]`. Throws a Lua /// error via `luaL_error` if anything is malformed — that propagates out /// of the running script as a Lua exception, which our `loadFile` surfaces /// as `LuaRunFailed`. fn registerToolThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; c.luaL_checktype(L, 1, T_TABLE); // Pull each named field onto the stack and type-check it. After this // block, the stack layout is: // 1: args table (input) // 2: name (string) // 3: description (string) // 4: schema (table) // 5: handler (function) expectField(L, 1, "name", T_STRING); expectField(L, 1, "description", T_STRING); expectField(L, 1, "schema", T_TABLE); expectField(L, 1, "handler", T_FUNCTION); // Serialize the schema table to JSON, leaving the string on top. pushSchemaAsJson(L, 4) catch |err| { const msg = switch (err) { BridgeError.OutOfMemory => "register_tool: out of memory serializing schema", else => "register_tool: schema is not JSON-serializable", }; _ = c.luaL_error(L, msg); unreachable; }; // Stack now: args(1), name(2), desc(3), schema(4), handler(5), schema_json(6) // Build the record table. c.lua_createtable(L, 0, 4); c.lua_pushvalue(L, 2); c.lua_setfield(L, -2, "name"); c.lua_pushvalue(L, 3); c.lua_setfield(L, -2, "description"); c.lua_pushvalue(L, 6); // schema_json c.lua_setfield(L, -2, "schema_json"); c.lua_pushvalue(L, 5); // handler c.lua_setfield(L, -2, "handler"); // Append the record to the registrations table. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, ®istrations_key); // Stack: ..., record, regs_table const n: c_int = @intCast(c.lua_rawlen(L, -1)); c.lua_pushvalue(L, -2); // copy record above regs_table c.lua_rawseti(L, -2, n + 1); c.lua_settop(L, c.lua_gettop(L) - 1); // pop regs_table return 0; } /// Implementation of `panto.register_command { name=, description=, handler= }`. /// /// Expects a single table argument with three named fields. The handler /// is a function `function(args) ... end` where `args` is the trimmed /// remainder of the slash-command line (a string, possibly empty). Its /// return value is ignored — commands act by side effect, like the /// builtin Zig commands. Appends a `{ name, description, handler }` /// record to the command registrations table. fn registerCommandThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; c.luaL_checktype(L, 1, T_TABLE); // Stack after these pulls: // 1: args table (input) // 2: name (string) // 3: description (string) // 4: handler (function) expectFieldNamed(L, 1, "name", T_STRING, "register_command"); expectFieldNamed(L, 1, "description", T_STRING, "register_command"); expectFieldNamed(L, 1, "handler", T_FUNCTION, "register_command"); // Build the record table { name, description, handler }. c.lua_createtable(L, 0, 3); c.lua_pushvalue(L, 2); c.lua_setfield(L, -2, "name"); c.lua_pushvalue(L, 3); c.lua_setfield(L, -2, "description"); c.lua_pushvalue(L, 4); // handler c.lua_setfield(L, -2, "handler"); // Append to the command registrations table. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &command_registrations_key); // Stack: ..., record, regs_table const n: c_int = @intCast(c.lua_rawlen(L, -1)); c.lua_pushvalue(L, -2); // copy record above regs_table c.lua_rawseti(L, -2, n + 1); c.lua_settop(L, c.lua_gettop(L) - 1); // pop regs_table return 0; } /// Push `args_table[field_name]` onto the stack and assert it has the /// expected type. Raises a Lua error if missing or wrong type. fn expectField( L: *c.lua_State, args_idx: c_int, comptime field_name: [:0]const u8, expected_type: c_int, ) void { expectFieldNamed(L, args_idx, field_name, expected_type, "register_tool"); } /// Like `expectField`, but lets the caller name the bridge function in /// the error message (`register_tool` vs `register_command`). fn expectFieldNamed( L: *c.lua_State, args_idx: c_int, comptime field_name: [:0]const u8, expected_type: c_int, comptime fn_name: [:0]const u8, ) void { _ = c.lua_getfield(L, args_idx, field_name.ptr); const got = c.lua_type(L, -1); if (got != expected_type) { const fmt: [:0]const u8 = fn_name ++ ": field '%s' must be %s (got %s)"; _ = c.luaL_error( L, fmt.ptr, field_name.ptr, c.lua_typename(L, expected_type), c.lua_typename(L, got), ); unreachable; } } // --------------------------------------------------------------------------- // JSON <-> Lua conversion // --------------------------------------------------------------------------- /// Serialize the Lua table at stack index `idx` to a JSON string and push /// that string onto the stack. fn pushSchemaAsJson(L: *c.lua_State, idx: c_int) BridgeError!void { var aw: std.Io.Writer.Allocating = .init(std.heap.c_allocator); defer aw.deinit(); var s: std.json.Stringify = .{ .writer = &aw.writer }; writeLuaValueAsJson(L, idx, &s) catch return BridgeError.OutOfMemory; const bytes = aw.written(); _ = c.lua_pushlstring(L, bytes.ptr, bytes.len); } /// Serialize the value at stack index `idx` to JSON via `stringifier`. We /// allow strings, numbers, booleans, nil (→ JSON null), arrays (Lua tables /// with sequential integer keys 1..n), and objects (any other table). fn writeLuaValueAsJson(L: *c.lua_State, idx: c_int, w: *std.json.Stringify) anyerror!void { switch (c.lua_type(L, idx)) { T_NIL => try w.write(null), T_BOOLEAN => try w.write(c.lua_toboolean(L, idx) != 0), T_NUMBER => { // Distinguish integer vs float so we emit clean integers. var isnum: c_int = 0; const as_int = c.lua_tointegerx(L, idx, &isnum); if (isnum != 0) { try w.write(as_int); } else { const as_float = c.lua_tonumberx(L, idx, null); try w.write(as_float); } }, T_STRING => { var len: usize = 0; const ptr = c.lua_tolstring(L, idx, &len); try w.write(ptr[0..len]); }, T_TABLE => try writeLuaTableAsJson(L, idx, w), else => return error.UnsupportedLuaType, } } /// Decide whether the table at `idx` is JSON-array-shaped or object-shaped /// and serialize accordingly. /// /// Heuristic: if `lua_rawlen > 0`, treat it as an array (Lua's standard /// length operator returns the array-part border, so this catches the /// common case of `{ "a", "b", "c" }`). Otherwise iterate via `lua_next` /// and emit a JSON object keyed by stringified keys. /// /// An empty Lua table is ambiguous (could be either) and we serialize it /// as `{}`, since JSON Schema usage almost always wants empty-object /// shape (e.g. `properties = {}`). fn writeLuaTableAsJson(L: *c.lua_State, idx_in: c_int, w: *std.json.Stringify) !void { // Normalize negative indices since we'll be pushing more on the stack. const abs_idx = c.lua_absindex(L, idx_in); const len = c.lua_rawlen(L, abs_idx); if (len > 0) { try w.beginArray(); var i: c.lua_Integer = 1; while (i <= @as(c.lua_Integer, @intCast(len))) : (i += 1) { _ = c.lua_rawgeti(L, abs_idx, i); try writeLuaValueAsJson(L, -1, w); c.lua_settop(L, c.lua_gettop(L) - 1); } try w.endArray(); return; } try w.beginObject(); c.lua_pushnil(L); // first key while (c.lua_next(L, abs_idx) != 0) { // Stack: ..., key, value. Key must be a string for JSON objects. if (c.lua_type(L, -2) != T_STRING) { // Pop value, leave key for next lua_next iteration. c.lua_settop(L, c.lua_gettop(L) - 1); return error.UnsupportedLuaKey; } var klen: usize = 0; // CAREFUL: lua_tolstring on a non-string-key would mutate the key // and break lua_next. We already verified it's T_STRING above. const kptr = c.lua_tolstring(L, -2, &klen); try w.objectField(kptr[0..klen]); try writeLuaValueAsJson(L, -1, w); c.lua_settop(L, c.lua_gettop(L) - 1); // pop value, keep key } try w.endObject(); } /// Push a parsed `std.json.Value` onto the stack. fn pushJsonValue(L: *c.lua_State, v: std.json.Value) !void { switch (v) { .null => c.lua_pushnil(L), .bool => |b| c.lua_pushboolean(L, if (b) 1 else 0), .integer => |i| c.lua_pushinteger(L, @intCast(i)), .float => |f| c.lua_pushnumber(L, f), .number_string => |s| { // Best effort: try integer, else float. if (std.fmt.parseInt(c.lua_Integer, s, 10)) |i| { c.lua_pushinteger(L, i); } else |_| { const f = try std.fmt.parseFloat(c.lua_Number, s); c.lua_pushnumber(L, f); } }, .string => |s| _ = c.lua_pushlstring(L, s.ptr, s.len), .array => |arr| { c.lua_createtable(L, @intCast(arr.items.len), 0); for (arr.items, 0..) |item, i| { try pushJsonValue(L, item); c.lua_rawseti(L, -2, @intCast(i + 1)); } }, .object => |obj| { c.lua_createtable(L, 0, @intCast(obj.count())); var it = obj.iterator(); while (it.next()) |kv| { // Push key as Lua string (length-prefixed; doesn't need NUL). const key = kv.key_ptr.*; _ = c.lua_pushlstring(L, key.ptr, key.len); try pushJsonValue(L, kv.value_ptr.*); // Stack: ..., table, key, value -> table[key]=value, leaving table. c.lua_rawset(L, -3); } }, } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Read a string-typed field `field_name` from the record at stack index /// `record_idx`, duplicate its bytes into `arena`, and return the slice. fn readStringField( L: *c.lua_State, record_idx: c_int, field_name: [:0]const u8, arena: Allocator, ) BridgeError![]const u8 { _ = c.lua_getfield(L, record_idx, field_name.ptr); defer c.lua_settop(L, c.lua_gettop(L) - 1); if (c.lua_type(L, -1) != T_STRING) return BridgeError.BadRegistration; var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); if (ptr == null) return BridgeError.BadRegistration; return arena.dupe(u8, ptr[0..len]) catch BridgeError.OutOfMemory; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- test "install creates panto.register_tool global" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); _ = c.lua_getglobal(L, "panto"); try std.testing.expectEqual(@as(c_int, T_TABLE), c.lua_type(L, -1)); _ = c.lua_getfield(L, -1, "register_tool"); try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1)); _ = c.lua_getglobal(L, "panto"); _ = c.lua_getfield(L, -1, "register_command"); try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1)); } test "register_command records name and description" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); const script = \\panto.register_command { \\ name = "hello", \\ description = "Greets the user.", \\ handler = function(args) return "hi " .. args end, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); std.debug.print("lua error: {s}\n", .{msg[0..len]}); return error.LuaScriptFailed; } var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const cmds = try harvestCommandRegistrations(L, arena_state.allocator()); try std.testing.expectEqual(@as(usize, 1), cmds.len); try std.testing.expectEqualStrings("hello", cmds[0].name); try std.testing.expectEqualStrings("Greets the user.", cmds[0].description); } test "register_command rejects a non-function handler" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); const script = \\panto.register_command { \\ name = "bad", description = "d", handler = 42, \\} ; const loaded = c.luaL_loadstring(L, script) == 0; try std.testing.expect(loaded); // Should raise a Lua error (non-zero pcall result). try std.testing.expect(c.lua_pcallk(L, 0, 0, 0, 0, null) != 0); var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); try std.testing.expect(std.mem.indexOf(u8, msg[0..len], "register_command") != null); } test "register_tool records name, description, schema_json" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); const script = \\panto.register_tool { \\ name = "echo", \\ description = "Echoes its input back.", \\ schema = { type = "object", properties = { msg = { type = "string" } } }, \\ handler = function(input) return input.msg end, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); std.debug.print("lua error: {s}\n", .{msg[0..len]}); return error.LuaScriptFailed; } var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const regs = try harvestRegistrations(L, arena_state.allocator()); try std.testing.expectEqual(@as(usize, 1), regs.len); try std.testing.expectEqualStrings("echo", regs[0].name); try std.testing.expectEqualStrings("Echoes its input back.", regs[0].description); // schema_json should be valid JSON containing "type": "object" try std.testing.expect(std.mem.indexOf(u8, regs[0].schema_json, "\"type\"") != null); try std.testing.expect(std.mem.indexOf(u8, regs[0].schema_json, "\"object\"") != null); } test "handler invocation: input parsed, result captured" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); const script = \\panto.register_tool { \\ name = "echo", description = "echoes", \\ schema = { type = "object" }, \\ handler = function(input) return "got: " .. input.msg end, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { return error.LuaScriptFailed; } try pushHandler(L, "echo"); try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1)); var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); try pushJsonAsLua(L, arena_state.allocator(), "{\"msg\":\"hello\"}"); // Call: 1 arg, 1 return. if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) { return error.LuaCallFailed; } const result = try readHandlerResult(L, -1, std.testing.allocator); defer panto.freeResultParts(std.testing.allocator, result); try std.testing.expectEqual(@as(usize, 1), result.len); try std.testing.expectEqualStrings("got: hello", result[0].text); } test "readHandlerResult: table with text and attachments" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); const script = \\return { \\ text = "see image", \\ attachments = { \\ { media_type = "image/png", data = "AAA=" }, \\ { media_type = "application/pdf", data = "BBB=" }, \\ }, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { return error.LuaScriptFailed; } const result = try readHandlerResult(L, -1, std.testing.allocator); defer panto.freeResultParts(std.testing.allocator, result); try std.testing.expectEqual(@as(usize, 3), result.len); try std.testing.expectEqualStrings("see image", result[0].text); try std.testing.expectEqualStrings("image/png", result[1].media.media_type.?); try std.testing.expectEqualStrings("AAA=", result[1].media.data); try std.testing.expectEqualStrings("application/pdf", result[2].media.media_type.?); try std.testing.expectEqualStrings("BBB=", result[2].media.data); } test "handler crash: error message surfaces via xpcall traceback hook" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); const script = \\panto.register_tool { \\ name = "boom", description = "crashes", \\ schema = { type = "object" }, \\ handler = function(input) error("explosion") end, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { return error.LuaScriptFailed; } // Push a traceback error handler at the bottom of 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); const errfunc_idx = c.lua_gettop(L); try pushHandler(L, "boom"); c.lua_pushnil(L); // input arg // pcallk with errfunc index = where we put debug.traceback. const rc = c.lua_pcallk(L, 1, 1, errfunc_idx, 0, null); try std.testing.expect(rc != 0); var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); try std.testing.expect(msg != null); const slice = msg[0..len]; try std.testing.expect(std.mem.indexOf(u8, slice, "explosion") != null); // Should also contain a traceback marker since we used debug.traceback. try std.testing.expect(std.mem.indexOf(u8, slice, "stack traceback") != null); }