summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJackson Moore <jacksonmoore@tuta.io>2026-09-04 14:23:03 +0200
committerJackson Moore <jacksonmoore@tuta.io>2026-09-04 14:23:03 +0200
commit970db02504aeaf5c392a1bcad2f148734ba6ea48 (patch)
treeb384b7e5e28e45d658112a6a8e1dcc71c3dbe374
parent5199ca2e6527ce83b80898c57a09e984beb1fb67 (diff)
Translate action outcomes
-rw-r--r--lua/clever_f/host_adapter.lua108
-rw-r--r--tests/run.lua71
2 files changed, 179 insertions, 0 deletions
diff --git a/lua/clever_f/host_adapter.lua b/lua/clever_f/host_adapter.lua
new file mode 100644
index 0000000..a0eec66
--- /dev/null
+++ b/lua/clever_f/host_adapter.lua
@@ -0,0 +1,108 @@
+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)
+ adapter_records[adapter] = {
+ runtime = require_runtime(current_runtime(options)),
+ }
+ 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
+
+local function escape_key(runtime)
+ if type(runtime.keycode) == "function" then
+ return runtime.keycode("<Esc>")
+ end
+ if type(runtime.api.nvim_replace_termcodes) == "function" then
+ return runtime.api.nvim_replace_termcodes("<Esc>", 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
diff --git a/tests/run.lua b/tests/run.lua
index 87b6e73..8c3c771 100644
--- a/tests/run.lua
+++ b/tests/run.lua
@@ -14,6 +14,7 @@ local composition_root = require("clever_f.composition_root")
local destination_engine = require("clever_f.destination_engine")
local direct_preview_planner = require("clever_f.direct_preview_planner")
local feedback_service = require("clever_f.feedback_service")
+local host_adapter = require("clever_f.host_adapter")
local case_policy = require("clever_f.case_policy")
local policy = require("clever_f.policy")
local repeat_resolver = require("clever_f.repeat_resolver")
@@ -360,6 +361,76 @@ test("ActionOutcome and DotPayload retain resolved result data", function()
same("problem", domain.ActionOutcome.error(destination, "problem").diagnostic)
end)
+test("Host adapter translates typed action outcomes to Nvim effects", function()
+ local effects = {}
+ local runtime = {
+ api = {
+ nvim_feedkeys = function(keys, mode, escape_csi)
+ effects[#effects + 1] = {
+ kind = "feedkeys",
+ keys = keys,
+ mode = mode,
+ escape_csi = escape_csi,
+ }
+ end,
+ },
+ keycode = function(key)
+ same("<Esc>", key)
+ return string.char(27)
+ end,
+ log = { levels = { ERROR = 4 } },
+ notify = function(text, level, options)
+ effects[#effects + 1] = {
+ kind = "diagnostic",
+ text = text,
+ level = level,
+ title = options.title,
+ }
+ end,
+ }
+ local adapter = host_adapter.new({ runtime = runtime })
+ local position = domain.Position.new(1, 1)
+ local complete = domain.SearchOutcome.complete(
+ domain.Position.new(1, 2),
+ 1
+ )
+ local failed = domain.SearchOutcome.boundary_before_any(position)
+ local no_effect = {
+ domain.ActionOutcome.neutral(position),
+ domain.ActionOutcome.empty(position),
+ domain.ActionOutcome.from_search(complete, "f"),
+ domain.ActionOutcome.from_search(failed, "f"),
+ }
+
+ for _, outcome in ipairs(no_effect) do
+ same(
+ host_adapter.ActionEffect.NONE,
+ adapter:translate_action_outcome(outcome)
+ )
+ end
+ same(0, #effects)
+
+ same(
+ host_adapter.ActionEffect.ESCAPE,
+ adapter:translate_action_outcome(domain.ActionOutcome.escape(position))
+ )
+ same("feedkeys", effects[1].kind)
+ same(string.char(27), effects[1].keys)
+ same("n", effects[1].mode)
+ falsy(effects[1].escape_csi)
+
+ same(
+ host_adapter.ActionEffect.ERROR,
+ adapter:translate_action_outcome(
+ domain.ActionOutcome.error(position, "adapter error")
+ )
+ )
+ same("diagnostic", effects[2].kind)
+ same("adapter error", effects[2].text)
+ same(4, effects[2].level)
+ same("clever-f", effects[2].title)
+end)
+
test("Capability contract reports every semantic method", function()
local host = MemoryHost.new()
same(host, capabilities.assert_implements(host))