summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJackson Moore <jacksonmoore@tuta.io>2026-09-04 14:50:32 +0200
committerJackson Moore <jacksonmoore@tuta.io>2026-09-04 14:50:32 +0200
commit03757a7355bbad885a9f3c86115f0374dd2cb854 (patch)
tree393b9500fe95fe83e96776c946dbb87936f693fa
parentaa2c42761a91e8eca2d551fe14f1bc1dd3fc8593 (diff)
Complete Nvim adapter contract
-rw-r--r--Makefile2
-rw-r--r--lua/clever_f/host_adapter.lua411
-rw-r--r--lua/clever_f/init.lua118
-rw-r--r--plugin/clever_f.lua6
-rw-r--r--tests/host_adapter_contract.lua179
-rw-r--r--tests/plugin_smoke.lua104
-rw-r--r--tests/run.lua2
7 files changed, 803 insertions, 19 deletions
diff --git a/Makefile b/Makefile
index b1faf93..1a692cd 100644
--- a/Makefile
+++ b/Makefile
@@ -4,3 +4,5 @@ NVIM ?= nvim
test:
NVIM_LOG_FILE=/dev/null $(NVIM) --headless -u NONE -i NONE -l tests/run.lua
+ NVIM_LOG_FILE=/dev/null $(NVIM) --headless -u NONE -i NONE -l tests/host_adapter_contract.lua
+ NVIM_LOG_FILE=/dev/null $(NVIM) --headless -u NONE -i NONE -l tests/plugin_smoke.lua
diff --git a/lua/clever_f/host_adapter.lua b/lua/clever_f/host_adapter.lua
index a81a44c..724e9da 100644
--- a/lua/clever_f/host_adapter.lua
+++ b/lua/clever_f/host_adapter.lua
@@ -2,6 +2,7 @@ local capabilities = require("clever_f.capabilities")
local domain = require("clever_f.domain")
local M = {}
+local unpack_values = table.unpack or unpack
local HostAdapter = {}
HostAdapter.__index = HostAdapter
M.HostAdapter = HostAdapter
@@ -11,6 +12,24 @@ M.ActionEffect = {
ESCAPE = "escape",
ERROR = "error",
}
+M.CONFIGURATION_PREFIX = "clever_f_"
+M.CONFIGURATION_GLOBALS = {
+ suppress_default_mappings = "clever_f_not_overwrites_standard_mappings",
+}
+
+local BOOLEAN_CONFIGURATION = {
+ search_current_line_only = true,
+ ignore_case = true,
+ smart_case = true,
+ use_migemo = true,
+ fix_key_direction = true,
+ show_prompt = true,
+ mark_cursor = true,
+ hide_cursor_on_cmdline = true,
+ mark_char = true,
+ mark_direct = true,
+ clean_labels_eagerly = true,
+}
local adapter_records = setmetatable({}, { __mode = "k" })
@@ -19,8 +38,14 @@ local function fail(message, level)
end
local function current_runtime(options)
- if type(options) == "table" and options.runtime ~= nil then
- return options.runtime
+ if type(options) == "table" then
+ if options.runtime ~= nil then
+ return options.runtime
+ end
+ if options.api ~= nil then
+ return options
+ end
+ return rawget(_G, "vim")
end
if options ~= nil and options ~= HostAdapter then
return options
@@ -47,9 +72,12 @@ function HostAdapter.new(options)
timers = {},
cursor_presentations = {},
events = {},
+ actions = {},
+ mappings = {},
dot_repeat = nil,
dot_bridge = nil,
event_order = {},
+ action_diagnostics = nil,
augroup = nil,
}
record.event_queue = capabilities.EventQueue.new(function(_, delivery)
@@ -67,6 +95,108 @@ function HostAdapter:runtime()
return adapter_records[self].runtime
end
+function HostAdapter:read_text()
+ local lines = self:runtime().api.nvim_buf_get_lines(0, 0, -1, true)
+ if #lines == 0 then
+ lines = { "" }
+ end
+ return domain.TextSnapshot.new(lines)
+end
+
+function HostAdapter:read_buffer()
+ return self:runtime().api.nvim_get_current_buf()
+end
+
+function HostAdapter:read_window()
+ return self:runtime().api.nvim_get_current_win()
+end
+
+function HostAdapter:read_cursor()
+ local position = self:runtime().api.nvim_win_get_cursor(0)
+ return domain.Position.new(position[1], position[2] + 1)
+end
+
+function HostAdapter:read_mode()
+ return self:runtime().api.nvim_get_mode().mode
+end
+
+local function selection_option(runtime)
+ local value = runtime.api.nvim_get_option_value(
+ "selection",
+ { scope = "global" }
+ )
+ if value == "exclusive" then
+ return domain.SelectionOption.EXCLUSIVE
+ end
+ return domain.SelectionOption.INCLUSIVE
+end
+
+function HostAdapter:read_selection()
+ local runtime = self:runtime()
+ local context = domain.ModeContext.from_full_mode(self:read_mode())
+ local kind = context.visual_kind or context.select_kind
+ local option = selection_option(runtime)
+ if kind == nil then
+ return domain.Selection.inactive(option)
+ end
+ local raw_anchor = runtime.fn.getpos("v")
+ local focus = self:read_cursor()
+ local anchor
+ if type(raw_anchor) == "table"
+ and type(raw_anchor[2]) == "number"
+ and raw_anchor[2] > 0
+ and type(raw_anchor[3]) == "number"
+ and raw_anchor[3] > 0
+ then
+ anchor = domain.Position.new(raw_anchor[2], raw_anchor[3])
+ else
+ anchor = focus
+ end
+ return domain.Selection.active(kind, anchor, focus, option)
+end
+
+function HostAdapter:read_count()
+ local runtime = self:runtime()
+ local count = runtime.v.count1
+ if type(count) ~= "number" or count < 1 then
+ count = 1
+ end
+ return domain.Count.new(count)
+end
+
+local function configuration_global(name)
+ if type(name) ~= "string" or name == "" then
+ fail("configuration name must be a nonempty string", 3)
+ end
+ return M.CONFIGURATION_GLOBALS[name] or M.CONFIGURATION_PREFIX .. name
+end
+
+function M.configuration_global(name)
+ return configuration_global(name)
+end
+
+function HostAdapter:configuration_present(name)
+ local global = configuration_global(name)
+ return self:runtime().fn.exists("g:" .. global) == 1
+end
+
+local function normalize_configuration(name, value)
+ if BOOLEAN_CONFIGURATION[name] and type(value) == "number" then
+ return value ~= 0
+ end
+ return value
+end
+
+function HostAdapter:read_configuration(name)
+ local global = configuration_global(name)
+ return normalize_configuration(name, self:runtime().g[global])
+end
+
+function HostAdapter:write_configuration(name, value)
+ local global = configuration_global(name)
+ self:runtime().g[global] = value
+end
+
function HostAdapter:read_encoding()
local runtime = self:runtime()
return runtime.api.nvim_get_option_value(
@@ -86,6 +216,45 @@ function HostAdapter:lowercase(value)
return result
end
+function HostAdapter:read_macro_state()
+ local register = self:runtime().fn.reg_executing()
+ return domain.MacroState.new(register ~= "" and register or nil)
+end
+
+local function fold_open_policies(value)
+ local result = {}
+ for item in tostring(value):gmatch("[^,]+") do
+ if item == "hor" then
+ item = "horizontal"
+ end
+ result[#result + 1] = item
+ end
+ return result
+end
+
+function HostAdapter:read_fold_state()
+ local runtime = self:runtime()
+ local foldopen = runtime.api.nvim_get_option_value(
+ "foldopen",
+ { scope = "global" }
+ )
+ local line = self:read_cursor().line
+ local closed_levels = runtime.fn.foldclosed(line) == -1 and 0 or 1
+ return domain.FoldState.new(
+ fold_open_policies(foldopen),
+ closed_levels
+ )
+end
+
+function HostAdapter:read_time_ms()
+ local runtime = self:runtime()
+ local uv = runtime.uv or runtime.loop
+ if type(uv) ~= "table" or type(uv.hrtime) ~= "function" then
+ fail("HostAdapter runtime must provide a monotonic clock", 2)
+ end
+ return uv.hrtime() / 1000000
+end
+
function HostAdapter:read_pending_operator()
local operator = self:runtime().v.operator
if operator == nil then
@@ -125,6 +294,101 @@ function HostAdapter:set_operator_inclusive(enabled)
end
end
+local function string_bytes(value)
+ local bytes = {}
+ for index = 1, #value do
+ bytes[index] = string.byte(value, index)
+ end
+ return bytes
+end
+
+function HostAdapter:read_input()
+ local runtime = self:runtime()
+ local value = runtime.fn.getcharstr()
+ if type(value) ~= "string" or value == "" then
+ fail("Nvim target input must be a nonempty string", 2)
+ end
+ local bytes = string_bytes(value)
+ if #bytes == 3
+ and bytes[1] == 0x80
+ and bytes[2] == 0xfd
+ and bytes[3] == 0x60
+ then
+ return domain.InputPacket.raw_bytes(bytes)
+ end
+ if #bytes == 1 and bytes[1] == 27 then
+ return domain.InputPacket.special_key("Escape", bytes)
+ end
+ if bytes[1] == 0x80 then
+ local name = type(runtime.fn.keytrans) == "function"
+ and runtime.fn.keytrans(value)
+ or "Special"
+ return domain.InputPacket.special_key(name, value)
+ end
+ return domain.InputPacket.text(value)
+end
+
+function HostAdapter:open_fold(position)
+ position = position and domain.Position.coerce(position) or self:read_cursor()
+ local runtime = self:runtime()
+ if runtime.fn.foldclosed(position.line) == -1 then
+ return false
+ end
+ runtime.api.nvim_cmd({
+ cmd = "normal",
+ bang = true,
+ args = { "zo" },
+ }, {})
+ return true
+end
+
+function HostAdapter:show_prompt(text)
+ if type(text) ~= "string" then
+ fail("prompt must be a string", 2)
+ end
+ self:runtime().api.nvim_echo({ { text } }, false, {})
+end
+
+function HostAdapter:redraw(kind)
+ if kind == "suppressed" then
+ return false
+ end
+ if kind ~= "screen" and kind ~= "full" then
+ fail("redraw kind must be screen, full, or suppressed", 2)
+ end
+ self:runtime().api.nvim_cmd({
+ cmd = "redraw",
+ bang = kind == "full",
+ }, {})
+ return true
+end
+
+local DIAGNOSTIC_LEVELS = {
+ error = "ERROR",
+ warning = "WARN",
+ info = "INFO",
+}
+
+function HostAdapter:emit_diagnostic(level, text)
+ local level_name = DIAGNOSTIC_LEVELS[level]
+ if level_name == nil then
+ fail("diagnostic level must be error, warning, or info", 2)
+ end
+ if type(text) ~= "string" or text == "" then
+ fail("diagnostic text must be a nonempty string", 2)
+ end
+ local record = adapter_records[self]
+ local runtime = record.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 {}
+ runtime.notify(text, levels[level_name], { title = "clever-f" })
+ if record.action_diagnostics ~= nil then
+ record.action_diagnostics[level .. "\0" .. text] = true
+ end
+end
+
local install_dot_bridge
function HostAdapter:register_dot_repeat(payload, callback)
@@ -356,7 +620,7 @@ function HostAdapter:start_timer(delay_ms, callback)
if resource == nil or not resource.active then
return
end
- resource.active = false
+ record.timers[identity] = nil
callback(identity)
end)
if type(timer_id) ~= "number" or timer_id < 0 then
@@ -375,7 +639,7 @@ function HostAdapter:stop_timer(identity)
if resource == nil or not resource.active then
return false
end
- resource.active = false
+ record.timers[identity] = nil
record.runtime.fn.timer_stop(resource.timer_id)
return true
end
@@ -544,13 +808,9 @@ install_dot_bridge = function(adapter)
if registration == nil or registration.callback == nil then
return
end
- local count = runtime.v.count1
- if type(count) ~= "number" or count < 1 then
- count = 1
- end
local outcome = registration.callback(
registration.payload,
- domain.Count.new(count)
+ adapter:read_count()
)
if domain.ActionOutcome.is(outcome) then
adapter:translate_action_outcome(outcome)
@@ -627,11 +887,16 @@ function HostAdapter:deliver_event(name, payload)
end
function HostAdapter:begin_action_transition()
- return adapter_records[self].event_queue:begin_transition()
+ local record = adapter_records[self]
+ record.action_diagnostics = {}
+ return record.event_queue:begin_transition()
end
function HostAdapter:commit_action_transition(token)
- return adapter_records[self].event_queue:commit_transition(token)
+ local record = adapter_records[self]
+ local result = record.event_queue:commit_transition(token)
+ record.action_diagnostics = nil
+ return result
end
local function terminal_cursor_option(runtime)
@@ -724,12 +989,7 @@ function HostAdapter:return_escape()
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" })
+ return self:emit_diagnostic("error", text)
end
function HostAdapter:translate_action_outcome(outcome)
@@ -741,12 +1001,127 @@ function HostAdapter:translate_action_outcome(outcome)
return M.ActionEffect.ESCAPE
end
if outcome.kind == domain.ActionKind.ERROR then
- self:emit_action_error(outcome.diagnostic)
+ local diagnostics = adapter_records[self].action_diagnostics
+ local key = "error\0" .. outcome.diagnostic
+ if diagnostics == nil or not diagnostics[key] then
+ self:emit_action_error(outcome.diagnostic)
+ end
return M.ActionEffect.ERROR
end
return M.ActionEffect.NONE
end
+local function packed(...)
+ return { n = select("#", ...), ... }
+end
+
+local function invoke_callback(adapter, callback, ...)
+ local arguments = packed(...)
+ local token = adapter:begin_action_transition()
+ local results = packed(pcall(function()
+ local values = packed(callback(unpack_values(arguments, 1, arguments.n)))
+ if domain.ActionOutcome.is(values[1]) then
+ adapter:translate_action_outcome(values[1])
+ end
+ return unpack_values(values, 1, values.n)
+ end))
+ local commit = packed(pcall(adapter.commit_action_transition, adapter, token))
+ if not results[1] then
+ error(results[2], 0)
+ end
+ if not commit[1] then
+ error(commit[2], 0)
+ end
+ return unpack_values(results, 2, results.n)
+end
+
+function HostAdapter:register_action(name, callback)
+ if type(name) ~= "string" or name == "" then
+ fail("action name must be a nonempty string", 2)
+ end
+ if type(callback) ~= "function" then
+ fail("action callback must be a function", 2)
+ end
+ local actions = adapter_records[self].actions
+ if actions[name] ~= nil then
+ fail("action is already registered", 2)
+ end
+ actions[name] = callback
+ return name
+end
+
+function HostAdapter:invoke_action(name, ...)
+ local callback = adapter_records[self].actions[name]
+ if callback == nil then
+ fail("action is not registered", 2)
+ end
+ return invoke_callback(self, callback, ...)
+end
+
+function HostAdapter:invoke_callback(callback, ...)
+ if type(callback) ~= "function" then
+ fail("action callback must be a function", 2)
+ end
+ return invoke_callback(self, callback, ...)
+end
+
+local function mapping_modes(value)
+ if type(value) == "string" then
+ value = { value }
+ end
+ if type(value) ~= "table" or #value == 0 then
+ fail("mapping modes must be a nonempty list", 3)
+ end
+ local result = {}
+ for index, mode in ipairs(value) do
+ if type(mode) ~= "string" or mode == "" then
+ fail("mapping mode must be a nonempty string", 3)
+ end
+ result[index] = mode
+ end
+ return result
+end
+
+function HostAdapter:register_mapping(modes, lhs, action, options)
+ modes = mapping_modes(modes)
+ if type(lhs) ~= "string" or lhs == "" then
+ fail("mapping lhs must be a nonempty string", 2)
+ end
+ if type(action) ~= "string" and type(action) ~= "function" then
+ fail("mapping action must be an action name or function", 2)
+ end
+ options = options or {}
+ if type(options) ~= "table" then
+ fail("mapping options must be a table", 2)
+ end
+ local callback
+ if type(action) == "string" then
+ callback = function()
+ return self:invoke_action(action)
+ end
+ else
+ callback = function(...)
+ return invoke_callback(self, action, ...)
+ end
+ end
+ local native_options = {
+ silent = options.silent == true,
+ remap = options.remap == true,
+ desc = options.desc
+ or ("clever-f " .. (type(action) == "string" and action or lhs)),
+ }
+ self:runtime().keymap.set(modes, lhs, callback, native_options)
+ local identity = next_identity(self, "mapping")
+ adapter_records[self].mappings[identity] = {
+ modes = modes,
+ lhs = lhs,
+ action = action,
+ options = options,
+ callback = callback,
+ }
+ return identity
+end
+
function M.new(options)
return HostAdapter.new(options)
end
diff --git a/lua/clever_f/init.lua b/lua/clever_f/init.lua
new file mode 100644
index 0000000..a1272fc
--- /dev/null
+++ b/lua/clever_f/init.lua
@@ -0,0 +1,118 @@
+local composition_root = require("clever_f.composition_root")
+local host_adapter = require("clever_f.host_adapter")
+
+local M = {}
+local active_root
+local active_activation
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function build_root(options)
+ if composition_root.CompositionRoot.is(options) then
+ return options
+ end
+ if host_adapter.HostAdapter.is(options) then
+ return composition_root.new({ host = options })
+ end
+ options = options or {}
+ if type(options) ~= "table" then
+ fail("clever-f activation options must be a table", 3)
+ end
+ if options.host == nil then
+ local adapter_options = options.runtime ~= nil
+ and { runtime = options.runtime }
+ or nil
+ options = { host = host_adapter.new(adapter_options) }
+ end
+ return composition_root.new(options)
+end
+
+function M.activate(options)
+ if active_root == nil then
+ active_root = build_root(options)
+ active_activation = active_root:activate()
+ end
+ return active_activation
+end
+
+local function root()
+ M.activate()
+ return active_root
+end
+
+function M.root()
+ return root()
+end
+
+function M.state()
+ return root():state()
+end
+
+local function invoke(name)
+ local instance = root()
+ return instance:host():invoke_action(name)
+end
+
+function M.StartFindForward()
+ return invoke("StartFindForward")
+end
+
+function M.StartFindBackward()
+ return invoke("StartFindBackward")
+end
+
+function M.StartTillForward()
+ return invoke("StartTillForward")
+end
+
+function M.StartTillBackward()
+ return invoke("StartTillBackward")
+end
+
+function M.Reset()
+ return invoke("Reset")
+end
+
+function M.RepeatSameDirection()
+ return invoke("RepeatSameDirection")
+end
+
+function M.RepeatOppositeDirection()
+ return invoke("RepeatOppositeDirection")
+end
+
+local function invoke_direct(callback)
+ local instance = root()
+ local host = instance:host()
+ if type(host.invoke_callback) ~= "function" then
+ fail("clever-f host must invoke direct action callbacks", 2)
+ end
+ return host:invoke_callback(function()
+ return callback(instance)
+ end)
+end
+
+function M.invoke_descriptor(value)
+ return invoke_direct(function(instance)
+ return instance:invoke_descriptor(value)
+ end)
+end
+
+function M._diagnostic_full_reset()
+ return invoke_direct(function(instance)
+ return instance:diagnostic_full_reset()
+ end)
+end
+
+M.start_find_forward = M.StartFindForward
+M.start_find_backward = M.StartFindBackward
+M.start_till_forward = M.StartTillForward
+M.start_till_backward = M.StartTillBackward
+M.reset = M.Reset
+M.repeat_same_direction = M.RepeatSameDirection
+M.repeat_opposite_direction = M.RepeatOppositeDirection
+M.free_form = M.invoke_descriptor
+
+return M
diff --git a/plugin/clever_f.lua b/plugin/clever_f.lua
new file mode 100644
index 0000000..1a8cadd
--- /dev/null
+++ b/plugin/clever_f.lua
@@ -0,0 +1,6 @@
+if vim.g.loaded_clever_f_lua then
+ return
+end
+
+vim.g.loaded_clever_f_lua = 1
+require("clever_f").activate()
diff --git a/tests/host_adapter_contract.lua b/tests/host_adapter_contract.lua
new file mode 100644
index 0000000..ff369d5
--- /dev/null
+++ b/tests/host_adapter_contract.lua
@@ -0,0 +1,179 @@
+local script = debug.getinfo(1, "S").source:sub(2)
+local root = script:match("^(.*)/tests/host_adapter_contract%.lua$") or "."
+package.path = table.concat({
+ root .. "/lua/?.lua",
+ root .. "/lua/?/init.lua",
+ package.path,
+}, ";")
+
+local capabilities = require("clever_f.capabilities")
+local composition_root = require("clever_f.composition_root")
+local domain = require("clever_f.domain")
+local host_adapter = require("clever_f.host_adapter")
+
+local function same(expected, actual, message)
+ if expected ~= actual then
+ error((message or "values differ")
+ .. ": expected " .. tostring(expected)
+ .. ", got " .. tostring(actual), 2)
+ end
+end
+
+local function truthy(value, message)
+ if not value then
+ error(message or "value must be true", 2)
+ end
+end
+
+local exercised = {}
+local host = host_adapter.new()
+capabilities.assert_implements(host)
+
+local function call(name, ...)
+ exercised[name] = true
+ return host[name](host, ...)
+end
+
+vim.api.nvim_buf_set_lines(0, 0, -1, true, { "ababa" })
+vim.api.nvim_win_set_cursor(0, { 1, 0 })
+
+same("ababa", call("read_text"):line(1))
+same(vim.api.nvim_get_current_buf(), call("read_buffer"))
+same(vim.api.nvim_get_current_win(), call("read_window"))
+same(domain.Position.new(1, 1), call("read_cursor"))
+same("n", call("read_mode"))
+truthy(not call("read_selection").active)
+same(1, call("read_count").value)
+
+local ignore_case_global = host_adapter.configuration_global("ignore_case")
+vim.g[ignore_case_global] = 0
+truthy(call("configuration_present", "ignore_case"))
+same(false, call("read_configuration", "ignore_case"))
+call("write_configuration", "ignore_case", true)
+same(true, call("read_configuration", "ignore_case"))
+same("utf-8", call("read_encoding"))
+same(vim.fn.tolower("AbC"), call("lowercase", "AbC"))
+truthy(not call("read_macro_state").executing)
+truthy(domain.FoldState.is(call("read_fold_state")))
+truthy(call("read_time_ms") > 0)
+
+same("", call("read_pending_operator"))
+call("apply_cursor", domain.Position.new(1, 2))
+same(domain.Position.new(1, 2), call("read_cursor"))
+call("apply_selection", domain.Position.new(1, 3), domain.SelectionKind.CHARACTER)
+same(domain.Position.new(1, 3), call("read_cursor"))
+call("set_operator_inclusive", false)
+
+vim.api.nvim_feedkeys("z", "n", false)
+local packet = call("read_input")
+same(domain.InputPacketKind.TEXT, packet.kind)
+same("z", packet.text)
+same(false, call("open_fold", domain.Position.new(1, 3)))
+call("show_prompt", "clever-f: ")
+call("redraw", "screen")
+call("redraw", "full")
+same(false, call("redraw", "suppressed"))
+
+local notifications = {}
+vim.notify = function(text, level, options)
+ notifications[#notifications + 1] = {
+ text = text,
+ level = level,
+ title = options.title,
+ }
+end
+call("emit_diagnostic", "info", "adapter contract")
+same("adapter contract", notifications[1].text)
+same("clever-f", notifications[1].title)
+
+call("define_highlight_group", "CleverFContract", {
+ guifg = "red",
+ guibg = "NONE",
+ gui = { bold = true },
+}, { force = true })
+truthy(call("read_highlight_group", "CleverFContract") ~= nil)
+local highlight = call("create_highlight", {
+ group = "CleverFContract",
+ window = call("read_window"),
+ positions = { domain.Position.new(1, 1), domain.Position.new(1, 3) },
+ priority = "high",
+})
+truthy(call("remove_highlight", highlight))
+
+truthy(call("supports_timers"))
+local timer = call("start_timer", 100000, function() end)
+truthy(timer ~= nil)
+truthy(call("stop_timer", timer))
+
+local prior_guicursor = vim.o.guicursor
+local prior_terminal_cursor = vim.fn.eval("&t_ve")
+local presentation_supported = call("supports_cursor_presentation")
+local lease = call("suppress_cursor_presentation")
+if presentation_supported then
+ truthy(lease ~= nil)
+ truthy(call("restore_cursor_presentation", lease))
+ same(prior_guicursor, vim.o.guicursor)
+ same(prior_terminal_cursor, vim.fn.eval("&t_ve"))
+else
+ same(nil, lease)
+ same(false, call("restore_cursor_presentation", lease))
+end
+
+local delivered
+local registration = call("register_events", "CursorMoved", function(name, payload)
+ delivered = { name = name, payload = payload }
+end)
+local transition = call("begin_action_transition")
+call("deliver_event", "CursorMoved", {
+ buffer = call("read_buffer"),
+ window = call("read_window"),
+})
+same(nil, delivered)
+call("commit_action_transition", transition)
+same("CursorMoved", delivered.name)
+truthy(call("remove_event_registration", registration))
+
+local neutral_position = call("read_cursor")
+call("register_action", "ContractNeutral", function()
+ return domain.ActionOutcome.neutral(neutral_position)
+end)
+local mapping = call(
+ "register_mapping",
+ { "n" },
+ "<Plug>(CleverFContract)",
+ "ContractNeutral",
+ { silent = true, remap = false, preserve_count = true }
+)
+truthy(mapping ~= nil)
+local mapping_info = vim.fn.maparg("<Plug>(CleverFContract)", "n", false, true)
+same(1, mapping_info.silent)
+same(1, mapping_info.noremap)
+
+local target = domain.TargetValue.character("a", string.byte("a"))
+local payload = domain.DotPayload.new("f", target)
+same(payload, call("register_dot_repeat", payload, function()
+ return domain.ActionOutcome.neutral(neutral_position)
+end))
+
+for _, method_name in ipairs(capabilities.required_methods()) do
+ truthy(exercised[method_name], "adapter capability was not exercised: " .. method_name)
+end
+
+local suppression_global = host_adapter.configuration_global(
+ "suppress_default_mappings"
+)
+same("clever_f_not_overwrites_standard_mappings", suppression_global)
+for _, value in ipairs({ false, 0 }) do
+ vim.g[suppression_global] = value
+ local suppressed = composition_root.new({
+ host = host_adapter.new(),
+ }):activate()
+ same(false, suppressed.setup.install_default_mappings)
+ same(nil, next(suppressed.mappings))
+end
+vim.g[suppression_global] = nil
+
+io.stdout:write(string.format(
+ "Phase 15 adapter contract: %d capabilities passed\n",
+ #capabilities.required_methods()
+))
diff --git a/tests/plugin_smoke.lua b/tests/plugin_smoke.lua
new file mode 100644
index 0000000..3028723
--- /dev/null
+++ b/tests/plugin_smoke.lua
@@ -0,0 +1,104 @@
+local script = debug.getinfo(1, "S").source:sub(2)
+local root = script:match("^(.*)/tests/plugin_smoke%.lua$") or "."
+vim.opt.runtimepath:prepend(root)
+
+local function same(expected, actual, message)
+ if expected ~= actual then
+ error((message or "values differ")
+ .. ": expected " .. tostring(expected)
+ .. ", got " .. tostring(actual), 2)
+ end
+end
+
+local function truthy(value, message)
+ if not value then
+ error(message or "value must be true", 2)
+ end
+end
+
+local function feed(keys)
+ vim.api.nvim_feedkeys(vim.keycode(keys), "xt", false)
+ vim.cmd("redraw")
+end
+
+vim.g.clever_f_mark_cursor = 0
+vim.g.clever_f_mark_char = 0
+vim.g.clever_f_mark_direct = 0
+vim.g.clever_f_hide_cursor_on_cmdline = 0
+vim.g.clever_f_clean_labels_eagerly = 0
+vim.api.nvim_buf_set_lines(0, 0, -1, true, { "ababa" })
+vim.api.nvim_win_set_cursor(0, { 1, 0 })
+
+vim.cmd.runtime("plugin/clever_f.lua")
+same(1, vim.g.loaded_clever_f_lua)
+local clever_f = require("clever_f")
+local first_root = clever_f.root()
+truthy(first_root ~= nil)
+
+for _, mode in ipairs({ "n", "x", "o" }) do
+ for _, lhs in ipairs({ "f", "F", "t", "T" }) do
+ local mapping = vim.fn.maparg(lhs, mode, false, true)
+ same(1, mapping.silent, mode .. lhs)
+ same(1, mapping.noremap, mode .. lhs)
+ end
+end
+
+feed("2fb")
+same(1, vim.api.nvim_win_get_cursor(0)[1])
+same(3, vim.api.nvim_win_get_cursor(0)[2])
+same(
+ 4,
+ clever_f.state():to_table().contexts.n.previous_landing.byte_column
+)
+clever_f.Reset()
+
+vim.api.nvim_buf_set_lines(0, 0, -1, true, { "ababa" })
+vim.api.nvim_win_set_cursor(0, { 1, 0 })
+feed("vfb")
+same("v", vim.api.nvim_get_mode().mode)
+same(1, vim.api.nvim_win_get_cursor(0)[2])
+feed("<Esc>")
+clever_f.Reset()
+
+vim.api.nvim_buf_set_lines(0, 0, -1, true, { "hoge fuge piye poye" })
+vim.api.nvim_win_set_cursor(0, { 1, 0 })
+feed("dfe")
+same(" fuge piye poye", vim.api.nvim_get_current_line())
+truthy(clever_f.root():host():dot_repeat_payload() ~= nil)
+feed(".")
+same(" piye poye", vim.api.nvim_get_current_line())
+feed(".")
+same(" poye", vim.api.nvim_get_current_line())
+feed(".")
+same("", vim.api.nvim_get_current_line())
+
+local ok, diagnostic = pcall(clever_f.invoke_descriptor, "X")
+same(false, ok)
+same("clever-f: Invalid mapping 'X'", diagnostic)
+
+vim.g.clever_f_mark_char = 1
+vim.g.clever_f_mark_char_color = "Search"
+vim.api.nvim_exec_autocmds("ColorScheme", {})
+same(
+ "Search",
+ vim.api.nvim_get_hl(0, { name = "CleverFChar", link = true }).link
+)
+
+for _, name in ipairs({
+ "StartFindForward",
+ "StartFindBackward",
+ "StartTillForward",
+ "StartTillBackward",
+ "Reset",
+ "RepeatSameDirection",
+ "RepeatOppositeDirection",
+}) do
+ same("function", type(clever_f[name]), name)
+end
+same("function", type(clever_f.invoke_descriptor))
+same("function", type(clever_f._diagnostic_full_reset))
+
+vim.cmd.runtime("plugin/clever_f.lua")
+same(first_root, clever_f.root())
+
+io.stdout:write("Phase 15 plugin smoke: startup, mappings, actions, and dot passed\n")
diff --git a/tests/run.lua b/tests/run.lua
index fb84dd3..03aa725 100644
--- a/tests/run.lua
+++ b/tests/run.lua
@@ -8756,4 +8756,4 @@ for _, item in ipairs(tests) do
passed = passed + 1
end
-io.stdout:write(string.format("Phase 14: %d tests passed\n", passed))
+io.stdout:write(string.format("Phase 15 core: %d tests passed\n", passed))