Skip to content

embedding lua via ziglua

nate
Jul 8, 20264 min read1 read

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 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:

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 @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:

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 access
  • openOsos.execute, os.exit, os.getenv
  • openPackagerequire + module loading
  • openDebug — 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.

Across the AtmosphereDiscussions