summaryrefslogtreecommitdiff
path: root/lua/clever_tee/motion_executor.lua
diff options
context:
space:
mode:
Diffstat (limited to 'lua/clever_tee/motion_executor.lua')
-rw-r--r--lua/clever_tee/motion_executor.lua436
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