diff options
Diffstat (limited to 'lua/clever_tee/motion_executor.lua')
| -rw-r--r-- | lua/clever_tee/motion_executor.lua | 436 |
1 files changed, 436 insertions, 0 deletions
diff --git a/lua/clever_tee/motion_executor.lua b/lua/clever_tee/motion_executor.lua new file mode 100644 index 0000000..4001661 --- /dev/null +++ b/lua/clever_tee/motion_executor.lua @@ -0,0 +1,436 @@ +local destination_engine = require("clever_tee.destination_engine") +local domain = require("clever_tee.domain") +local sequence_state = require("clever_tee.sequence_state") +local state_transitions = require("clever_tee.state_transitions") +local text_topology = require("clever_tee.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 + return M.ExecutionPath.VISUAL + end + return M.ExecutionPath.COMMAND +end + +function M.moved_forward(origin, destination) + origin = domain.Position.coerce(origin) + destination = domain.Position.coerce(destination) + return domain.Position.compare(destination, origin) > 0 +end + +function M.command_moved_forward(descriptor, origin, destination) + descriptor = domain.Descriptor.from_string(descriptor) + origin = domain.Position.coerce(origin) + destination = domain.Position.coerce(destination) + if descriptor.family == domain.Family.TILL + and domain.Position.stationary(origin, destination) + then + return false + end + return M.moved_forward(origin, destination) +end + +function M.create_dot_payload(plan) + if not domain.ResolvedMotionPlan.is(plan) then + fail("dot payload plan must be a ResolvedMotionPlan", 2) + end + return domain.DotPayload.new(plan.descriptor, plan.target_plan.target) +end + +function M.plan_for_dot_payload(plan, payload) + if not domain.ResolvedMotionPlan.is(plan) then + fail("dot replay plan must be a ResolvedMotionPlan", 2) + end + if not domain.DotPayload.is(payload) then + fail("dot replay payload must be a DotPayload", 2) + end + if payload.target ~= plan.target_plan.target then + fail("dot replay payload target must match its resolved target plan", 2) + end + return domain.ResolvedMotionPlan.new({ + target_plan = plan.target_plan, + descriptor = payload.descriptor, + search_scope = plan.search_scope, + endpoint_policy = plan.endpoint_policy, + }) +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" + or type(host.read_encoding) ~= "function" + or type(host.read_pending_operator) ~= "function" + or type(host.read_selection) ~= "function" + or type(host.read_text) ~= "function" + or type(host.apply_cursor) ~= "function" + or type(host.apply_selection) ~= "function" + or type(host.set_operator_inclusive) ~= "function" + or type(host.register_dot_repeat) ~= "function" + then + fail("MotionExecutor host must provide movement state", 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 function require_feedback_service(service) + if service ~= nil and (type(service) ~= "table" + or type(service.migrate_command) ~= "function") + then + fail("MotionExecutor feedback service must provide migrate_command", 3) + end + return service +end + +local function require_state(state) + state = state or sequence_state.get() + if not sequence_state.is(state) then + fail("MotionExecutor state must be the plugin-global SequenceState", 3) + end + return state +end + +local function require_transitions(transitions, state) + transitions = transitions or state_transitions.new(state) + if type(transitions) ~= "table" + or type(transitions.CommitCommandSuccess) ~= "function" + or type(transitions.CommitVisualSuccess) ~= "function" + then + fail("MotionExecutor transitions must commit motion success", 3) + end + return transitions +end + +local executor_metatable = { + __index = MotionExecutor, + __newindex = function() + fail("MotionExecutor values are immutable", 2) + end, + __tostring = function() + return "motion-executor" + end, + __metatable = "clever_tee.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) + local state = require_state(options.state) + executor_records[executor] = { + host = require_host(options.host), + destination_engine = require_destination_engine( + options.destination_engine or options.engine + ), + feedback_service = require_feedback_service( + options.feedback_service or options.feedback + ), + state = state, + transitions = require_transitions( + options.transitions or options.state_transitions, + state + ), + } + 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, + execution_options +) + 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 + execution_options = execution_options or {} + if type(execution_options) ~= "table" then + fail("motion execution options must be a table", 3) + end + local dot_payload = execution_options.dot_payload + if dot_payload ~= nil and not domain.DotPayload.is(dot_payload) then + fail("motion execution dot_payload must be a DotPayload", 3) + end + local register_dot_repeat = execution_options.register_dot_repeat + if register_dot_repeat == nil then + register_dot_repeat = true + elseif type(register_dot_repeat) ~= "boolean" then + fail("motion execution register_dot_repeat must be a Boolean", 3) + end + return { + view = view, + context = context, + plan = plan, + count = count, + first_move = first_move, + dot_payload = dot_payload, + register_dot_repeat = register_dot_repeat, + } +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 + +local function command_action( + host, + outcome, + descriptor, + dot_payload, + use_current_position +) + return domain.ActionOutcome.new({ + kind = outcome.complete + and domain.ActionKind.MOVEMENT + or domain.ActionKind.FAILED_SEARCH, + position = use_current_position and host:read_cursor() or outcome.endpoint, + search_outcome = outcome, + effective_descriptor = descriptor, + dot_payload = dot_payload, + }) +end + +local function register_dot_replay(executor, request, payload) + local host = executor_records[executor].host + host:register_dot_repeat(payload, function(replayed_payload, replay_count) + return executor:execute_dot( + text_topology.from_host(host), + request.context, + request.plan, + replayed_payload, + replay_count + ) + end) +end + +local function migrate_command_feedback(executor, request, origin, outcome) + local record = executor_records[executor] + local feedback = record.feedback_service + if feedback == nil then + return + end + feedback:migrate_command({ + context = request.context, + origin = origin, + destination = outcome.endpoint, + plan = request.plan, + resolved_motion_plan = request.plan, + outcome = outcome, + count = request.count, + first_move = request.first_move, + moved_forward = request.moved_forward, + previous_moved_forward = record.state.moved_forward, + previous_moved_forward_initialized = record.state.moved_forward_initialized, + }) +end + +function MotionExecutor:_execute_command(request) + local host = executor_records[self].host + local origin = host:read_cursor() + local pending_operator = request.context.operator + and host:read_pending_operator() + or nil + local outcome = calculate(self, request, origin) + if outcome.successful_steps > 0 then + if request.context.operator + and request.plan.descriptor.direction == domain.Direction.FORWARD + then + host:set_operator_inclusive(true) + end + host:apply_cursor(outcome.endpoint, { + context = request.context, + descriptor = request.plan.descriptor, + origin = origin, + }) + end + if not outcome.complete then + return command_action( + host, + outcome, + request.plan.descriptor, + nil, + pending_operator ~= nil and pending_operator ~= "" + ) + end + request.moved_forward = M.command_moved_forward( + request.plan.descriptor, + origin, + outcome.endpoint + ) + migrate_command_feedback(self, request, origin, outcome) + executor_records[self].transitions:CommitCommandSuccess( + request.context, + outcome.endpoint, + request.moved_forward + ) + local dot_payload + if pending_operator ~= nil and pending_operator ~= "" then + dot_payload = request.dot_payload or M.create_dot_payload(request.plan) + if request.register_dot_repeat then + register_dot_replay(self, request, dot_payload) + end + end + return command_action( + host, + outcome, + request.plan.descriptor, + dot_payload, + pending_operator ~= nil and pending_operator ~= "" + ) +end + +function MotionExecutor:_execute_visual(request) + local host = executor_records[self].host + local selection = host:read_selection() + if not domain.Selection.is(selection) + or not selection.active + or selection.kind ~= request.context.visual_kind + then + fail("Visual motion execution requires its active selection kind", 2) + end + local origin = host:read_cursor() + local outcome = calculate(self, request, origin) + request.selection = selection + if outcome.successful_steps > 0 then + host:apply_selection(selection:with_focus(outcome.endpoint)) + end + if not outcome.complete then + return domain.ActionOutcome.from_search(outcome, request.plan.descriptor) + end + executor_records[self].transitions:CommitVisualSuccess( + request.context, + outcome.endpoint + ) + return domain.ActionOutcome.from_search(outcome, request.plan.descriptor) +end + +function MotionExecutor:execute( + view, + context, + plan, + count, + first_move, + execution_options +) + local request = execution_request( + view, + context, + plan, + count, + first_move, + execution_options + ) + if M.execution_path(request.context) == M.ExecutionPath.VISUAL then + return self:_execute_visual(request) + end + return self:_execute_command(request) +end + +function MotionExecutor:execute_dot(view, context, plan, payload, count) + return self:execute( + view, + context, + M.plan_for_dot_payload(plan, payload), + count, + false, + { + dot_payload = payload, + register_dot_repeat = false, + } + ) +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 |
