# embedding lua via ziglua > written against zig 0.16. how to wire a working Lua 5.1 interpreter into a zig 0.16 project, in the shape a redis-compatible `EVAL` engine needs for its `redis.call` bridge. ## the dep [`natecraddock/ziglua`](https://github.com/natecraddock/ziglua) tracks zig master / 0.16. it vendors the Lua C sources and compiles them per build; no system `liblua` needed. ```sh zig fetch --save "git+https://github.com/natecraddock/ziglua#" ``` ```zig // build.zig const zlua = b.dependency("zlua", .{ .target = target, .optimize = optimize, .lang = .lua51, // pick the redis-compatible Lua version }); my_module.addImport("zlua", zlua.module("zlua")); ``` `lang = .lua51` is the right choice if you're implementing a redis-compatible engine (see [eval-lua](../../storage/redis/eval-lua.md)). other options: `.lua52`, `.lua53`, `.lua54`, `.luau`. ## minimal boot ```zig const zlua = @import("zlua"); const Lua = zlua.Lua; const lua = try Lua.init(allocator); defer lua.deinit(); lua.openBase(); lua.openString(); lua.openTable(); lua.openMath(); // (skip openIo / openOs / openPackage for sandboxing) try lua.doString("return 'hello'"); const s = try lua.toString(-1); // [:0]const u8 — note: NOT []const u8 ``` `Lua.init` returns a `*Lua` (the type is opaque). pass `lua` (not `&lua`) to all methods. `Lua.init` wires your zig allocator into `lua_Alloc` — no libc malloc. ## reading stack values zig-style returns. `lua_tostring` becomes: ```zig const got: [:0]const u8 = try lua.toString(-1); // for strings const n: Integer = try lua.toInteger(-1); // for integers const f: Number = try lua.toNumber(-1); // for numbers const b: bool = lua.toBoolean(-1); const ty: LuaType = lua.typeOf(-1); ``` `toString` returns `[:0]const u8` — sentinel-terminated. don't run `std.mem.span` on it; it's already a slice. ## registering native functions (the redis.call shape) the goal: expose a zig function as `redis.call(cmd, arg1, arg2, ...)` inside the Lua VM, with access to engine state. ```zig // 1) Define the C-callback signature. fn luaRedisCall(state: ?*zlua.LuaState) callconv(.c) c_int { // 2) Cast LuaState* → *Lua (opaque-pointer reinterpret). const lua: *Lua = @ptrCast(state.?); // 3) Pull state from upvalue 1. const ctx = lua.toUserdata(DispatchCtx, Lua.upvalueIndex(1)) catch { lua.raiseErrorStr("internal context missing", .{}); }; // 4) Read positional args. lua.getTop() = arg count. // ... dispatch on args[0], push reply, return 1 ... return 1; } // 5) Wire it: redis.call captures ctx as an upvalue. lua.newTable(); lua.pushLightUserdata(&ctx); // upvalue 1 lua.pushClosure(luaRedisCall, 1); // pop 1 upvalue, push closure lua.setField(-2, "call"); lua.setGlobal("redis"); ``` key points: - the callback must be `callconv(.c)` and take `?*zlua.LuaState` (the C ABI). use `@ptrCast` to convert it to `*Lua`. - `pushClosure(fn, n)` pops `n` upvalues from the stack and binds them to the closure. retrieve them at `Lua.upvalueIndex(i)` inside the callback. - `pushLightUserdata(ptr)` lets you bury a `*const anyopaque` pointer into the Lua state. it doesn't own anything; the caller must keep the pointee alive for the call. ## API differences vs Lua 5.4 if you're following docs for newer Lua versions, watch out: - **no `lua_seti`.** in 5.1, use `setIndexRaw` (maps to `lua_rawseti`) for integer-keyed assignment. - **`unpack`, not `table.unpack`.** in 5.1, the global `unpack(table)` exists; in 5.2+ it moved to `table.unpack`. - **no integer type.** every number is a double (5.1/5.2). 5.3 added integers; if you're on 5.1, division of two integers is float division by default. - **`#tbl` length operator** has implementation-defined behavior for tables with nil holes. avoid relying on it for sparse arrays. ## error model Lua errors `longjmp` out of the C runtime — and that skips zig `defer` blocks. always run scripts through `protectedCall`: ```zig lua.loadString(z) catch { /* compile error reply */ }; lua.protectedCall(.{ .results = 1 }) catch { /* runtime error reply */ }; ``` `raiseError` / `raiseErrorStr` inside a C callback is the right way to surface an error to the Lua side — but only call them from inside a callback that was itself called under `pcall`. otherwise the longjmp climbs up to whatever C frame is on top. ## fresh VM per call (isolation) a new Lua VM per script execution, disposed after, guarantees no script state leaks across calls. the cost is `Lua.init` per script — negligible at low call rates. for high-throughput execution, pool VMs and `lua.setTop(0)` between uses to clear the stack. ## sandboxing Lua 5.1 base lib includes `dofile`, `loadfile`, `load`, `loadstring`, `print`, `require`, `setmetatable`, etc. for a redis-compatible engine, skip: - `openIo` — filesystem access - `openOs` — `os.execute`, `os.exit`, `os.getenv` - `openPackage` — `require` + module loading - `openDebug` — introspection bypasses sandboxing leave open: `openBase` (limited — `print` is fine), `openString`, `openTable`, `openMath`. matches what real redis exposes inside `EVAL`. ## sources - [burner-redis](https://tangled.org/zzstoatzz.io/burner-redis) — the `redis.call` bridge this was learned on (2026)