diff options
| -rw-r--r-- | lua/clever_f/motion_executor.lua | 159 | ||||
| -rw-r--r-- | tests/run.lua | 29 |
2 files changed, 188 insertions, 0 deletions
diff --git a/lua/clever_f/motion_executor.lua b/lua/clever_f/motion_executor.lua index 212a362..5dba08f 100644 --- a/lua/clever_f/motion_executor.lua +++ b/lua/clever_f/motion_executor.lua @@ -1,12 +1,22 @@ +local destination_engine = require("clever_f.destination_engine") local domain = require("clever_f.domain") +local text_topology = require("clever_f.text_topology") local M = {} +local MotionExecutor = {} +M.MotionExecutor = MotionExecutor M.ExecutionPath = { VISUAL = "visual", COMMAND = "command", } +local executor_records = setmetatable({}, { __mode = "k" }) + +local function fail(message, level) + error(message, (level or 1) + 1) +end + function M.execution_path(context) context = domain.ModeContext.from_full_mode(context) if context.visual_kind ~= nil then @@ -15,4 +25,153 @@ function M.execution_path(context) return M.ExecutionPath.COMMAND end +local function copy_options(options) + local result = {} + for key, value in pairs(options or {}) do + result[key] = value + end + return result +end + +local function normalize_options(options, dependencies) + if MotionExecutor.is(options) and dependencies == nil then + return options + end + if type(options) ~= "table" then + fail("MotionExecutor options must be a table", 3) + end + if options.host ~= nil then + if dependencies ~= nil then + fail("MotionExecutor dependencies must be part of its options", 3) + end + return options + end + local result = copy_options(dependencies) + result.host = options + return result +end + +local function require_host(host) + if type(host) ~= "table" + or type(host.read_cursor) ~= "function" + then + fail("MotionExecutor host must provide read_cursor", 3) + end + return host +end + +local function require_destination_engine(engine) + engine = engine or destination_engine.new() + if type(engine) ~= "table" or type(engine.calculate) ~= "function" then + fail("MotionExecutor destination engine must provide calculate", 3) + end + return engine +end + +local executor_metatable = { + __index = MotionExecutor, + __newindex = function() + fail("MotionExecutor values are immutable", 2) + end, + __tostring = function() + return "motion-executor" + end, + __metatable = "clever_f.motion_executor.MotionExecutor", +} + +function MotionExecutor.new(options, dependencies) + options = normalize_options(options, dependencies) + if MotionExecutor.is(options) then + return options + end + + local executor = setmetatable({}, executor_metatable) + executor_records[executor] = { + host = require_host(options.host), + destination_engine = require_destination_engine( + options.destination_engine or options.engine + ), + } + return executor +end + +function MotionExecutor.is(value) + return type(value) == "table" and executor_records[value] ~= nil +end + +local function execution_request(view, context, plan, count, first_move) + if not text_topology.TextView.is(view) then + fail("motion execution view must be a TextView", 3) + end + context = domain.ModeContext.from_full_mode(context) + if not domain.ResolvedMotionPlan.is(plan) then + fail("motion execution plan must be a ResolvedMotionPlan", 3) + end + count = domain.Count.new(count) + if type(first_move) ~= "boolean" then + fail("motion execution first_move must be a Boolean", 3) + end + return { + view = view, + context = context, + plan = plan, + count = count, + first_move = first_move, + } +end + +local function calculate(executor, request, origin) + return executor_records[executor].destination_engine:calculate( + request.view, + origin, + request.plan, + request.count, + request.first_move + ) +end + +function MotionExecutor:_execute_command(request) + local host = executor_records[self].host + local origin = host:read_cursor() + local outcome = calculate(self, request, origin) + return domain.ActionOutcome.from_search(outcome, request.plan.descriptor) +end + +function MotionExecutor:_execute_visual(request) + local host = executor_records[self].host + local origin = host:read_cursor() + local outcome = calculate(self, request, origin) + return domain.ActionOutcome.from_search(outcome, request.plan.descriptor) +end + +function MotionExecutor:execute(view, context, plan, count, first_move) + local request = execution_request(view, context, plan, count, first_move) + if M.execution_path(request.context) == M.ExecutionPath.VISUAL then + return self:_execute_visual(request) + end + return self:_execute_command(request) +end + +function M.new(options, dependencies) + return MotionExecutor.new(options, dependencies) +end + +function M.execute(host, view, context, plan, count, first_move, dependencies) + return MotionExecutor.new(host, dependencies):execute( + view, + context, + plan, + count, + first_move + ) +end + +M.run = M.execute + +setmetatable(M, { + __call = function(_, options, dependencies) + return MotionExecutor.new(options, dependencies) + end, +}) + return M diff --git a/tests/run.lua b/tests/run.lua index f10aa70..938d21a 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -3144,6 +3144,35 @@ test("Motion execution routes non-Visual contexts to the command path", function end end) +test("Command execution saves its origin before destination calculation", function() + local host = MemoryHost.new({ + buffer_lines = { "abc" }, + cursor = { line = 1, byte_column = 1 }, + }) + local view = text_topology.from_host(host) + local saved_origin + local engine = { + calculate = function(_, _, origin) + saved_origin = origin + host:set_cursor(domain.Position.new(1, 3)) + return domain.SearchOutcome.boundary_before_any(origin) + end, + } + local executor = motion_executor.new({ + host = host, + destination_engine = engine, + }) + local plan = motion_plan.build( + target_plan.build(target("z"), matching_policy()), + "f" + ) + + local outcome = executor:execute(view, "n", plan, 1, true) + same(domain.Position.new(1, 1), saved_origin) + same(domain.ActionKind.FAILED_SEARCH, outcome.kind) + same(domain.Position.new(1, 1), outcome.position) +end) + for _, item in ipairs(tests) do local ok, failure = xpcall(item.body, debug.traceback) if not ok then |
