Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Stage 28 — Lua Configuration

What it is. Replacing the line-based 0xin.conf parser with a real configuration language: ~/.config/0xin/init.lua, run by an embedded interpreter, with keybindings that can be Lua functions, handlers that fire on compositor events, and a neovim-shaped plugin path.

Deliverable. 0xin is configured and scripted in Lua. The old format and its parser are gone.

Why

The .conf parser worked, and its limits were structural rather than cosmetic. It was a closed vocabulary: every new capability meant a new key, a new parse arm and a new test. A config could not compute — nine workspaces meant eighteen near-identical lines, a binding could not depend on whether a program was installed, and there was no way to remove a built-in binding at all. Behaviour that wasn’t already an Action variant was unreachable.

The interpreter

luna — a stackless, fuel-bounded Lua 5.4 interpreter in pure Rust, a hard fork of piccolo. Chosen over mlua mainly because it is pure Rust with no FFI, in a project whose only other runtime dependency was an image decoder, and because “runs untrusted scripts under a budget” is exactly the property a compositor config needs.

The honest cost: the lockfile went from 47 crates to 70, and luna is pre-1.0 with breaking changes expected on minor bumps. The mitigation is a hard boundary — every luna type stays inside src/lua/, and the rest of the compositor sees only plain Rust (LuaLink, WindowRecord, Action::Lua(u32)). A breaking bump is a change to one directory.

The API

Assign the settings, register the behaviour, return nothing.

local oxin = require("oxin")

oxin.gap = 4                                          -- assigned
oxin.keys["MOD+Return"] = oxin.action.spawn("kitty")  -- registered
oxin.on.window(function(w)                            -- reacted to
  if w.app_id == "pavucontrol" then return { float = true } end
end)

Settings are assigned rather than collected into a table the host merges, so a setting the config never mentions is left alone — which is what keeps OXIN_MOD and the built-in defaults working. The usual cost of that style is that a typo (oxin.gpa = 4) is silently ignored, because a host generally cannot tell one from a user stashing a helper on the module. 0xin owns the whole namespace, so it does not accept that: every write goes through __newindex, which raises and names the nearest real setting. luna’s set_intercept_all_writes is what makes that fire for re-assignment too — stock Lua fires __newindex only for absent keys.

The four registrars (keys, hold, gestures, monitors) are keyed by the thing they are about, so binding a chord twice replaces rather than stacks and "MOD+SHIFT+q" and "SHIFT+MOD+q" land on the same entry. That is the same merge-never-replace rule the .conf format had, now structural. nil removes a binding, which the old format could not express.

Three events — on.startup, on.window, on.workspace — deliberately few. The codebase offers about thirteen plausible firing points; inventing a taxonomy up front is the usual failure. on.window is the one that influences: nil means “not mine”, a table means “do this instead”. It subsumes what float = <app_id> used to do and is strictly more capable.

exec_once is gone too, and did not need a replacement feature — once the config is a list of statements, the terse form is four lines of the user’s own Lua:

local function autostart(cmd)
  oxin.on.startup(function() oxin.spawn(cmd) end)
end

What was harder than expected

luna’s own run-to-completion helpers cannot bound anything. Lua::finish refills its fuel every iteration, so it loops forever on while true do end; Lua::execute is finish plus a result. Calling either from the keyboard handler would wedge the compositor permanently on a config typo. The host has to own the loop over Executor::step, where running out of fuel is resumable rather than an error — that is what makes stopping on our own terms possible. The real guarantee is a wall-clock deadline, not the fuel number: fuel counts instructions, and one string.rep("x", 1e9) can blow the clock inside a single slice.

Errors from native callbacks carry no position. luna positions errors raised by Lua (init.lua:3: boom) but not ones a Rust callback returns, and a config error with no line number is the one thing a config author most needs. The calling frame knows both, so 0xin prefixes it — with one correction: current_line is attributed to the instruction about to run, and luna only steps back for a Call opcode. __newindex fires on a store, so a failed assignment on line 3 reported line 4 until the lookup was done against the store opcode itself.

Syntax errors carry no filename at all, whatever chunk name you pass — ParseError’s message is just parse error at line N. Tolerable for one config and useless once plugins are on a runtimepath, so the loader prepends the path.

Lua cannot live inside Server. Server’s address is handed to the C shim and every callback rebuilds &mut Server from it. A lua.enter(...) through a Server field would be a reborrow covering the whole struct, invalidating that pointer’s provenance. The VM and its registry are separate main locals; Server carries only a Copy pair of raw pointers, and copying that field out ends the borrow so the three can be held at once.

Reaching &mut Server from a 'static callback is a thread-local parked pointer with a re-entry flag beside it, so the one realistic way to get it wrong — a host function calling another while it still holds the &mut — comes back as a catchable Lua error instead of undefined behaviour. The type is checked on pickup too, which is what caught the first wiring mistake.

Worth recording, because the plan for this stage assumed otherwise: luna does not forbid Rust→Lua nesting. It ships tests/reentrancy.rs proving a nested executor works. Handler dispatch is still deferred, but for compositor reasons — the aliasing invariant above, deterministic handler order, and a clean per-handler budget — not because the VM prohibits it.

Failure policy

init.lua raising aborts the whole load; a plugin raising is reported and the others still load. The asymmetry is deliberate: carrying on with a half-read user config silently applies settings nobody asked for, while dropping one plugin is better than dropping the session.

Neither is fatal to startup. Assignments land on a staged Config adopted only if the chunk reaches its end, so there is no path on which a partly-applied config reaches the compositor — “0xin always starts” became a structural property rather than a discipline. The message is kept for 0xinctl config-error, because a session that silently reverted to defaults is otherwise indistinguishable from one that loaded fine.

Sandbox

Lua::core() plus package, not Lua::full(). Three entries in the fuller set are disqualifying for a compositor: os.exit calls std::process::exit directly, killing the session and skipping client teardown; os.execute blocks the whole compositor for the child’s lifetime and bypasses the spawn path that resets child signals; debug.setlocal lets a script rewrite its caller’s variables. dofile, loadfile and load are removed as well — loading chunks is the host’s job, with a name and a budget. core() ships no print either, so 0xin installs one that logs to stderr in the compositor’s own style.

Plugins

neovim’s model, reduced: an ordered runtimepath rather than one directory, plugin/ auto-run and lua/ require-only, after/ last, alphabetical within a directory, packages as plain directories under pack/*/start/*. Deviating buys nothing and costs everyone the transfer — most people arriving here have already learned it. --noplugin exists because the first question when a compositor misbehaves is “is it me or a plugin?”, and a tool with no way to start without them makes that unanswerable.

Status

Done. The .conf parser, its tests, 0xin.conf.example and the checked-in profile config are gone; init.lua.example and the fp5 profile’s init.lua replace them. Still open: the control socket’s wire is still line-based and one-request-per-connection, so exposing a named subset of the Lua API to sibling tools is a separate piece of work.