fix(keymap): prevent Niri and Hyprland CPU-budget failures (1.3.4) (#135)
* fix(keymap): reduce Niri parser CPU usage * test(keymap): cover Niri parser scan budget * fix(keymap): keep Hyprland refreshes within CPU budget * chore(keymap): bump release to 1.3.4
This commit is contained in:
@@ -2,6 +2,30 @@
|
||||
|
||||
All notable changes to Keymap are documented in this file.
|
||||
|
||||
## [1.3.4] - 2026-07-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented Niri startup CPU-budget failures on large, split configurations.
|
||||
- Prevented Hyprland refreshes from exhausting the callback CPU budget and
|
||||
leaving the shortcut snapshot stuck in its loading state.
|
||||
- Restored editing for literal native-Lua binds after the optimized refresh.
|
||||
|
||||
### Changed
|
||||
|
||||
- Skipped the hidden-bind scan for files without Keymap hidden sentinels.
|
||||
- Skipped character-level parsing for included files that cannot contain binds
|
||||
or nested includes.
|
||||
- Added fast paths for common Hyprland Lua literals and dispatcher expressions.
|
||||
- Switched active Hyprland source validation to exact snippet comparison while
|
||||
retaining legacy fingerprints for hidden blocks and older snapshots.
|
||||
|
||||
### Tests
|
||||
|
||||
- Added a 127-bind split-config regression with a large unrelated include.
|
||||
- Added a split native-Lua regression covering invalid Hyprland JSON fallback,
|
||||
callback instruction budgets, and editable source provenance.
|
||||
|
||||
## [1.3.1] - 2026-07-21
|
||||
|
||||
### Fixed
|
||||
|
||||
+30
-15
@@ -744,26 +744,41 @@ local function parseConfig(root, rootSource)
|
||||
local currentBind = nil
|
||||
local slashdashDepth = nil
|
||||
local lineNumber = 0
|
||||
local hiddenLines = sourceLines(source)
|
||||
local hiddenCategory = nil
|
||||
local hiddenCursor = 1
|
||||
while hiddenCursor <= #hiddenLines do
|
||||
local block, blockEnd, candidate = hiddenBlockAt(hiddenLines, hiddenCursor)
|
||||
if candidate then
|
||||
if block == nil then
|
||||
warn("hidden_block_invalid:" .. path .. ":" .. tostring(hiddenCursor))
|
||||
-- Hidden sentinels are uncommon. Avoid a second character-by-character
|
||||
-- pass over every ordinary config: on large stock Niri configs that pass
|
||||
-- alone can consume a substantial part of Luau's callback CPU budget.
|
||||
if source:find("// Keymap hidden ", 1, true) ~= nil then
|
||||
local hiddenLines = sourceLines(source)
|
||||
local hiddenCategory = nil
|
||||
local hiddenCursor = 1
|
||||
while hiddenCursor <= #hiddenLines do
|
||||
local block, blockEnd, candidate = hiddenBlockAt(hiddenLines, hiddenCursor)
|
||||
if candidate then
|
||||
if block == nil then
|
||||
warn("hidden_block_invalid:" .. path .. ":" .. tostring(hiddenCursor))
|
||||
else
|
||||
hidden[#hidden + 1] = hiddenTarget(block, path, hiddenCursor, blockEnd, hiddenCategory)
|
||||
end
|
||||
hiddenCursor = blockEnd + 1
|
||||
else
|
||||
hidden[#hidden + 1] = hiddenTarget(block, path, hiddenCursor, blockEnd, hiddenCategory)
|
||||
local _, comment = stripComments(hiddenLines[hiddenCursor], false)
|
||||
local label = type(comment) == "string" and comment:match('^%s*#?%s*"([^\"]+)"%s*$') or nil
|
||||
if label ~= nil and label ~= "" then hiddenCategory = label end
|
||||
hiddenCursor = hiddenCursor + 1
|
||||
end
|
||||
hiddenCursor = blockEnd + 1
|
||||
else
|
||||
local _, comment = stripComments(hiddenLines[hiddenCursor], false)
|
||||
local label = type(comment) == "string" and comment:match('^%s*#?%s*"([^\"]+)"%s*$') or nil
|
||||
if label ~= nil and label ~= "" then hiddenCategory = label end
|
||||
hiddenCursor = hiddenCursor + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Most split Niri setups also include theme, output, input, and window
|
||||
-- rule files. Once hidden sentinels have been handled, a file without
|
||||
-- either keyword cannot contribute binds or extend the include graph.
|
||||
-- This cheap rejection keeps unrelated config files out of the parser's
|
||||
-- character-level brace/comment scan.
|
||||
if source:find("binds", 1, true) == nil and source:find("include", 1, true) == nil then
|
||||
visiting[path] = nil
|
||||
return
|
||||
end
|
||||
|
||||
local function finishBind()
|
||||
local action = contentBeforeOuterClose(table.concat(currentBind.parts, "\n"))
|
||||
parseBind(
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
id = "blackbartblues/keymap"
|
||||
name = "Keymap"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
plugin_api = 9
|
||||
author = "blackbartblues"
|
||||
license = "MIT"
|
||||
|
||||
+113
-70
@@ -10,6 +10,7 @@ local MAX_LUA_FILES = 64
|
||||
local MAX_SOURCE_BYTES = 512 * 1024
|
||||
local MAX_HIDDEN_BYTES = 2 * 1024 * 1024
|
||||
local HYPRCTL_TIMEOUT_MS = 5000
|
||||
local EXACT_SOURCE_FINGERPRINT = "exact-v1"
|
||||
|
||||
local refreshing = false
|
||||
local refreshQueued = false
|
||||
@@ -194,6 +195,15 @@ local function assignmentLiteral(line, field)
|
||||
if quote ~= '"' and quote ~= "'" then
|
||||
return nil, false
|
||||
end
|
||||
local simpleValue, simpleTail
|
||||
if quote == '"' then
|
||||
simpleValue, simpleTail = literal:match('^"([^"\\]*)"(.*)$')
|
||||
else
|
||||
simpleValue, simpleTail = literal:match("^'([^'\\]*)'(.*)$")
|
||||
end
|
||||
if simpleValue ~= nil then
|
||||
return simpleValue, simpleTail:match("^%s*%.%.") ~= nil
|
||||
end
|
||||
|
||||
local out = {}
|
||||
local escaped = false
|
||||
@@ -255,6 +265,13 @@ local function generatedCommand(line)
|
||||
local suffix = line:sub(endIndex + 1)
|
||||
local quote = suffix:sub(1, 1)
|
||||
if quote == '"' or quote == "'" then
|
||||
local simpleValue
|
||||
if quote == '"' then
|
||||
simpleValue = suffix:match('^"([^"\\]*)"')
|
||||
else
|
||||
simpleValue = suffix:match("^'([^'\\]*)'")
|
||||
end
|
||||
if simpleValue ~= nil then return simpleValue end
|
||||
local out = {}
|
||||
local escaped = false
|
||||
for index = 2, #suffix do
|
||||
@@ -281,6 +298,8 @@ local function generatedCommand(line)
|
||||
end
|
||||
|
||||
local function generatedAction(line)
|
||||
local balanced = line:match("(hl%.dsp%.[%w_%.]+%b())")
|
||||
if balanced ~= nil then return balanced end
|
||||
local startIndex = line:find("hl%.dsp%.")
|
||||
if startIndex == nil then return nil end
|
||||
local quote, escaped, depth, opened = nil, false, 0, false
|
||||
@@ -303,9 +322,9 @@ local function generatedAction(line)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function xorByte(left, right)
|
||||
local function xorNibbleSlow(left, right)
|
||||
local result, place = 0, 1
|
||||
for _ = 1, 8 do
|
||||
for _ = 1, 4 do
|
||||
if left % 2 ~= right % 2 then result = result + place end
|
||||
left = math.floor(left / 2)
|
||||
right = math.floor(right / 2)
|
||||
@@ -314,6 +333,19 @@ local function xorByte(left, right)
|
||||
return result
|
||||
end
|
||||
|
||||
local xorNibbles = {}
|
||||
for left = 0, 15 do
|
||||
xorNibbles[left] = {}
|
||||
for right = 0, 15 do
|
||||
xorNibbles[left][right] = xorNibbleSlow(left, right)
|
||||
end
|
||||
end
|
||||
|
||||
local function xorByte(left, right)
|
||||
return xorNibbles[left % 16][right % 16]
|
||||
+ xorNibbles[math.floor(left / 16)][math.floor(right / 16)] * 16
|
||||
end
|
||||
|
||||
local xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte
|
||||
|
||||
local function fingerprint(value)
|
||||
@@ -419,6 +451,7 @@ local function scanLuaSources(rootPath)
|
||||
warnings[#warnings + 1] = "lua_source_truncated"
|
||||
end
|
||||
|
||||
local containsHidden = source:find("-- Keymap hidden ", 1, true) ~= nil
|
||||
local currentCategory = nil
|
||||
local pendingBindCategory = nil
|
||||
local pendingMarkerLine = nil
|
||||
@@ -427,7 +460,10 @@ local function scanLuaSources(rootPath)
|
||||
local lineNumber = 1
|
||||
while lineNumber <= #lines do
|
||||
local line = lines[lineNumber]
|
||||
local block, blockEnd, candidate = hiddenBlockAt(lines, lineNumber)
|
||||
local block, blockEnd, candidate = nil, lineNumber, false
|
||||
if containsHidden then
|
||||
block, blockEnd, candidate = hiddenBlockAt(lines, lineNumber)
|
||||
end
|
||||
if candidate then
|
||||
if block == nil then
|
||||
warnings[#warnings + 1] = "hidden_block_invalid:" .. path .. ":" .. tostring(lineNumber)
|
||||
@@ -467,81 +503,88 @@ local function scanLuaSources(rootPath)
|
||||
lineNumber = blockEnd + 1
|
||||
else
|
||||
local bindCategory = line:match(
|
||||
"^%s*%-%-%s*Keymap bind%-category:%s*(.-)%s*$"
|
||||
)
|
||||
local header = line:match("^%s*%-%-%s*%d+%.%s*(.-)%s*$")
|
||||
if bindCategory ~= nil and bindCategory ~= "" then
|
||||
pendingBindCategory = bindCategory
|
||||
pendingMarkerLine = lineNumber
|
||||
pendingMarkerRaw = line
|
||||
elseif header ~= nil and header ~= "" then
|
||||
currentCategory = header
|
||||
appendUnique(headers, headerSeen, header)
|
||||
elseif not line:match("^%s*%-%-") then
|
||||
local modules = requiredModules(line)
|
||||
for _, moduleName in ipairs(modules) do
|
||||
local requiredPath = modulePath(path, moduleName)
|
||||
if requiredPath ~= nil and not queued[requiredPath] and noctalia.fileExists(requiredPath) then
|
||||
queued[requiredPath] = true
|
||||
queue[#queue + 1] = requiredPath
|
||||
end
|
||||
end
|
||||
|
||||
local effectiveCategory = pendingBindCategory or currentCategory
|
||||
if effectiveCategory ~= nil then
|
||||
local description, dynamic = assignmentLiteral(line, "description")
|
||||
if description == nil then
|
||||
description, dynamic = assignmentLiteral(line, "desc")
|
||||
end
|
||||
if description ~= nil and trim(description) ~= "" then
|
||||
if dynamic then
|
||||
local prefixKey = description .. "\0" .. effectiveCategory
|
||||
if not prefixSeen[prefixKey] then
|
||||
prefixSeen[prefixKey] = true
|
||||
prefixes[#prefixes + 1] = {
|
||||
prefix = description,
|
||||
category = effectiveCategory,
|
||||
}
|
||||
"^%s*%-%-%s*Keymap bind%-category:%s*(.-)%s*$"
|
||||
)
|
||||
local header = line:match("^%s*%-%-%s*%d+%.%s*(.-)%s*$")
|
||||
if bindCategory ~= nil and bindCategory ~= "" then
|
||||
pendingBindCategory = bindCategory
|
||||
pendingMarkerLine = lineNumber
|
||||
pendingMarkerRaw = line
|
||||
elseif header ~= nil and header ~= "" then
|
||||
currentCategory = header
|
||||
appendUnique(headers, headerSeen, header)
|
||||
elseif not line:match("^%s*%-%-") then
|
||||
if line:find("require", 1, true) ~= nil then
|
||||
local modules = requiredModules(line)
|
||||
for _, moduleName in ipairs(modules) do
|
||||
local requiredPath = modulePath(path, moduleName)
|
||||
if requiredPath ~= nil and not queued[requiredPath]
|
||||
and noctalia.fileExists(requiredPath) then
|
||||
queued[requiredPath] = true
|
||||
queue[#queue + 1] = requiredPath
|
||||
end
|
||||
elseif exactCategories[description] == nil then
|
||||
exactCategories[description] = effectiveCategory
|
||||
end
|
||||
if keySequences[description] == nil then
|
||||
keySequences[description] = multiKeySequence(line)
|
||||
end
|
||||
|
||||
local effectiveCategory = pendingBindCategory or currentCategory
|
||||
local hasBind = line:find("hl.bind", 1, true) ~= nil
|
||||
if effectiveCategory ~= nil and hasBind then
|
||||
local description, dynamic = assignmentLiteral(line, "description")
|
||||
if description == nil then
|
||||
description, dynamic = assignmentLiteral(line, "desc")
|
||||
end
|
||||
if origins[description] == nil then
|
||||
if description ~= nil and trim(description) ~= "" then
|
||||
local command = generatedCommand(line)
|
||||
local action = generatedAction(line)
|
||||
local rawSnippet = pendingMarkerRaw ~= nil
|
||||
and (pendingMarkerRaw .. "\n" .. line) or line
|
||||
origins[description] = {
|
||||
source = path,
|
||||
line = pendingMarkerLine or lineNumber,
|
||||
start_line = pendingMarkerLine or lineNumber,
|
||||
end_line = lineNumber,
|
||||
managed = path:match("([^/]+)$") == "keymap.lua",
|
||||
raw_snippet = rawSnippet,
|
||||
fingerprint = fingerprint(rawSnippet),
|
||||
action = action,
|
||||
capabilities = {
|
||||
combo = not dynamic,
|
||||
category = not dynamic,
|
||||
description = not dynamic,
|
||||
command = not dynamic and command ~= nil,
|
||||
activation = not dynamic,
|
||||
},
|
||||
}
|
||||
if dynamic then
|
||||
local prefixKey = description .. "\0" .. effectiveCategory
|
||||
if not prefixSeen[prefixKey] then
|
||||
prefixSeen[prefixKey] = true
|
||||
prefixes[#prefixes + 1] = {
|
||||
prefix = description,
|
||||
category = effectiveCategory,
|
||||
}
|
||||
end
|
||||
elseif exactCategories[description] == nil then
|
||||
exactCategories[description] = effectiveCategory
|
||||
end
|
||||
if keySequences[description] == nil then
|
||||
keySequences[description] = multiKeySequence(line)
|
||||
end
|
||||
if origins[description] == nil then
|
||||
local rawSnippet = pendingMarkerRaw ~= nil
|
||||
and (pendingMarkerRaw .. "\n" .. line) or line
|
||||
origins[description] = {
|
||||
source = path,
|
||||
line = pendingMarkerLine or lineNumber,
|
||||
start_line = pendingMarkerLine or lineNumber,
|
||||
end_line = lineNumber,
|
||||
managed = path:match("([^/]+)$") == "keymap.lua",
|
||||
raw_snippet = rawSnippet,
|
||||
-- The writer verifies this complete snippet byte-for-byte at
|
||||
-- the recorded line range. Avoid hashing every bind inside the
|
||||
-- service's tightly budgeted refresh callback.
|
||||
fingerprint = EXACT_SOURCE_FINGERPRINT,
|
||||
action = action,
|
||||
capabilities = {
|
||||
combo = not dynamic,
|
||||
category = not dynamic,
|
||||
description = not dynamic,
|
||||
command = not dynamic and command ~= nil,
|
||||
activation = not dynamic,
|
||||
},
|
||||
}
|
||||
end
|
||||
if commands[description] == nil then commands[description] = command end
|
||||
if actions[description] == nil then actions[description] = action end
|
||||
end
|
||||
if commands[description] == nil then commands[description] = generatedCommand(line) end
|
||||
if actions[description] == nil then actions[description] = generatedAction(line) end
|
||||
end
|
||||
if hasBind then
|
||||
pendingBindCategory = nil
|
||||
pendingMarkerLine = nil
|
||||
pendingMarkerRaw = nil
|
||||
end
|
||||
end
|
||||
if line:find("hl.bind", 1, true) ~= nil then
|
||||
pendingBindCategory = nil
|
||||
pendingMarkerLine = nil
|
||||
pendingMarkerRaw = nil
|
||||
end
|
||||
end
|
||||
lineNumber = lineNumber + 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,10 +59,13 @@ assert all(";" not in entry["template"] and "{" not in re.sub(r"\{\{[^}]+\}\}",
|
||||
assert all("#" not in entry["template"]
|
||||
for entry in entries if entry["source"] == "mangowc")
|
||||
|
||||
# The retained UI deliberately renders only a small result window and removes
|
||||
# callbacks that are no longer part of the current render.
|
||||
# The retained UI deliberately renders only a small result window and passes
|
||||
# closures directly instead of registering callback names in the script global
|
||||
# environment. Keep this assertion aligned with the plugin API 9 callback form.
|
||||
assert "local visibleCount = math.min(#matches, 6)" in panel
|
||||
assert "finishDynamicCallbackRender()" in panel
|
||||
assert 'registerDynamicCallback(callbackName' in panel
|
||||
assert "local selectedEntry = entry" in panel
|
||||
assert "onClick = useCallback" in panel
|
||||
assert "finishDynamicCallbackRender()" not in panel
|
||||
assert "registerDynamicCallback(" not in panel
|
||||
|
||||
print(f"command library tests: ok ({len(entries)} entries: {dict(counts)})")
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
local rootPath = "/fixture/hypr/hyprland.lua"
|
||||
local files = {}
|
||||
|
||||
local rootLines = {
|
||||
"-- Hyprland configuration",
|
||||
'require("keybind")',
|
||||
'require("colors")',
|
||||
'require("noctalia").apply_theme()',
|
||||
'require("keymap")',
|
||||
}
|
||||
for index = 1, 120 do
|
||||
rootLines[#rootLines + 1] = string.format("hl.config({ value_%d = %d })", index, index)
|
||||
end
|
||||
files[rootPath] = table.concat(rootLines, "\n")
|
||||
|
||||
local bindLines, bindDescriptions = {}, {}
|
||||
for index = 1, 60 do
|
||||
if index % 15 == 1 then
|
||||
bindLines[#bindLines + 1] = "-- " .. tostring(math.floor(index / 15) + 1) .. ". Group"
|
||||
end
|
||||
local description = "Action " .. tostring(index)
|
||||
bindDescriptions[#bindDescriptions + 1] = description
|
||||
bindLines[#bindLines + 1] = string.format(
|
||||
'hl.bind("SUPER + Key%d", hl.dsp.exec_cmd("command-%d"), { description = "%s" })',
|
||||
index, index, description
|
||||
)
|
||||
end
|
||||
files["/fixture/hypr/keybind.lua"] = table.concat(bindLines, "\n")
|
||||
|
||||
local unrelated = {}
|
||||
for index = 1, 100 do
|
||||
unrelated[#unrelated + 1] = string.format("local value_%d = %d", index, index)
|
||||
end
|
||||
files["/fixture/hypr/colors.lua"] = table.concat(unrelated, "\n")
|
||||
files["/fixture/hypr/noctalia.lua"] = table.concat(unrelated, "\n")
|
||||
files["/fixture/hypr/keymap.lua"] = table.concat({
|
||||
"-- Managed by Noctalia Keymap.",
|
||||
"-- 5. Managed",
|
||||
'hl.bind("CTRL + grave", hl.dsp.exec_cmd("managed-command"), { description = "Managed action" })',
|
||||
}, "\n")
|
||||
bindDescriptions[#bindDescriptions + 1] = "Managed action"
|
||||
|
||||
local textRecords = {}
|
||||
for index, description in ipairs(bindDescriptions) do
|
||||
textRecords[#textRecords + 1] = table.concat({
|
||||
"bindd",
|
||||
"\tmodmask: 64",
|
||||
"\tsubmap: ",
|
||||
"\tkey: Key" .. tostring(index),
|
||||
"\tkeycode: 0",
|
||||
"\tcatchall: false",
|
||||
"\tdescription: " .. description,
|
||||
"\tdispatcher: __lua",
|
||||
"\targ: " .. tostring(index),
|
||||
"",
|
||||
}, "\n")
|
||||
end
|
||||
local textOutput = table.concat(textRecords, "\n")
|
||||
|
||||
local values, watchers = {}, {}
|
||||
local instructionBlocks, firstAsyncInstructionBlocks = 0, nil
|
||||
noctalia = {
|
||||
getConfig = function(key)
|
||||
return ({
|
||||
compositor = "hyprland",
|
||||
hyprland_config = rootPath,
|
||||
merge_sequential = false,
|
||||
show_undescribed = true,
|
||||
})[key]
|
||||
end,
|
||||
getenv = function(key) return key == "HYPRLAND_INSTANCE_SIGNATURE" and "fixture" or "" end,
|
||||
expandPath = function(path) return path end,
|
||||
fileExists = function(path) return files[path] ~= nil end,
|
||||
listDir = function() return nil end,
|
||||
readFile = function(path) return files[path] end,
|
||||
commandExists = function(command) return command == "hyprctl" end,
|
||||
json = { decode = function() error("Hyprland emitted invalid JSON") end },
|
||||
runAsync = function(command, callback)
|
||||
if firstAsyncInstructionBlocks == nil then firstAsyncInstructionBlocks = instructionBlocks end
|
||||
callback({
|
||||
exitCode = 0,
|
||||
timedOut = false,
|
||||
stdout = command == "hyprctl binds -j" and "{invalid-json" or textOutput,
|
||||
})
|
||||
return true
|
||||
end,
|
||||
tr = function(key)
|
||||
if key == "category.other" then return "Other" end
|
||||
if key == "category.undescribed" then return "Without description" end
|
||||
return key
|
||||
end,
|
||||
state = {
|
||||
get = function(key) return values[key] end,
|
||||
set = function(key, value)
|
||||
values[key] = value
|
||||
if watchers[key] ~= nil then watchers[key](value) end
|
||||
end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
},
|
||||
}
|
||||
|
||||
debug.sethook(function() instructionBlocks = instructionBlocks + 1 end, "", 1000)
|
||||
assert(loadfile("service.luau"))()
|
||||
debug.sethook()
|
||||
local initialInstructionBlocks = instructionBlocks
|
||||
instructionBlocks, firstAsyncInstructionBlocks = 0, nil
|
||||
debug.sethook(function() instructionBlocks = instructionBlocks + 1 end, "", 1000)
|
||||
watchers["keymap.refresh_request"](1)
|
||||
debug.sethook()
|
||||
local refreshInstructionBlocks = instructionBlocks
|
||||
local refreshScanInstructionBlocks = firstAsyncInstructionBlocks
|
||||
|
||||
local snapshot = values["keymap.snapshot"]
|
||||
assert(snapshot.status == "ready", "split Hyprland fallback did not publish a ready snapshot")
|
||||
assert(snapshot.total == #bindDescriptions, "split Hyprland fallback lost binds")
|
||||
|
||||
local editable = 0
|
||||
for _, category in ipairs(snapshot.categories or {}) do
|
||||
for _, bind in ipairs(category.binds or {}) do
|
||||
if bind.capabilities ~= nil and bind.capabilities.combo == true
|
||||
and bind.capabilities.description == true and bind.capabilities.command == true
|
||||
and bind.source ~= nil and bind.raw_snippet ~= nil and bind.fingerprint == "exact-v1" then
|
||||
editable = editable + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
assert(editable == #bindDescriptions, "literal Hyprland binds did not retain editable source provenance")
|
||||
assert(refreshScanInstructionBlocks < 50, "Hyprland source scan exceeded its callback instruction budget")
|
||||
assert(refreshInstructionBlocks < 150, "Hyprland refresh exceeded its regression instruction budget")
|
||||
assert(refreshScanInstructionBlocks ~= nil, "Hyprland parser did not start the live bind request")
|
||||
|
||||
print(string.format(
|
||||
"hypr CPU-budget regression tests: ok (%d initial / %d refresh scan / %d refresh total blocks, %d editable binds)",
|
||||
initialInstructionBlocks, refreshScanInstructionBlocks, refreshInstructionBlocks, editable
|
||||
))
|
||||
@@ -0,0 +1,100 @@
|
||||
local rootLines = {}
|
||||
for index = 1, 420 do
|
||||
rootLines[#rootLines + 1] = "// Stock Niri configuration documentation line " .. tostring(index)
|
||||
end
|
||||
rootLines[#rootLines + 1] = "binds {"
|
||||
for index = 1, 114 do
|
||||
rootLines[#rootLines + 1] = string.format(
|
||||
" Mod+Key%d repeat=false { focus-workspace %d; }", index, index
|
||||
)
|
||||
end
|
||||
rootLines[#rootLines + 1] = "}"
|
||||
rootLines[#rootLines + 1] = 'include "mine/binds.kdl"'
|
||||
rootLines[#rootLines + 1] = 'include optional=true "mine/debug.kdl"'
|
||||
rootLines[#rootLines + 1] = 'include "mine/theme.kdl"'
|
||||
|
||||
local includeLines = { "binds {" }
|
||||
for index = 115, 126 do
|
||||
includeLines[#includeLines + 1] = string.format(
|
||||
' Mod+Key%d hotkey-overlay-title="Included %d" { focus-workspace %d; }',
|
||||
index, index, index
|
||||
)
|
||||
end
|
||||
includeLines[#includeLines + 1] = "}"
|
||||
|
||||
local sources = {
|
||||
["/fixture/config.kdl"] = table.concat(rootLines, "\n"),
|
||||
["/fixture/mine/binds.kdl"] = table.concat(includeLines, "\n"),
|
||||
["/fixture/mine/debug.kdl"] = "binds {\n Mod+Key127 { toggle-debug-tint; }\n}",
|
||||
["/fixture/mine/theme.kdl"] = string.rep("// unrelated theme setting\n", 500),
|
||||
}
|
||||
|
||||
local values, watchers = {}, {}
|
||||
local reads = {}
|
||||
local xorCalls = 0
|
||||
local stringSubCalls = 0
|
||||
local originalBit32 = bit32
|
||||
local originalStringSub = string.sub
|
||||
string.sub = function(...)
|
||||
stringSubCalls = stringSubCalls + 1
|
||||
return originalStringSub(...)
|
||||
end
|
||||
bit32 = {
|
||||
bxor = function(left, right)
|
||||
xorCalls = xorCalls + 1
|
||||
local result, place = 0, 1
|
||||
for _ = 1, 8 do
|
||||
if left % 2 ~= right % 2 then result = result + place end
|
||||
left = math.floor(left / 2)
|
||||
right = math.floor(right / 2)
|
||||
place = place * 2
|
||||
end
|
||||
return result
|
||||
end,
|
||||
}
|
||||
|
||||
noctalia = {
|
||||
state = {
|
||||
get = function(key) return values[key] end,
|
||||
set = function(key, value)
|
||||
values[key] = value
|
||||
if watchers[key] ~= nil then watchers[key](value) end
|
||||
end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
},
|
||||
getConfig = function(key)
|
||||
return ({
|
||||
compositor = "niri", niri_config = "/fixture/config.kdl", merge_sequential = false,
|
||||
})[key]
|
||||
end,
|
||||
getenv = function(key) return key == "NIRI_SOCKET" and "test" or "" end,
|
||||
fileExists = function(path) return path == "/fixture/config.kdl" end,
|
||||
listDir = function() return nil end,
|
||||
readFile = function(path)
|
||||
reads[path] = (reads[path] or 0) + 1
|
||||
return sources[path]
|
||||
end,
|
||||
tr = function(key, args)
|
||||
if args ~= nil and args.action ~= nil then return args.action end
|
||||
return key == "category.other" and "Other" or key
|
||||
end,
|
||||
}
|
||||
|
||||
assert(loadfile("niri_service.luau"))()
|
||||
bit32 = originalBit32
|
||||
string.sub = originalStringSub
|
||||
|
||||
local snapshot = values["keymap.snapshot"]
|
||||
assert(snapshot.status == "ready", "large, split Niri fixture did not parse")
|
||||
assert(snapshot.total == 127, "large, split Niri fixture lost binds")
|
||||
assert(reads["/fixture/config.kdl"] == 1, "Niri root config should only be read once")
|
||||
assert(reads["/fixture/mine/binds.kdl"] == 1, "Niri bind include should only be read once")
|
||||
assert(reads["/fixture/mine/debug.kdl"] == 1, "Niri optional include should only be read once")
|
||||
assert(reads["/fixture/mine/theme.kdl"] == 1, "Niri non-bind include should only be read once")
|
||||
assert(xorCalls > 0, "Niri parser did not use the native-xor fingerprint path")
|
||||
assert(
|
||||
stringSubCalls < 45000,
|
||||
"Niri parser scanned an unrelated include character by character: " .. tostring(stringSubCalls)
|
||||
)
|
||||
|
||||
print("niri CPU-budget regression tests: ok")
|
||||
@@ -111,6 +111,9 @@ local function runCase(case)
|
||||
case.id, source, case.snippet, case.capabilities,
|
||||
case.startLine, case.endLine
|
||||
)
|
||||
-- Active Hyprland source entries use exact byte comparison to keep hashing
|
||||
-- out of the service's tightly budgeted refresh callback.
|
||||
if case.compositor == "Hyprland" then bind.fingerprint = "exact-v1" end
|
||||
stateValues["keymap.snapshot"] = {
|
||||
status = "ready", compositor = case.compositor, source = root,
|
||||
categories = { { name = case.category, binds = { bind } } },
|
||||
|
||||
+36
-12
@@ -11,6 +11,7 @@ local REFRESH_REQUEST_KEY = "keymap.refresh_request"
|
||||
local LAST_HANDLED_REQUEST_KEY = "keymap.last_handled_create_request"
|
||||
local LAST_HANDLED_UPDATE_KEY = "keymap.last_handled_update_request"
|
||||
local SNAPSHOT_KEY = "keymap.snapshot"
|
||||
local EXACT_SOURCE_FINGERPRINT = "exact-v1"
|
||||
|
||||
local MAX_ROOT_BYTES = 2 * 1024 * 1024
|
||||
local MAX_MANAGED_BYTES = 1024 * 1024
|
||||
@@ -756,9 +757,9 @@ processValidatedRequest = function(request)
|
||||
verifyAndReload(request, transaction)
|
||||
end
|
||||
|
||||
local function xorByte(left, right)
|
||||
local function xorNibbleSlow(left, right)
|
||||
local result, place = 0, 1
|
||||
for _ = 1, 8 do
|
||||
for _ = 1, 4 do
|
||||
if left % 2 ~= right % 2 then result = result + place end
|
||||
left = math.floor(left / 2)
|
||||
right = math.floor(right / 2)
|
||||
@@ -767,6 +768,19 @@ local function xorByte(left, right)
|
||||
return result
|
||||
end
|
||||
|
||||
local xorNibbles = {}
|
||||
for left = 0, 15 do
|
||||
xorNibbles[left] = {}
|
||||
for right = 0, 15 do
|
||||
xorNibbles[left][right] = xorNibbleSlow(left, right)
|
||||
end
|
||||
end
|
||||
|
||||
local function xorByte(left, right)
|
||||
return xorNibbles[left % 16][right % 16]
|
||||
+ xorNibbles[math.floor(left / 16)][math.floor(right / 16)] * 16
|
||||
end
|
||||
|
||||
local xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte
|
||||
|
||||
local function contentFingerprint(value)
|
||||
@@ -779,6 +793,10 @@ local function contentFingerprint(value)
|
||||
return string.format("%08x", hash)
|
||||
end
|
||||
|
||||
local function sourceFingerprintMatches(value, expected)
|
||||
return expected == EXACT_SOURCE_FINGERPRINT or contentFingerprint(value) == expected
|
||||
end
|
||||
|
||||
local function splitLines(content)
|
||||
local lines, cursor = {}, 1
|
||||
while cursor <= #content do
|
||||
@@ -797,7 +815,7 @@ local function replaceLineRange(content, firstLine, lastLine, expected, replacem
|
||||
local lines, trailingNewline = splitLines(content)
|
||||
if firstLine < 1 or lastLine < firstLine or lastLine > #lines then return nil, "target_range_invalid" end
|
||||
local current = table.concat(lines, "\n", firstLine, lastLine)
|
||||
if current ~= expected or contentFingerprint(current) ~= contentFingerprint(expected) then
|
||||
if current ~= expected then
|
||||
return nil, "target_changed"
|
||||
end
|
||||
local replacementLines = {}
|
||||
@@ -831,11 +849,11 @@ local function reorderLineRanges(content, target, anchor, placement, replacement
|
||||
local currentTarget = table.concat(lines, "\n", targetFirst, targetLast)
|
||||
local currentAnchor = table.concat(lines, "\n", anchorFirst, anchorLast)
|
||||
if currentTarget ~= targetRaw
|
||||
or contentFingerprint(currentTarget) ~= tostring(target.fingerprint or "") then
|
||||
or not sourceFingerprintMatches(currentTarget, tostring(target.fingerprint or "")) then
|
||||
return nil, "target_changed"
|
||||
end
|
||||
if currentAnchor ~= anchorRaw
|
||||
or contentFingerprint(currentAnchor) ~= tostring(anchor.fingerprint or "") then
|
||||
or not sourceFingerprintMatches(currentAnchor, tostring(anchor.fingerprint or "")) then
|
||||
return nil, "anchor_changed"
|
||||
end
|
||||
|
||||
@@ -1088,7 +1106,9 @@ local function processValidatedUpdate(request, target, currentCategory)
|
||||
busy = false
|
||||
return
|
||||
end
|
||||
if contentFingerprint(tostring(target.raw_snippet or "")) ~= tostring(target.fingerprint or "") then
|
||||
if not sourceFingerprintMatches(
|
||||
tostring(target.raw_snippet or ""), tostring(target.fingerprint or "")
|
||||
) then
|
||||
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
||||
busy = false
|
||||
return
|
||||
@@ -1176,7 +1196,7 @@ local function processValidatedMove(request, target, currentCategory)
|
||||
return
|
||||
end
|
||||
local rawSnippet = tostring(target.raw_snippet or "")
|
||||
if contentFingerprint(rawSnippet) ~= tostring(target.fingerprint or "") then
|
||||
if not sourceFingerprintMatches(rawSnippet, tostring(target.fingerprint or "")) then
|
||||
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
||||
busy = false
|
||||
return
|
||||
@@ -1249,12 +1269,16 @@ local function processValidatedReorder(request, target, anchor)
|
||||
busy = false
|
||||
return
|
||||
end
|
||||
if contentFingerprint(tostring(target.raw_snippet or "")) ~= tostring(target.fingerprint or "") then
|
||||
if not sourceFingerprintMatches(
|
||||
tostring(target.raw_snippet or ""), tostring(target.fingerprint or "")
|
||||
) then
|
||||
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
||||
busy = false
|
||||
return
|
||||
end
|
||||
if contentFingerprint(tostring(anchor.raw_snippet or "")) ~= tostring(anchor.fingerprint or "") then
|
||||
if not sourceFingerprintMatches(
|
||||
tostring(anchor.raw_snippet or ""), tostring(anchor.fingerprint or "")
|
||||
) then
|
||||
publishUpdateResult(request.request_id, false, "anchor_changed", anchor.source)
|
||||
busy = false
|
||||
return
|
||||
@@ -1359,9 +1383,9 @@ local function processValidatedCategoryRename(request, category)
|
||||
return
|
||||
end
|
||||
local current = table.concat(lines, "\n", bind.start_line, bind.end_line)
|
||||
if contentFingerprint(rawSnippet) ~= tostring(bind.fingerprint or "")
|
||||
if not sourceFingerprintMatches(rawSnippet, tostring(bind.fingerprint or ""))
|
||||
or current ~= rawSnippet
|
||||
or contentFingerprint(current) ~= tostring(bind.fingerprint or "") then
|
||||
or not sourceFingerprintMatches(current, tostring(bind.fingerprint or "")) then
|
||||
publishUpdateResult(request.request_id, false, "target_changed", path)
|
||||
busy = false
|
||||
return
|
||||
@@ -1445,7 +1469,7 @@ local function processValidatedMutation(request, target)
|
||||
return
|
||||
end
|
||||
local rawSnippet = tostring(target.raw_snippet or "")
|
||||
if contentFingerprint(rawSnippet) ~= tostring(target.fingerprint or "") then
|
||||
if not sourceFingerprintMatches(rawSnippet, tostring(target.fingerprint or "")) then
|
||||
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
||||
busy = false
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user