-- ffi/readline.lua — GNU readline binding. -- Phase 0: readline + add_history + EOF handling. -- Phase 1: custom key bindings via rl_bind_keyseq. -- Phase 3: rl_insert_text + rl_redisplay so bound key handlers can -- stuff text into the in-progress line buffer (used by \C-n -- to insert ":norris " in repl.lua). -- See docs/PHASE0.md §9 and docs/PHASE1.md §7 and docs/PHASE3.md §3. local ffi = require("ffi") ffi.cdef[[ char *readline(const char *prompt); void add_history(const char *line); void free(void *ptr); typedef int (*rl_command_func_t)(int, int); int rl_bind_keyseq(const char *keyseq, rl_command_func_t function); int rl_insert_text(const char *text); int rl_redisplay(void); ]] -- libreadline-dev (which ships the unversioned `libreadline.so` symlink) is -- not assumed to be installed on the runtime host; fall back to versioned -- sonames so a base Debian/Arch with just libreadline runtime works. local function load_readline() local errs = {} for _, name in ipairs({"readline", "readline.so.8", "readline.so.7"}) do local ok, lib = pcall(ffi.load, name) if ok then return lib end errs[#errs+1] = name .. ": " .. tostring(lib) end error("libreadline not loadable: " .. table.concat(errs, "; ")) end local rl = load_readline() local C = ffi.C local M = {} -- Read one line of input. -- Returns: -- string : the line (no trailing newline) -- nil : EOF (Ctrl-D on empty line) function M.readline(prompt) local cstr = rl.readline(prompt) if cstr == nil then return nil end local s = ffi.string(cstr) C.free(cstr) return s end -- Append a non-empty line to readline's in-memory history. function M.add_history(line) if line and #line > 0 then rl.add_history(line) end end -- Bind `seq` (e.g. "\\C-n") to a Lua function that runs when the user types -- that key sequence at the readline prompt. The Lua fn takes no arguments -- (readline passes count + key, but consumers don't need them). -- Callback trampolines are pinned in module-local state for process -- lifetime. We do NOT free the previous binding on rebind: readline -- retains the function pointer in its keymap, and the window between -- :free() and the new rl_bind_keyseq is a potential use-after-free. -- Memory cost is bounded — one closure per bound key sequence. -- (Phase 3 R-C4 fold-in.) -- `_pinned` keeps every callback ever cast alive for process lifetime -- (so readline's keymap pointers never dangle even after a re-bind). -- `_bound` indexes by seq for "what's currently bound here" lookup but -- both old and new closures stay reachable via _pinned. local _bound = {} local _pinned = {} function M.bind(seq, fn) local cb = ffi.cast("rl_command_func_t", function(_count, _key) local ok, err = pcall(fn) if not ok then io.stderr:write("ffi/readline bind handler error: " .. tostring(err) .. "\n") end return 0 end) _pinned[#_pinned + 1] = cb -- never freed; bounded by N rebinds local rc = rl.rl_bind_keyseq(seq, cb) _bound[seq] = cb return rc == 0 end -- Insert `text` at the cursor in the in-progress readline buffer. -- Used by bound key handlers to stuff e.g. ":norris " into the line. -- Caller typically follows with M.redisplay() to refresh the display. function M.insert_text(text) if text and text ~= "" then rl.rl_insert_text(text) end end -- Force readline to redraw the current line. Call after insert_text or -- any other buffer mutation from inside a bound handler. function M.redisplay() rl.rl_redisplay() end return M