diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index 4ac216ae172..bb37521bbd3 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -122,9 +122,10 @@ build_lua_bind_cache() { LUA_BIND_DISPATCHER_MAP["$cache_key"]="$dispatcher" LUA_BIND_ARG_MAP["$cache_key"]="$arg" done < <( - # -E keeps LUA_INIT and LUA_PATH from the environment out of the scan, which - # loads the user's config. - lua -E - <<'LUA' + # A config the stub cannot satisfy must not leave a runaway lua behind after + # the menu closes, so the scan is time-boxed. -E keeps LUA_INIT and LUA_PATH + # from the environment out of the scan, which loads the user's config. + timeout 10 lua -E - <<'LUA' local modifiers = { SHIFT = 1, CTRL = 4, CONTROL = 4, ALT = 8, SUPER = 64 } local function split_keys(keys) @@ -227,14 +228,67 @@ local function dsp_proxy(path) }) end +-- Stand-in for every Hyprland value the config reads while we only want its +-- binds. It has to be safe for the loops and comparisons user configs run at +-- load time: a numeric index answers nil so `ipairs(hl.get_monitors())` +-- terminates instead of spinning a 100% CPU `lua` forever, and comparisons, +-- arithmetic, and concatenation degrade to harmless values instead of raising +-- and aborting the scan halfway through the config. local noop noop = setmetatable({}, { - __index = function() + __index = function(_, key) + if type(key) == "number" then + return nil + end + return noop end, __call = function() return noop end, + __len = function() + return 0 + end, + __lt = function() + return false + end, + __le = function() + return false + end, + __unm = function() + return 0 + end, + __add = function() + return 0 + end, + __sub = function() + return 0 + end, + __mul = function() + return 0 + end, + __div = function() + return 0 + end, + __mod = function() + return 0 + end, + __idiv = function() + return 0 + end, + __pow = function() + return 0 + end, + __concat = function(left, right) + if rawequal(left, noop) then + return tostring(right) + end + + return tostring(left) + end, + __tostring = function() + return "" + end, }) hl = setmetatable({ @@ -609,7 +663,7 @@ output_binding_records_uncached() { keybindings_cache_key() { { - printf 'v17\n' + printf 'v18\n' leading_keyboard omarchy-language 2>/dev/null cat "$OMARCHY_PATH/default/i18n/$(omarchy-language 2>/dev/null)/keybindings.json" 2>/dev/null diff --git a/test/shell.d/menu-keybindings-lua-scan-test.sh b/test/shell.d/menu-keybindings-lua-scan-test.sh new file mode 100755 index 00000000000..71367d11dcd --- /dev/null +++ b/test/shell.d/menu-keybindings-lua-scan-test.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command lua + +# The keybindings menu learns about Lua-only binds by running the user's +# hyprland.lua under a stub `hl`. That stub has to survive whatever a config +# does at load time, or the scan hangs (one user config spun a `lua` at 100% +# CPU for 27 hours after a single SUPER+K) or aborts halfway, silently dropping +# every bind declared after the failing line. + +script="$ROOT/bin/omarchy-menu-keybindings" +scan=$(awk '/lua (-E - )?<<.LUA.$/ { capture = 1; next } /^LUA$/ { capture = 0 } capture' "$script") +[[ -n $scan ]] || fail "keybindings menu embeds the Lua bind scan" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +mkdir -p "$TMPDIR/home/.config/hypr" +cat >"$TMPDIR/home/.config/hypr/hyprland.lua" <<'LUA' +-- Load-time reads a real config does against the compositor state. +for _, monitor in ipairs(hl.get_monitors()) do + hl.monitor({ output = monitor.name, scale = monitor.scale }) +end + +local monitor = hl.get_active_monitor() +if not monitor or not monitor.scale or monitor.scale <= 0 then + error("comparison should not raise") +end +local reserved = monitor.height - monitor.reserved.top * monitor.scale +local label = "monitor " .. monitor.name .. " " .. tostring(monitor.scale) +if #hl.get_workspaces() ~= 0 then + error("stub collections should be empty") +end +if monitor.scale > 1 then + error("stub comparisons should be false") +end + +hl.bind("SUPER + K", hl.dsp.exec_cmd("omarchy-menu keybindings"), { description = "Keybindings menu" }) +hl.bind("SUPER + code:20", hl.dsp.workspace({ name = "special" }), { description = "Lua only bind" }) +LUA + +start=$(date +%s) +output=$(HOME="$TMPDIR/home" DEBUG=1 timeout 10 lua - <<<"$scan" 2>&1) || fail "Lua bind scan exits cleanly on a config that iterates and compares compositor state (output: $output)" +(( $(date +%s) - start < 5 )) || fail "Lua bind scan terminates promptly" +pass "Lua bind scan survives load-time loops, comparisons, and arithmetic over stub state" + +[[ $output != *"lua bind scan failed"* ]] || fail "Lua bind scan does not abort on stub comparisons ($output)" +grep -qF $'64\tKeybindings menu\tK\texec\tomarchy-menu keybindings' <<<"$output" || fail "Lua bind scan reports exec binds declared after load-time reads (got: $output)" +grep -qF $'64\tLua only bind\tcode:20\tlua\thl.dsp.workspace({ name = "special" })' <<<"$output" || fail "Lua bind scan reports Lua dispatcher binds (got: $output)" +pass "Lua bind scan reports binds declared after load-time compositor reads" + +grep -q "timeout 10 lua -E - <<'LUA'" "$script" || fail "Lua bind scan is time-boxed" +pass "Lua bind scan is time-boxed"