embedding lua via ziglua
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 used by burner-redis (an in-process redis-compat engine) for the redis.call bridge.
the dep
natecraddock/ziglua tracks zig master / 0.16. it vendors the Lua C sources and compiles them per build; no system liblua needed.
zig fetch --save "git+https://github.com/natecraddock/ziglua#<commit-sha>"// 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). other options: .lua52, .lua53, .lua54, .luau.
minimal boot
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 u8Lua.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:
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.
// 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@ptrCastto convert it to*Lua. pushClosure(fn, n)popsnupvalues from the stack and binds them to the closure. retrieve them atLua.upvalueIndex(i)inside the callback.pushLightUserdata(ptr)lets you bury a*const anyopaquepointer 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, usesetIndexRaw(maps tolua_rawseti) for integer-keyed assignment. unpack, nottable.unpack. in 5.1, the globalunpack(table)exists; in 5.2+ it moved totable.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.
#tbllength 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:
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)
burner-redis spins up a new Lua VM for every EVAL and disposes it after. no script state leaks across calls. you pay the cost of Lua.init per script — for the use case of docket (a task system; one EVAL per task per worker tick) it's invisible. for high-throughput EVAL you'd 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 accessopenOs—os.execute,os.exit,os.getenvopenPackage—require+ module loadingopenDebug— introspection bypasses sandboxing
leave open: openBase (limited — print is fine), openString, openTable, openMath. matches what real redis exposes inside EVAL.
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.