diff options
Diffstat (limited to 'src/lua_bridge.zig')
| -rw-r--r-- | src/lua_bridge.zig | 571 |
1 files changed, 571 insertions, 0 deletions
diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig new file mode 100644 index 0000000..3d4a24f --- /dev/null +++ b/src/lua_bridge.zig @@ -0,0 +1,571 @@ +//! 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. The Zig side then reads that table to decide what to do with it: +//! +//! - **Discovery** (`harvestRegistrations`): runs an extension script once at +//! startup to learn the *names*, *descriptions*, and *schemas* of every +//! tool it declares. The handler functions are discarded — that throwaway +//! state will be closed immediately. +//! +//! - **Invocation** (`fetchHandler` + `runHandler`): per tool call, we open +//! a fresh `lua_State`, re-run the script, then look up the handler by +//! name in the same registry table. +//! +//! No `lua_State` pooling, no shared mutable state across calls. Every +//! `LuaTool.invoke` builds and tears down its own state. This is slow per- +//! call (~ms of Lua startup) but mechanically the simplest model: there is +//! nothing that can leak between invocations. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +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. +var 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, +}; + +// --------------------------------------------------------------------------- +// 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); + + // Build the `panto` global table with `register_tool`. + c.lua_createtable(L, 0, 1); + c.lua_pushcclosure(L, registerToolThunk, 0); + c.lua_setfield(L, -2, "register_tool"); + c.lua_setglobal(L, "panto"); +} + +/// 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; +} + +/// 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 Lua value at `idx` and serialize it to a JSON-compatible owned +/// byte string. Used to convert handler return values into ToolResult +/// content. For now we only accept string returns; extending to richer +/// types is straightforward but unnecessary for slice 2. +pub fn readHandlerResult( + L: *c.lua_State, + idx: c_int, + allocator: Allocator, +) BridgeError![]u8 { + if (c.lua_type(L, idx) != T_STRING) return BridgeError.BadHandlerReturn; + var len: usize = 0; + const ptr = c.lua_tolstring(L, idx, &len); + if (ptr == null) return BridgeError.BadHandlerReturn; + return allocator.dupe(u8, ptr[0..len]) 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; +} + +/// 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 { + _ = c.lua_getfield(L, args_idx, field_name.ptr); + const got = c.lua_type(L, -1); + if (got != expected_type) { + _ = c.luaL_error( + L, + "register_tool: field '%s' must be %s (got %s)", + 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)); +} + +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 std.testing.allocator.free(result); + try std.testing.expectEqualStrings("got: hello", result); +} + +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); +} |
