summaryrefslogtreecommitdiff
path: root/build
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-27 08:04:04 -0600
committerT <t@tjp.lol>2026-05-27 11:46:52 -0600
commit576891dc2ec4d917932a4c396471d4bbbad90c8e (patch)
tree0662d629cf15a2e9cbb51353f6d3abe6d2c6edb5 /build
parentb72a405534d6be019573ee0a806014e2713fe55e (diff)
Finish the hard parts of the lua makeover
- bundle luarocks source in the panto binary - bootstrap process (intended for first `panto` run): - make ~/.local/share/panto/... - write out luarocks sources into it - run luarocks to install luv - new `panto bootstrap` command just runs the bootstrap - `panto bootstrap --force` removes everything and re-bootstraps - new `panto lua` command just runs panto's embedded lua
Diffstat (limited to 'build')
-rw-r--r--build/gen_lua_anchor.zig144
-rw-r--r--build/gen_lua_headers_embed.zig68
-rw-r--r--build/gen_luarocks_embed.zig157
-rw-r--r--build/panto_lua_repl.c49
4 files changed, 418 insertions, 0 deletions
diff --git a/build/gen_lua_anchor.zig b/build/gen_lua_anchor.zig
new file mode 100644
index 0000000..1143c2c
--- /dev/null
+++ b/build/gen_lua_anchor.zig
@@ -0,0 +1,144 @@
+//! Build-time codegen: scan Lua's `lua.h` and `lauxlib.h` for every
+//! `LUA_API` / `LUALIB_API` function declaration, and emit a C source
+//! file that takes the address of each into a single `__attribute__
+//! ((used))` array.
+//!
+//! Result: the linker can't drop those functions even under LTO +
+//! gc-sections, because they're reachable from the surviving array.
+//! Combined with `rdynamic = true` on the executable, every Lua API
+//! function ends up in the dynamic symbol table for C rocks (luv.so
+//! etc.) to resolve at `dlopen` time.
+//!
+//! Invoked from `build.zig`:
+//!
+//! gen-lua-anchor <lua-src-dir> <out-file>
+
+const std = @import("std");
+
+pub fn main(init: std.process.Init) !void {
+ const arena = init.arena.allocator();
+ const io = init.io;
+
+ var args = init.minimal.args.iterate();
+ defer args.deinit();
+ _ = args.next();
+ const src_dir = args.next() orelse return error.MissingSrcDir;
+ const out_path = args.next() orelse return error.MissingOutPath;
+
+ var names: std.array_list.Managed([]const u8) = .init(arena);
+ try collectFrom(arena, io, src_dir, "lua.h", &names);
+ try collectFrom(arena, io, src_dir, "lauxlib.h", &names);
+
+ // Deduplicate via a string set.
+ var seen: std.StringHashMapUnmanaged(void) = .empty;
+ try seen.ensureTotalCapacity(arena, @intCast(names.items.len * 2));
+ var unique: std.array_list.Managed([]const u8) = .init(arena);
+ for (names.items) |n| {
+ const gop = seen.getOrPutAssumeCapacity(n);
+ if (!gop.found_existing) try unique.append(n);
+ }
+ std.mem.sort([]const u8, unique.items, {}, lessThanString);
+
+ var out_file = try std.Io.Dir.cwd().createFile(io, out_path, .{});
+ defer out_file.close(io);
+ var buf: [4096]u8 = undefined;
+ var writer = out_file.writer(io, &buf);
+ const w = &writer.interface;
+
+ try w.writeAll(
+ \\/* Auto-generated by build/gen_lua_anchor.zig. Do not edit.
+ \\ *
+ \\ * Holds a reference to every Lua public API function so the
+ \\ * linker can't drop them under LTO + gc-sections. C rocks
+ \\ * loaded at runtime (luv.so etc.) resolve these symbols
+ \\ * against the host process's symbol table; combined with
+ \\ * rdynamic on the executable, this keeps the whole API
+ \\ * surface exported.
+ \\ */
+ \\
+ \\#include "lua.h"
+ \\#include "lauxlib.h"
+ \\
+ \\__attribute__((used, visibility("default")))
+ \\const void *const _panto_lua_api_anchor[] = {
+ \\
+ );
+ for (unique.items) |n| {
+ try w.print(" (const void *)&{s},\n", .{n});
+ }
+ try w.writeAll("};\n");
+ try w.flush();
+}
+
+fn lessThanString(_: void, a: []const u8, b: []const u8) bool {
+ return std.mem.lessThan(u8, a, b);
+}
+
+fn collectFrom(
+ arena: std.mem.Allocator,
+ io: std.Io,
+ src_dir: []const u8,
+ filename: []const u8,
+ out: *std.array_list.Managed([]const u8),
+) !void {
+ const path = try std.fs.path.join(arena, &.{ src_dir, filename });
+ const contents = try std.Io.Dir.cwd().readFileAlloc(io, path, arena, .unlimited);
+
+ // Walk line by line. When we hit a line starting with `LUA_API`,
+ // accumulate continuation lines until we see `);`. From the joined
+ // string, extract the function name \u2014 either parenthesized
+ // (`(lua_foo)`) or bare (`lua_foo(...)`).
+ var line_it = std.mem.splitScalar(u8, contents, '\n');
+ while (line_it.next()) |first_line| {
+ const trimmed = std.mem.trimStart(u8, first_line, " \t");
+ if (!std.mem.startsWith(u8, trimmed, "LUA_API") and
+ !std.mem.startsWith(u8, trimmed, "LUALIB_API")) continue;
+
+ var joined: std.array_list.Managed(u8) = .init(arena);
+ try joined.appendSlice(first_line);
+ while (std.mem.indexOf(u8, joined.items, ");") == null) {
+ const next_line = line_it.next() orelse break;
+ try joined.append(' ');
+ try joined.appendSlice(next_line);
+ }
+
+ if (extractName(joined.items)) |name| {
+ try out.append(try arena.dupe(u8, name));
+ }
+ }
+}
+
+/// Return the function-name portion of a Lua API declaration. Handles
+/// both `LUA_API <ret> (lua_foo) (...)` and `LUA_API <ret> lua_foo(...)`.
+fn extractName(decl: []const u8) ?[]const u8 {
+ // Find a `(name)` token first.
+ var i: usize = 0;
+ while (i + 1 < decl.len) : (i += 1) {
+ if (decl[i] != '(') continue;
+ const end = std.mem.indexOfScalar(u8, decl[i + 1 ..], ')') orelse return null;
+ const candidate = decl[i + 1 .. i + 1 + end];
+ if (isApiName(candidate)) return candidate;
+ // Skip this candidate; try further on.
+ }
+ // Fall back: bare-name pattern (no parens around the name).
+ var j: usize = 0;
+ while (j < decl.len) : (j += 1) {
+ if (j + 4 > decl.len) break;
+ if (!std.mem.eql(u8, decl[j .. j + 4], "lua_") and
+ !std.mem.eql(u8, decl[j .. j + 5], "luaL_")) continue;
+ var end = j;
+ while (end < decl.len and (std.ascii.isAlphanumeric(decl[end]) or decl[end] == '_')) end += 1;
+ return decl[j..end];
+ }
+ return null;
+}
+
+fn isApiName(s: []const u8) bool {
+ if (s.len < 4) return false;
+ const ok_prefix = std.mem.startsWith(u8, s, "lua_") or std.mem.startsWith(u8, s, "luaL_");
+ if (!ok_prefix) return false;
+ for (s) |ch| {
+ if (!std.ascii.isAlphanumeric(ch) and ch != '_') return false;
+ }
+ return true;
+}
diff --git a/build/gen_lua_headers_embed.zig b/build/gen_lua_headers_embed.zig
new file mode 100644
index 0000000..1ac4004
--- /dev/null
+++ b/build/gen_lua_headers_embed.zig
@@ -0,0 +1,68 @@
+//! Build-time codegen: emit a Zig module exposing every Lua public
+//! header from the Lua source tarball as embedded bytes. Bootstrap
+//! writes these to `$PANTO_HOME/rocks/lua-X.Y.Z/include/` on first run
+//! so luarocks can compile C rocks against them.
+//!
+//! Invoked from `build.zig`:
+//!
+//! gen-lua-headers-embed <lua-src-dir> <out-file>
+//!
+//! `<lua-src-dir>` is the `src/` directory of the Lua tarball.
+
+const std = @import("std");
+
+const headers = [_][]const u8{
+ "lua.h",
+ "luaconf.h",
+ "lualib.h",
+ "lauxlib.h",
+ // hpp is the C++-friendly include shim shipped upstream; some C
+ // rocks reference it.
+ "lua.hpp",
+};
+
+pub fn main(init: std.process.Init) !void {
+ const arena = init.arena.allocator();
+ const io = init.io;
+
+ var args = init.minimal.args.iterate();
+ defer args.deinit();
+ _ = args.next();
+ const embed_prefix = args.next() orelse return error.MissingEmbedPrefix;
+ const src_dir = args.next() orelse return error.MissingSrcDir;
+ const out_path = args.next() orelse return error.MissingOutPath;
+
+ var out_file = try std.Io.Dir.cwd().createFile(io, out_path, .{});
+ defer out_file.close(io);
+ var buf: [4096]u8 = undefined;
+ var writer = out_file.writer(io, &buf);
+ const w = &writer.interface;
+
+ try w.writeAll(
+ \\//! Auto-generated. Do not edit. See build/gen_lua_headers_embed.zig.
+ \\
+ \\pub const Entry = struct {
+ \\ name: []const u8,
+ \\ contents: []const u8,
+ \\};
+ \\
+ \\pub const files: []const Entry = &.{
+ \\
+ );
+
+ for (headers) |name| {
+ const abs = try std.fs.path.join(arena, &.{ src_dir, name });
+ // Verify the file exists; emit nothing for missing files (lua.hpp
+ // exists in current Lua releases but we don't want to hard-fail
+ // if the tarball ever drops it).
+ std.Io.Dir.cwd().access(io, abs, .{}) catch continue;
+ const embed_path = try std.fs.path.join(arena, &.{ embed_prefix, name });
+ try w.print(
+ " .{{ .name = \"{s}\", .contents = @embedFile(\"{s}\") }},\n",
+ .{ name, embed_path },
+ );
+ }
+
+ try w.writeAll("};\n");
+ try w.flush();
+}
diff --git a/build/gen_luarocks_embed.zig b/build/gen_luarocks_embed.zig
new file mode 100644
index 0000000..3f85177
--- /dev/null
+++ b/build/gen_luarocks_embed.zig
@@ -0,0 +1,157 @@
+//! Build-time codegen: walk the luarocks `src/` tree and emit a Zig
+//! module that exposes every Lua file as an `@embedFile` entry, with a
+//! comptime table mapping `require`-style module names to contents.
+//!
+//! Invoked from `build.zig`:
+//!
+//! gen-luarocks-embed <src-dir> <out-file>
+//!
+//! `<src-dir>` is `luarocks_src/src` (contains `luarocks/`, `compat53/`,
+//! and `bin/`). `<out-file>` is the Zig source to write.
+//!
+//! The generated file looks like:
+//!
+//! pub const Entry = struct { name: []const u8, contents: []const u8 };
+//! pub const files: []const Entry = &.{
+//! .{ .name = "luarocks.cmd", .contents = @embedFile("...") },
+//! ...
+//! };
+//! pub const luarocks_main: []const u8 = @embedFile("...");
+//! pub const luarocks_admin_main: []const u8 = @embedFile("...");
+//!
+//! The `@embedFile` paths are absolute paths into the Zig cache. That's
+//! safe because the codegen step depends on the luarocks dependency, so
+//! the cache entry exists by the time the generated source is compiled.
+
+const std = @import("std");
+
+pub fn main(init: std.process.Init) !void {
+ const arena = init.arena.allocator();
+ const io = init.io;
+
+ var args = init.minimal.args.iterate();
+ defer args.deinit();
+ _ = args.next(); // skip program name
+ const embed_prefix = args.next() orelse return error.MissingEmbedPrefix;
+ const src_dir_arg = args.next() orelse return error.MissingSrcDir;
+ const out_path_arg = args.next() orelse return error.MissingOutPath;
+
+ // Walk src/luarocks and src/compat53 collecting *.lua files; pick
+ // up src/bin/luarocks and src/bin/luarocks-admin separately.
+ var entries: std.array_list.Managed(Entry) = .init(arena);
+ try collect(arena, io, src_dir_arg, embed_prefix, "luarocks", &entries);
+ try collect(arena, io, src_dir_arg, embed_prefix, "compat53", &entries);
+
+ // Sort for deterministic output.
+ std.mem.sort(Entry, entries.items, {}, Entry.lessThan);
+
+ const bin_luarocks = try std.fs.path.join(arena, &.{ embed_prefix, "bin/luarocks" });
+ const bin_luarocks_admin = try std.fs.path.join(arena, &.{ embed_prefix, "bin/luarocks-admin" });
+
+ var out_file = try std.Io.Dir.cwd().createFile(io, out_path_arg, .{});
+ defer out_file.close(io);
+ var buf: [4096]u8 = undefined;
+ var writer = out_file.writer(io, &buf);
+ const w = &writer.interface;
+
+ try w.writeAll(
+ \\//! Auto-generated. Do not edit. See build/gen_luarocks_embed.zig.
+ \\
+ \\pub const Entry = struct {
+ \\ /// Lua `require` path, e.g. "luarocks.core.cfg".
+ \\ name: []const u8,
+ \\ /// File contents (Lua source).
+ \\ contents: []const u8,
+ \\};
+ \\
+ \\pub const files: []const Entry = &.{
+ \\
+ );
+
+ for (entries.items) |e| {
+ try w.print(
+ " .{{ .name = \"{s}\", .contents = @embedFile(\"{s}\") }},\n",
+ .{ e.require_name, e.embed_path },
+ );
+ }
+
+ try w.writeAll("};\n\n");
+ try w.print(
+ "pub const luarocks_main: []const u8 = @embedFile(\"{s}\");\n",
+ .{bin_luarocks},
+ );
+ try w.print(
+ "pub const luarocks_admin_main: []const u8 = @embedFile(\"{s}\");\n",
+ .{bin_luarocks_admin},
+ );
+
+ try w.flush();
+}
+
+const Entry = struct {
+ require_name: []const u8,
+ embed_path: []const u8,
+
+ fn lessThan(_: void, a: Entry, b: Entry) bool {
+ return std.mem.lessThan(u8, a.require_name, b.require_name);
+ }
+};
+
+/// Walk `<root>/<subdir>` recursively, collecting every `.lua` file. The
+/// `require` name is computed as the path relative to `<root>` with `/`
+/// replaced by `.` and `.lua` stripped, then `/init.lua` collapsed to
+/// the parent directory.
+fn collect(
+ arena: std.mem.Allocator,
+ io: std.Io,
+ root_abs: []const u8,
+ embed_prefix: []const u8,
+ subdir: []const u8,
+ out: *std.array_list.Managed(Entry),
+) !void {
+ const start_abs = try std.fs.path.join(arena, &.{ root_abs, subdir });
+ var dir = std.Io.Dir.cwd().openDir(io, start_abs, .{ .iterate = true }) catch |err| switch (err) {
+ error.FileNotFound => return,
+ else => return err,
+ };
+ defer dir.close(io);
+
+ var walker = try dir.walk(arena);
+ defer walker.deinit();
+
+ while (try walker.next(io)) |entry| {
+ if (entry.kind != .file) continue;
+ if (!std.mem.endsWith(u8, entry.basename, ".lua")) continue;
+
+ // `embed_path` lives under the staged tree (`<embed_prefix>/...`)
+ // so `@embedFile` can resolve it relative to the generated zig.
+ const embed_path = try std.fs.path.join(
+ arena,
+ &.{ embed_prefix, subdir, entry.path },
+ );
+ const rel = try std.mem.concat(arena, u8, &.{ subdir, "/", entry.path });
+
+ // Compute `require` name: drop ".lua", convert `/` to `.`.
+ //
+ // We deliberately do NOT collapse `/init.lua` into the parent
+ // module name. Lua's stock path searcher checks both
+ // `foo.lua` and `foo/init.lua` when you `require("foo")`, so
+ // the on-disk layout would resolve fine without our help —
+ // and luarocks's own tree contains both `luarocks/cmd.lua`
+ // (the package) and `luarocks/cmd/init.lua` (the subcommand
+ // module named `luarocks.cmd.init`). Collapsing would alias
+ // them onto one name. The embedded searcher serves whichever
+ // name appears literally in the source tree; the standard
+ // searcher behavior emerges from cmd.lua being a sibling.
+ const stem = rel[0 .. rel.len - ".lua".len];
+ const require_name = try arena.alloc(u8, stem.len);
+ for (stem, 0..) |ch, i| {
+ require_name[i] = if (ch == '/') '.' else ch;
+ }
+
+ try out.append(.{
+ .require_name = require_name,
+ .embed_path = embed_path,
+ });
+ }
+}
diff --git a/build/panto_lua_repl.c b/build/panto_lua_repl.c
new file mode 100644
index 0000000..ce99cb1
--- /dev/null
+++ b/build/panto_lua_repl.c
@@ -0,0 +1,49 @@
+/*
+ * panto's hook into the upstream `lua.c` standalone interpreter.
+ *
+ * We need to call into `pmain` (the body of the standalone REPL/driver)
+ * with a pre-configured `lua_State` — specifically, one where panto's
+ * embedded-luarocks searcher and configured `package.path` have already
+ * been installed. Upstream `pmain` is `static`, so we can't link to it
+ * from Zig directly.
+ *
+ * Trick: include `lua.c` verbatim with `static` redefined to empty, so
+ * every static gets external linkage. We rename `main` to a stub via
+ * macro so it doesn't conflict with panto's own `main`. Then we expose
+ * a single function, `panto_lua_pmain`, that runs `pmain` against a
+ * caller-provided state.
+ *
+ * `lua.c` also defines its own signal handlers via `setsignal` /
+ * `laction`. We leave those alone — they're useful in `panto lua` too,
+ * giving Ctrl-C the same behavior it has in upstream `lua`.
+ */
+
+/* Remove `static` everywhere lua.c uses it so symbols are externally
+ * accessible. We undef `static` back to default for any system headers
+ * that follow, in case they care. */
+#define static
+#include "lua.c"
+#undef static
+
+/* Forward declarations of the (now non-static) symbols we use. */
+extern int pmain(lua_State *L);
+
+/*
+ * Trampoline: push (argc, argv) onto `L` and run `pmain` in protected
+ * mode, mirroring `lua.c::main`'s call pattern. Returns the exit code
+ * appropriate for `lua` (0 on success, 1 on failure).
+ *
+ * The caller is responsible for state lifecycle (lua_close, etc.) —
+ * we don't open or close `L` ourselves. This is what lets panto's
+ * subcommand install the embedded-luarocks searcher into `L` *before*
+ * calling us.
+ */
+int panto_lua_pmain(lua_State *L, int argc, char **argv) {
+ lua_pushcfunction(L, &pmain);
+ lua_pushinteger(L, argc);
+ lua_pushlightuserdata(L, argv);
+ int status = lua_pcall(L, 2, 1, 0);
+ int result = lua_toboolean(L, -1);
+ report(L, status);
+ return (result && status == LUA_OK) ? 0 : 1;
+}