local capabilities = require("clever_f.capabilities") local domain = require("clever_f.domain") local M = {} local HostAdapter = {} HostAdapter.__index = HostAdapter M.HostAdapter = HostAdapter M.ActionEffect = { NONE = "none", ESCAPE = "escape", ERROR = "error", } local adapter_records = setmetatable({}, { __mode = "k" }) local function fail(message, level) error(message, (level or 1) + 1) end local function current_runtime(options) if type(options) == "table" and options.runtime ~= nil then return options.runtime end if options ~= nil and options ~= HostAdapter then return options end return rawget(_G, "vim") end local function require_runtime(runtime) if type(runtime) ~= "table" or type(runtime.api) ~= "table" then fail("HostAdapter requires the Nvim Lua runtime", 3) end return runtime end function HostAdapter.new(options) if HostAdapter.is(options) then return options end local adapter = setmetatable({}, HostAdapter) local record = { runtime = require_runtime(current_runtime(options)), next_identity = 1, highlights = {}, timers = {}, cursor_presentations = {}, events = {}, event_order = {}, augroup = nil, } record.event_queue = capabilities.EventQueue.new(function(_, delivery) delivery.callback(delivery.name, delivery.payload) end) adapter_records[adapter] = record return adapter end function HostAdapter.is(value) return type(value) == "table" and adapter_records[value] ~= nil end function HostAdapter:runtime() return adapter_records[self].runtime end function HostAdapter:read_encoding() local runtime = self:runtime() return runtime.api.nvim_get_option_value( "encoding", { scope = "global" } ) end function HostAdapter:lowercase(value) if type(value) ~= "string" then fail("case conversion value must be a string", 2) end local result = self:runtime().fn.tolower(value) if type(result) ~= "string" then fail("Nvim case conversion must return a string", 2) end return result 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 nonnegative_integer(value, name) if type(value) ~= "number" or value < 0 or value ~= math.floor(value) or value == math.huge then fail((name or "value") .. " must be a nonnegative integer", 3) end return value end function HostAdapter:supports_timers() local fn = self:runtime().fn return type(fn) == "table" and type(fn.timer_start) == "function" and type(fn.timer_stop) == "function" end function HostAdapter:start_timer(delay_ms, callback) nonnegative_integer(delay_ms, "timer delay") if type(callback) ~= "function" then fail("timer callback must be a function", 2) end if not self:supports_timers() then return nil end local record = adapter_records[self] local identity = next_identity(self, "timer") local timer_id = record.runtime.fn.timer_start(delay_ms, function() local resource = record.timers[identity] if resource == nil or not resource.active then return end resource.active = false callback(identity) end) if type(timer_id) ~= "number" or timer_id < 0 then fail("Nvim could not start the timer", 2) end record.timers[identity] = { timer_id = timer_id, active = true, } return identity end function HostAdapter:stop_timer(identity) local record = adapter_records[self] local resource = record.timers[identity] if resource == nil or not resource.active then return false end resource.active = false record.runtime.fn.timer_stop(resource.timer_id) return true end local function event_names(value) if type(value) == "string" then value = { value } end if type(value) ~= "table" or #value == 0 then fail("event names must be a nonempty list", 3) end local names = {} local set = {} for index, name in ipairs(value) do if type(name) ~= "string" or name == "" then fail("event name must be a nonempty string", 3) end if not set[name] then names[#names + 1] = name set[name] = true end end return names, set end local function event_payload(adapter, event) local payload = { buffer = event.buf, file = event.file, match = event.match, data = event.data, } local api = adapter:runtime().api if type(api.nvim_get_current_win) == "function" then payload.window = api.nvim_get_current_win() end return payload end local function event_augroup(record) if record.augroup == nil then record.augroup = record.runtime.api.nvim_create_augroup( "clever_f", { clear = true } ) end return record.augroup end local function queue_event(record, name, payload, callback) return record.event_queue:emit(name, { name = name, payload = payload, callback = callback, }) end function HostAdapter:register_events(names, callback, options) local name_set names, name_set = event_names(names) if type(callback) ~= "function" then fail("event callback must be a function", 2) end options = options or {} if type(options) ~= "table" then fail("event registration options must be a table", 2) end local record = adapter_records[self] local identity = next_identity(self, "event-registration") local autocmd_options = { group = event_augroup(record), desc = "clever-f " .. table.concat(names, "/"), callback = function(event) local resource = record.events[identity] if resource ~= nil and resource.active then queue_event( record, event.event, event_payload(self, event), resource.callback ) end end, } if options.buffer ~= nil then autocmd_options.buffer = options.buffer end local autocmd_id = record.runtime.api.nvim_create_autocmd( names, autocmd_options ) record.events[identity] = { autocmd_id = autocmd_id, names = names, name_set = name_set, callback = callback, buffer = options.buffer, active = true, } record.event_order[#record.event_order + 1] = identity return identity end function HostAdapter:remove_event_registration(identity) local record = adapter_records[self] local resource = record.events[identity] if resource == nil or not resource.active then return false end resource.active = false record.runtime.api.nvim_del_autocmd(resource.autocmd_id) return true end function HostAdapter:deliver_event(name, payload) if type(name) ~= "string" or name == "" then fail("event name must be a nonempty string", 2) end payload = payload or {} local record = adapter_records[self] local event_buffer = payload.buffer for _, identity in ipairs(record.event_order) do local resource = record.events[identity] if resource.active and resource.name_set[name] and (resource.buffer == nil or event_buffer == nil or resource.buffer == event_buffer) then queue_event(record, name, payload, resource.callback) end end end function HostAdapter:begin_action_transition() return adapter_records[self].event_queue:begin_transition() end function HostAdapter:commit_action_transition(token) return adapter_records[self].event_queue:commit_transition(token) end local function terminal_cursor_option(runtime) return runtime.fn.eval("&t_ve") end local function set_terminal_cursor_option(runtime, value) runtime.api.nvim_cmd({ cmd = "let", args = { "&t_ve", "=", runtime.fn.string(value) }, }, {}) end function HostAdapter:supports_cursor_presentation() local runtime = self:runtime() return type(runtime.api.nvim_get_option_value) == "function" and type(runtime.api.nvim_set_option_value) == "function" and type(runtime.api.nvim_cmd) == "function" and type(runtime.fn) == "table" and type(runtime.fn.exists) == "function" and runtime.fn.exists("+t_ve") == 1 and type(runtime.fn.eval) == "function" and type(runtime.fn.string) == "function" end function HostAdapter:suppress_cursor_presentation() if not self:supports_cursor_presentation() then return nil end local record = adapter_records[self] local runtime = record.runtime local identity = next_identity(self, "cursor-presentation") local saved = { guicursor = runtime.api.nvim_get_option_value( "guicursor", { scope = "global" } ), terminal_cursor = terminal_cursor_option(runtime), } runtime.api.nvim_set_option_value( "guicursor", "a:ver1", { scope = "global" } ) local ok, failure = pcall(set_terminal_cursor_option, runtime, "") if not ok then runtime.api.nvim_set_option_value( "guicursor", saved.guicursor, { scope = "global" } ) error(failure, 0) end record.cursor_presentations[identity] = saved return identity end function HostAdapter:restore_cursor_presentation(identity) local record = adapter_records[self] local saved = record.cursor_presentations[identity] if saved == nil then return false end record.cursor_presentations[identity] = nil record.runtime.api.nvim_set_option_value( "guicursor", saved.guicursor, { scope = "global" } ) set_terminal_cursor_option(record.runtime, saved.terminal_cursor) return true end local function escape_key(runtime) if type(runtime.keycode) == "function" then return runtime.keycode("") end if type(runtime.api.nvim_replace_termcodes) == "function" then return runtime.api.nvim_replace_termcodes("", true, false, true) end return string.char(27) end function HostAdapter:return_escape() local runtime = self:runtime() if type(runtime.api.nvim_feedkeys) ~= "function" then fail("HostAdapter runtime must provide nvim_feedkeys", 2) end runtime.api.nvim_feedkeys(escape_key(runtime), "n", false) end function HostAdapter:emit_action_error(text) local runtime = self:runtime() if type(runtime.notify) ~= "function" then fail("HostAdapter runtime must provide notify", 2) end local levels = type(runtime.log) == "table" and runtime.log.levels or nil runtime.notify(text, levels and levels.ERROR or nil, { title = "clever-f" }) end function HostAdapter:translate_action_outcome(outcome) if not domain.ActionOutcome.is(outcome) then fail("host action translation requires an ActionOutcome", 2) end if outcome.kind == domain.ActionKind.ESCAPE then self:return_escape() return M.ActionEffect.ESCAPE end if outcome.kind == domain.ActionKind.ERROR then self:emit_action_error(outcome.diagnostic) return M.ActionEffect.ERROR end return M.ActionEffect.NONE end function M.new(options) return HostAdapter.new(options) end setmetatable(M, { __call = function(_, options) return HostAdapter.new(options) end, }) return M