summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJackson Moore <jacksonmoore@tuta.io>2026-09-04 14:24:35 +0200
committerJackson Moore <jacksonmoore@tuta.io>2026-09-04 14:24:35 +0200
commit4cb3fb0f1532de3465962903a1697339ad3d6159 (patch)
tree952c43a26dd8b114dfacf17ecaa09b2f9b31afbe
parent970db02504aeaf5c392a1bcad2f148734ba6ea48 (diff)
Translate window-local highlights
-rw-r--r--lua/clever_f/host_adapter.lua160
-rw-r--r--tests/run.lua90
2 files changed, 250 insertions, 0 deletions
diff --git a/lua/clever_f/host_adapter.lua b/lua/clever_f/host_adapter.lua
index a0eec66..747d9b8 100644
--- a/lua/clever_f/host_adapter.lua
+++ b/lua/clever_f/host_adapter.lua
@@ -41,6 +41,8 @@ function HostAdapter.new(options)
local adapter = setmetatable({}, HostAdapter)
adapter_records[adapter] = {
runtime = require_runtime(current_runtime(options)),
+ next_identity = 1,
+ highlights = {},
}
return adapter
end
@@ -53,6 +55,164 @@ function HostAdapter:runtime()
return adapter_records[self].runtime
end
+local function next_identity(adapter, prefix)
+ local record = adapter_records[adapter]
+ local identity = prefix .. "-" .. tostring(record.next_identity)
+ record.next_identity = record.next_identity + 1
+ return identity
+end
+
+local function highlight_exists(runtime, name)
+ if type(runtime.fn) == "table" and type(runtime.fn.hlexists) == "function" then
+ return runtime.fn.hlexists(name) == 1
+ end
+ local definition = runtime.api.nvim_get_hl(0, {
+ name = name,
+ link = true,
+ create = false,
+ })
+ return next(definition) ~= nil
+end
+
+function HostAdapter:read_highlight_group(name)
+ if type(name) ~= "string" or name == "" then
+ fail("highlight group name must be a nonempty string", 2)
+ end
+ local runtime = self:runtime()
+ if not highlight_exists(runtime, name) then
+ return nil
+ end
+ return runtime.api.nvim_get_hl(0, {
+ name = name,
+ link = true,
+ create = false,
+ })
+end
+
+local function native_highlight_definition(definition, options)
+ if type(definition) ~= "table" then
+ fail("highlight group definition must be a table", 3)
+ end
+ options = options or {}
+ if type(options) ~= "table" then
+ fail("highlight group options must be a table", 3)
+ end
+ local native = {}
+ for key, value in pairs(definition) do
+ if key ~= "guifg" and key ~= "guibg" and key ~= "gui" then
+ native[key] = value
+ end
+ end
+ if definition.guifg ~= nil then
+ native.fg = definition.guifg
+ end
+ if definition.guibg ~= nil then
+ native.bg = definition.guibg
+ end
+ for key, value in pairs(definition.gui or {}) do
+ native[key] = value
+ end
+ if options.default ~= nil then
+ native.default = options.default
+ end
+ if options.force ~= nil then
+ native.force = options.force
+ end
+ return native
+end
+
+function HostAdapter:define_highlight_group(name, definition, options)
+ if type(name) ~= "string" or name == "" then
+ fail("highlight group name must be a nonempty string", 2)
+ end
+ options = options or {}
+ local runtime = self:runtime()
+ if options.default and highlight_exists(runtime, name) then
+ return false
+ end
+ runtime.api.nvim_set_hl(
+ 0,
+ name,
+ native_highlight_definition(definition, options)
+ )
+ return true
+end
+
+local function overlay_positions(specification)
+ local positions = specification.positions
+ if positions == nil and specification.position ~= nil then
+ positions = { specification.position }
+ end
+ if type(positions) ~= "table" then
+ fail("highlight positions must be a list", 3)
+ end
+ local native = {}
+ for index, position in ipairs(positions) do
+ position = domain.Position.coerce(position)
+ native[index] = { position.line, position.byte_column }
+ end
+ if #native == 0 then
+ native[1] = { 0 }
+ end
+ return native
+end
+
+local function overlay_priority(value)
+ if value == "high" then
+ return 100
+ end
+ if value == "ordinary" or value == nil then
+ return 10
+ end
+ if type(value) == "number" then
+ return value
+ end
+ fail("highlight priority must be high, ordinary, or numeric", 3)
+end
+
+function HostAdapter:create_highlight(specification)
+ if type(specification) ~= "table" then
+ fail("highlight specification must be a table", 2)
+ end
+ if type(specification.group) ~= "string" or specification.group == "" then
+ fail("highlight group must be a nonempty string", 2)
+ end
+ if specification.window == nil then
+ fail("highlight window must identify its Nvim window", 2)
+ end
+ local record = adapter_records[self]
+ local identity = specification.identity or next_identity(self, "highlight")
+ if record.highlights[identity] ~= nil then
+ fail("highlight identity is already active", 2)
+ end
+ local match_id = record.runtime.fn.matchaddpos(
+ specification.group,
+ overlay_positions(specification),
+ overlay_priority(specification.priority),
+ -1,
+ { window = specification.window }
+ )
+ if type(match_id) ~= "number" or match_id < 0 then
+ fail("Nvim could not create the window-local highlight", 2)
+ end
+ record.highlights[identity] = {
+ match_id = match_id,
+ window = specification.window,
+ }
+ return identity
+end
+
+function HostAdapter:remove_highlight(identity)
+ local record = adapter_records[self]
+ local resource = record.highlights[identity]
+ if resource == nil then
+ return false
+ end
+ record.highlights[identity] = nil
+ record.runtime.fn.matchdelete(resource.match_id, resource.window)
+ return true
+end
+
local function escape_key(runtime)
if type(runtime.keycode) == "function" then
return runtime.keycode("<Esc>")
diff --git a/tests/run.lua b/tests/run.lua
index 8c3c771..64c12d6 100644
--- a/tests/run.lua
+++ b/tests/run.lua
@@ -361,6 +361,96 @@ test("ActionOutcome and DotPayload retain resolved result data", function()
same("problem", domain.ActionOutcome.error(destination, "problem").diagnostic)
end)
+test("Host adapter materializes window-local highlight resources", function()
+ local groups = { Existing = { fg = 7 } }
+ local added = {}
+ local deleted = {}
+ local next_match = 1000
+ local runtime = {
+ api = {
+ nvim_get_hl = function(_, options)
+ return vim.deepcopy(groups[options.name] or {})
+ end,
+ nvim_set_hl = function(_, name, definition)
+ groups[name] = vim.deepcopy(definition)
+ end,
+ },
+ fn = {
+ hlexists = function(name)
+ return groups[name] ~= nil and 1 or 0
+ end,
+ matchaddpos = function(group, positions, priority, id, options)
+ local match_id = next_match
+ next_match = next_match + 1
+ added[#added + 1] = {
+ group = group,
+ positions = positions,
+ priority = priority,
+ id = id,
+ window = options.window,
+ match_id = match_id,
+ }
+ return match_id
+ end,
+ matchdelete = function(match_id, window)
+ deleted[#deleted + 1] = { match_id = match_id, window = window }
+ return 0
+ end,
+ },
+ }
+ local adapter = host_adapter.new({ runtime = runtime })
+
+ same(7, adapter:read_highlight_group("Existing").fg)
+ same(nil, adapter:read_highlight_group("Missing"))
+ falsy(adapter:define_highlight_group(
+ "Existing",
+ { guifg = "red" },
+ { default = true }
+ ))
+ truthy(adapter:define_highlight_group("CleverFDefaultLabel", {
+ guifg = "red",
+ guibg = "NONE",
+ gui = { bold = true, underline = true },
+ ctermfg = "red",
+ ctermbg = "NONE",
+ cterm = { bold = true, underline = true },
+ }, { default = true }))
+ same("red", groups.CleverFDefaultLabel.fg)
+ same("NONE", groups.CleverFDefaultLabel.bg)
+ truthy(groups.CleverFDefaultLabel.bold)
+ truthy(groups.CleverFDefaultLabel.underline)
+ truthy(groups.CleverFDefaultLabel.default)
+
+ local cursor = adapter:create_highlight({
+ group = "CleverFCursor",
+ window = 42,
+ position = domain.Position.new(3, 5),
+ priority = "high",
+ })
+ local character = adapter:create_highlight({
+ group = "CleverFChar",
+ window = 51,
+ positions = {},
+ priority = "ordinary",
+ })
+ same(2, #added)
+ list_same({ 3, 5 }, added[1].positions[1])
+ same(100, added[1].priority)
+ same(42, added[1].window)
+ list_same({ 0 }, added[2].positions[1])
+ same(10, added[2].priority)
+ same(51, added[2].window)
+
+ truthy(adapter:remove_highlight(cursor))
+ truthy(adapter:remove_highlight(character))
+ falsy(adapter:remove_highlight(character))
+ same(2, #deleted)
+ same(1000, deleted[1].match_id)
+ same(42, deleted[1].window)
+ same(1001, deleted[2].match_id)
+ same(51, deleted[2].window)
+end)
+
test("Host adapter translates typed action outcomes to Nvim effects", function()
local effects = {}
local runtime = {