summaryrefslogtreecommitdiff
path: root/lua/clever_tee/sequence_coordinator.lua
diff options
context:
space:
mode:
Diffstat (limited to 'lua/clever_tee/sequence_coordinator.lua')
-rw-r--r--lua/clever_tee/sequence_coordinator.lua675
1 files changed, 675 insertions, 0 deletions
diff --git a/lua/clever_tee/sequence_coordinator.lua b/lua/clever_tee/sequence_coordinator.lua
new file mode 100644
index 0000000..1437d6c
--- /dev/null
+++ b/lua/clever_tee/sequence_coordinator.lua
@@ -0,0 +1,675 @@
+local acquisition_service_factory = require("clever_tee.acquisition_service")
+local case_policy = require("clever_tee.case_policy")
+local direct_preview_planner = require("clever_tee.direct_preview_planner")
+local domain = require("clever_tee.domain")
+local feedback_service_factory = require("clever_tee.feedback_service")
+local motion_executor_factory = require("clever_tee.motion_executor")
+local motion_plan_factory = require("clever_tee.motion_plan")
+local policy = require("clever_tee.policy")
+local repeat_resolver_factory = require("clever_tee.repeat_resolver")
+local sequence_state = require("clever_tee.sequence_state")
+local state_transitions = require("clever_tee.state_transitions")
+local target_plan_factory = require("clever_tee.target_plan")
+local text_topology = require("clever_tee.text_topology")
+
+local M = {}
+local SequenceCoordinator = {}
+SequenceCoordinator.__index = SequenceCoordinator
+M.SequenceCoordinator = SequenceCoordinator
+
+local coordinator_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function descriptor_text(value)
+ if domain.Descriptor.is(value) then
+ return value.value
+ end
+ return tostring(value)
+end
+
+function M.validate_primary_descriptor(value)
+ local descriptor = domain.Descriptor.try_from_string(value)
+ if descriptor == nil then
+ error("clever-tee: Invalid mapping '" .. descriptor_text(value) .. "'", 0)
+ end
+ return descriptor
+end
+
+function SequenceCoordinator.new(options)
+ if SequenceCoordinator.is(options) then
+ return options
+ end
+ if type(options) ~= "table" then
+ fail("SequenceCoordinator options must be a table", 2)
+ end
+ local host = options.host or options
+ local state = options.state or sequence_state.get()
+ if not sequence_state.is(state) then
+ fail("SequenceCoordinator state must be the plugin-global SequenceState", 2)
+ end
+ local transitions = options.transitions
+ or options.state_transitions
+ or state_transitions.new(state)
+ local policy_service = options.policy
+ or options.policy_service
+ or policy.new(host)
+ local resolver = options.repeat_resolver
+ or options.resolver
+ or repeat_resolver_factory.new({
+ state = state,
+ transitions = transitions,
+ policy = policy_service,
+ clock = host,
+ })
+ if type(resolver) ~= "table" or type(resolver.decide) ~= "function" then
+ fail("SequenceCoordinator repeat resolver must provide decide", 2)
+ end
+ local feedback = options.feedback
+ or options.feedback_service
+ or feedback_service_factory.new({
+ host = host,
+ state = state,
+ transitions = transitions,
+ policy = policy_service,
+ })
+ local lowercase = options.lowercase
+ if lowercase == nil and type(host.lowercase) == "function" then
+ lowercase = function(value)
+ return host:lowercase(value)
+ end
+ end
+ local case_resolver = options.case_resolver
+ or case_policy.new({ lowercase = lowercase })
+ local target_factory = options.target_factory
+ or options.target_plan_factory
+ or target_plan_factory.new({
+ policy = policy_service,
+ case_resolver = case_resolver,
+ })
+ if type(target_factory) ~= "table" or type(target_factory.build) ~= "function" then
+ fail("SequenceCoordinator target factory must provide build", 2)
+ end
+ local motion_factory = options.motion_factory
+ or options.motion_plan_factory
+ or motion_plan_factory.new({ policy = policy_service })
+ if type(motion_factory) ~= "table"
+ or type(motion_factory.build_for_context) ~= "function"
+ then
+ fail("SequenceCoordinator motion factory must build contextual plans", 2)
+ end
+ local direct_planner = options.direct_planner
+ or options.direct_preview_planner
+ or direct_preview_planner.new({ case_resolver = case_resolver })
+ local acquisition = options.acquisition
+ or options.acquisition_service
+ or acquisition_service_factory.new({
+ host = host,
+ state = state,
+ transitions = transitions,
+ policy = policy_service,
+ feedback = feedback,
+ direct_planner = direct_planner,
+ target_factory = target_factory,
+ motion_factory = motion_factory,
+ })
+ if type(acquisition) ~= "table" or type(acquisition.acquire) ~= "function" then
+ fail("SequenceCoordinator acquisition service must provide acquire", 2)
+ end
+ local executor = options.motion_executor
+ or options.executor
+ or motion_executor_factory.new({
+ host = host,
+ state = state,
+ transitions = transitions,
+ feedback = feedback,
+ })
+ if type(executor) ~= "table" or type(executor.execute) ~= "function" then
+ fail("SequenceCoordinator motion executor must provide execute", 2)
+ end
+
+ local coordinator = setmetatable({}, SequenceCoordinator)
+ coordinator_records[coordinator] = {
+ host = host,
+ state = state,
+ transitions = transitions,
+ policy = policy_service,
+ repeat_resolver = resolver,
+ feedback = feedback,
+ target_factory = target_factory,
+ motion_factory = motion_factory,
+ acquisition = acquisition,
+ motion_executor = executor,
+ last_primary_resolution = nil,
+ last_explicit_resolution = nil,
+ }
+ return coordinator
+end
+
+function SequenceCoordinator.is(value)
+ return type(value) == "table" and coordinator_records[value] ~= nil
+end
+
+function SequenceCoordinator:validate_primary_descriptor(value)
+ return M.validate_primary_descriptor(value)
+end
+
+local function require_primary_reader(host)
+ if type(host) ~= "table"
+ or type(host.read_mode) ~= "function"
+ or type(host.read_cursor) ~= "function"
+ or type(host.read_count) ~= "function"
+ or type(host.read_macro_state) ~= "function"
+ then
+ fail("SequenceCoordinator host must provide primary action state", 3)
+ end
+ return host
+end
+
+function SequenceCoordinator:read_primary_invocation()
+ local host = require_primary_reader(coordinator_records[self].host)
+ local context = domain.ModeContext.from_full_mode(host:read_mode())
+ local position = domain.Position.coerce(host:read_cursor())
+ local count = domain.Count.new(host:read_count())
+ local macro_state = domain.MacroState.new(host:read_macro_state())
+ return {
+ context = context,
+ position = position,
+ origin = position,
+ count = count,
+ macro_state = macro_state,
+ }
+end
+
+function SequenceCoordinator:inspect_fold_open_policy(invocation)
+ if type(invocation) ~= "table" or not domain.Position.is(invocation.position) then
+ fail("fold preflight requires primary invocation state", 2)
+ end
+ local host = coordinator_records[self].host
+ if type(host.read_fold_state) ~= "function" then
+ fail("SequenceCoordinator host must provide fold state", 2)
+ end
+ local fold_state = host:read_fold_state()
+ if not domain.FoldState.is(fold_state) then
+ fail("SequenceCoordinator host must return FoldState", 2)
+ end
+ return fold_state
+end
+
+local function fold_open_enabled(fold_state)
+ return fold_state:opens("horizontal") or fold_state:opens("all")
+end
+
+function SequenceCoordinator:open_enclosing_folds(invocation, fold_state)
+ if type(invocation) ~= "table" or not domain.Position.is(invocation.position) then
+ fail("fold opening requires primary invocation state", 2)
+ end
+ if not domain.FoldState.is(fold_state) then
+ fail("fold opening requires FoldState", 2)
+ end
+ if not fold_open_enabled(fold_state) then
+ return 0
+ end
+
+ local host = coordinator_records[self].host
+ if type(host.open_fold) ~= "function" then
+ fail("SequenceCoordinator host must open folds", 2)
+ end
+ local opened = 0
+ while fold_state.closed_levels > 0 do
+ if host:open_fold(invocation.position) ~= true then
+ break
+ end
+ opened = opened + 1
+ fold_state = self:inspect_fold_open_policy(invocation)
+ end
+ return opened
+end
+
+function SequenceCoordinator:decide_primary(invocation)
+ if type(invocation) ~= "table"
+ or not domain.ModeContext.is(invocation.context)
+ or not domain.Position.is(invocation.position)
+ or not domain.MacroState.is(invocation.macro_state)
+ then
+ fail("primary decision requires invocation state", 2)
+ end
+ return coordinator_records[self].repeat_resolver:decide(
+ invocation.context,
+ invocation.position,
+ invocation.macro_state
+ )
+end
+
+function SequenceCoordinator:acquire_primary(descriptor, invocation)
+ descriptor = self:validate_primary_descriptor(descriptor)
+ if type(invocation) ~= "table" then
+ fail("primary acquisition requires invocation state", 2)
+ end
+ return coordinator_records[self].acquisition:acquire(
+ descriptor,
+ invocation.context,
+ invocation.position,
+ invocation.count,
+ invocation.macro_state
+ )
+end
+
+function SequenceCoordinator:fresh_primary_resolution(
+ initiating_descriptor,
+ acquisition_result,
+ invocation
+)
+ initiating_descriptor = self:validate_primary_descriptor(initiating_descriptor)
+ if type(acquisition_result) ~= "table"
+ or acquisition_result.resolved ~= true
+ or not domain.TargetValue.is(acquisition_result.target)
+ or not domain.TargetPlan.is(acquisition_result.target_plan)
+ or not domain.ResolvedMotionPlan.is(acquisition_result.motion_plan)
+ then
+ fail("fresh primary resolution requires acquired motion plans", 2)
+ end
+ return {
+ kind = "fresh",
+ invocation = invocation,
+ acquisition_result = acquisition_result,
+ target = acquisition_result.target,
+ target_plan = acquisition_result.target_plan,
+ motion_plan = acquisition_result.motion_plan,
+ effective_descriptor = initiating_descriptor,
+ first_move = coordinator_records[self].state:get_first_move(
+ invocation.context
+ ) == true,
+ skip_destination = acquisition_result.target_plan.kind
+ == domain.TargetPlanKind.EMPTY,
+ }
+end
+
+function SequenceCoordinator:resolve_acquisition(descriptor, invocation)
+ local result = self:acquire_primary(descriptor, invocation)
+ if type(result.has_outcome) ~= "function" then
+ fail("AcquisitionService must return an AcquisitionResult", 2)
+ end
+ if result:has_outcome() then
+ return result.outcome
+ end
+ return self:fresh_primary_resolution(descriptor, result, invocation)
+end
+
+function SequenceCoordinator:evaluate_repeat_timeout(invocation)
+ if type(invocation) ~= "table" then
+ fail("repeat timeout requires primary invocation state", 2)
+ end
+ local record = coordinator_records[self]
+ if type(record.repeat_resolver.evaluate_timeout) ~= "function" then
+ fail("SequenceCoordinator repeat resolver must evaluate timeout", 2)
+ end
+ local window = record.host:read_window()
+ local decision, elapsed_ms, cleanup =
+ record.repeat_resolver:evaluate_timeout(window)
+ return {
+ decision = decision,
+ elapsed_ms = elapsed_ms,
+ cleanup = cleanup,
+ window = window,
+ }
+end
+
+function SequenceCoordinator:build_live_target_plan(target, invocation)
+ if not domain.TargetValue.is(target) then
+ fail("primary target planning requires a TargetValue", 2)
+ end
+ if type(invocation) ~= "table" or not domain.Position.is(invocation.position) then
+ fail("primary target planning requires invocation state", 2)
+ end
+ local record = coordinator_records[self]
+ local view = text_topology.from_host(record.host)
+ local sampled_search = record.policy:sample_search()
+ local target_plan = record.target_factory:build(target, nil, {
+ text_view = view,
+ origin = invocation.position,
+ search_scope = sampled_search.search_scope,
+ effective_encoding = view.effective_encoding,
+ })
+ if not domain.TargetPlan.is(target_plan) then
+ fail("TargetPlanFactory must return a TargetPlan", 2)
+ end
+ return target_plan, view, sampled_search.search_scope
+end
+
+function SequenceCoordinator:build_movement_plan(
+ target_plan,
+ effective_descriptor,
+ invocation,
+ search_scope
+)
+ if not domain.TargetPlan.is(target_plan) then
+ fail("movement planning requires a TargetPlan", 2)
+ end
+ if type(invocation) ~= "table" or not domain.ModeContext.is(invocation.context) then
+ fail("movement planning requires invocation state", 2)
+ end
+ local record = coordinator_records[self]
+ local selection = invocation.context.visual and record.host:read_selection() or nil
+ local motion_plan = record.motion_factory:build_for_context(
+ target_plan,
+ effective_descriptor,
+ invocation.context,
+ selection,
+ search_scope
+ )
+ if not domain.ResolvedMotionPlan.is(motion_plan) then
+ fail("MotionPlanFactory must return a ResolvedMotionPlan", 2)
+ end
+ return motion_plan
+end
+
+function SequenceCoordinator:restore_repeated_feedback(resolution)
+ if type(resolution) ~= "table"
+ or not domain.TargetPlan.is(resolution.target_plan)
+ or not domain.ResolvedMotionPlan.is(resolution.motion_plan)
+ then
+ fail("feedback restoration requires a repeated primary resolution", 2)
+ end
+ local feedback = coordinator_records[self].feedback
+ if type(feedback.restore_primary) ~= "function" then
+ fail("FeedbackService must restore primary feedback", 2)
+ end
+ return feedback:restore_primary({
+ context = resolution.invocation.context,
+ anchor = resolution.invocation.position,
+ target_plan = resolution.target_plan,
+ motion_plan = resolution.motion_plan,
+ stored_descriptor = resolution.stored_descriptor,
+ endpoint_policy = resolution.motion_plan.endpoint_policy,
+ text_view = resolution.text_view,
+ window = resolution.timeout.window,
+ })
+end
+
+function SequenceCoordinator:stored_primary_resolution(
+ invocation,
+ pressed_descriptor,
+ timeout
+)
+ if type(invocation) ~= "table" or not domain.ModeContext.is(invocation.context) then
+ fail("stored primary resolution requires invocation state", 2)
+ end
+ pressed_descriptor = self:validate_primary_descriptor(pressed_descriptor)
+ local state = coordinator_records[self].state
+ local stored_descriptor = state:get_previous_descriptor(invocation.context)
+ local stored_target = state:get_previous_target(invocation.context)
+ if stored_descriptor == nil or stored_target == nil then
+ fail("repeat-eligible primary state must contain descriptor and target", 2)
+ end
+ local resolver = coordinator_records[self].repeat_resolver
+ if type(resolver.resolve_primary_direction) ~= "function" then
+ fail("SequenceCoordinator repeat resolver must resolve primary direction", 2)
+ end
+ local effective_descriptor = domain.Descriptor.from_string(
+ resolver:resolve_primary_direction(stored_descriptor, pressed_descriptor)
+ )
+ if effective_descriptor.family ~= stored_descriptor.family then
+ fail("primary repetition must preserve the stored motion family", 2)
+ end
+ local target_plan, text_view, search_scope = self:build_live_target_plan(
+ stored_target,
+ invocation
+ )
+ local motion_plan = self:build_movement_plan(
+ target_plan,
+ effective_descriptor,
+ invocation,
+ search_scope
+ )
+ local resolution = {
+ kind = "repeat",
+ invocation = invocation,
+ pressed_descriptor = pressed_descriptor,
+ timeout = timeout,
+ stored_descriptor = stored_descriptor,
+ target = stored_target,
+ target_plan = target_plan,
+ motion_plan = motion_plan,
+ text_view = text_view,
+ search_scope = search_scope,
+ effective_descriptor = effective_descriptor,
+ first_move = state:get_first_move(invocation.context) == true,
+ }
+ resolution.restored_feedback = self:restore_repeated_feedback(resolution)
+ return resolution
+end
+
+function SequenceCoordinator:refresh_primary_feedback(resolution)
+ if type(resolution) ~= "table" or not domain.TargetValue.is(resolution.target) then
+ fail("primary feedback refresh requires a resolved target", 2)
+ end
+ local record = coordinator_records[self]
+ if type(record.feedback.refresh_primary) ~= "function" then
+ fail("FeedbackService must refresh primary feedback", 2)
+ end
+ local window = resolution.timeout and resolution.timeout.window
+ or record.host:read_window()
+ return record.feedback:refresh_primary(resolution.target, window)
+end
+
+function SequenceCoordinator:execute_resolved_motion(resolution, execution_options)
+ if type(resolution) ~= "table"
+ or not domain.ModeContext.is(resolution.invocation.context)
+ or not domain.ResolvedMotionPlan.is(resolution.motion_plan)
+ then
+ fail("motion execution requires a resolved motion", 2)
+ end
+ if resolution.skip_destination then
+ return domain.ActionOutcome.empty(resolution.invocation.position)
+ end
+ local record = coordinator_records[self]
+ local view = resolution.text_view or text_topology.from_host(record.host)
+ local outcome = record.motion_executor:execute(
+ view,
+ resolution.invocation.context,
+ resolution.motion_plan,
+ resolution.invocation.count,
+ resolution.first_move,
+ execution_options
+ )
+ if not domain.ActionOutcome.is(outcome) then
+ fail("MotionExecutor must return an ActionOutcome", 2)
+ end
+ return outcome
+end
+
+function SequenceCoordinator:execute_primary_resolution(resolution)
+ local record = coordinator_records[self]
+ record.last_primary_resolution = resolution
+ local outcome = self:execute_resolved_motion(resolution)
+ resolution.highlight_timer = self:refresh_primary_feedback(resolution)
+ return outcome
+end
+
+function SequenceCoordinator:last_primary_resolution()
+ return coordinator_records[self].last_primary_resolution
+end
+
+function SequenceCoordinator:last_explicit_resolution()
+ return coordinator_records[self].last_explicit_resolution
+end
+
+function SequenceCoordinator:reset()
+ local record = coordinator_records[self]
+ local position = domain.Position.coerce(record.host:read_cursor())
+ local cleanup = record.transitions:PublicReset(record.host:read_window())
+ if type(record.feedback.release_transition_cleanup) ~= "function" then
+ fail("FeedbackService must release reset cleanup", 2)
+ end
+ record.feedback:release_transition_cleanup(cleanup)
+ return domain.ActionOutcome.neutral(position)
+end
+
+SequenceCoordinator.Reset = SequenceCoordinator.reset
+
+function SequenceCoordinator:diagnostic_full_reset()
+ local record = coordinator_records[self]
+ local position = domain.Position.coerce(record.host:read_cursor())
+ local cleanup = record.transitions:DiagnosticFullReset(
+ record.host:read_window()
+ )
+ if type(record.feedback.release_transition_cleanup) ~= "function" then
+ fail("FeedbackService must release diagnostic cleanup", 2)
+ end
+ record.feedback:release_transition_cleanup(cleanup)
+ return domain.ActionOutcome.neutral(position)
+end
+
+SequenceCoordinator.DiagnosticFullReset =
+ SequenceCoordinator.diagnostic_full_reset
+
+function SequenceCoordinator:read_explicit_invocation()
+ local host = coordinator_records[self].host
+ if type(host) ~= "table"
+ or type(host.read_mode) ~= "function"
+ or type(host.read_cursor) ~= "function"
+ or type(host.read_count) ~= "function"
+ then
+ fail("SequenceCoordinator host must provide explicit action state", 2)
+ end
+ local position = domain.Position.coerce(host:read_cursor())
+ return {
+ context = domain.ModeContext.from_full_mode(host:read_mode()),
+ position = position,
+ origin = position,
+ count = domain.Count.new(host:read_count()),
+ }
+end
+
+function SequenceCoordinator:resolve_explicit(kind, resolver_method)
+ if type(kind) ~= "string" or kind == "" then
+ fail("explicit repeat kind must be a nonempty string", 2)
+ end
+ if type(resolver_method) ~= "string" or resolver_method == "" then
+ fail("explicit repeat resolver method must be a nonempty string", 2)
+ end
+ local invocation = self:read_explicit_invocation()
+ local resolver = coordinator_records[self].repeat_resolver
+ if type(resolver[resolver_method]) ~= "function" then
+ fail("RepeatResolver must build " .. kind .. " requests", 2)
+ end
+ local request = resolver[resolver_method](resolver, invocation.context)
+ local resolution = {
+ kind = kind,
+ invocation = invocation,
+ request = request,
+ }
+ if request.neutral then
+ return resolution
+ end
+ local target_plan, text_view, search_scope = self:build_live_target_plan(
+ request.target,
+ invocation
+ )
+ local motion_plan = self:build_movement_plan(
+ target_plan,
+ request.descriptor,
+ invocation,
+ search_scope
+ )
+ resolution.target = request.target
+ resolution.target_plan = target_plan
+ resolution.motion_plan = motion_plan
+ resolution.text_view = text_view
+ resolution.search_scope = search_scope
+ resolution.effective_descriptor = request.descriptor
+ resolution.first_move = coordinator_records[self].state:get_first_move(
+ invocation.context
+ ) == true
+ resolution.skip_destination = target_plan.kind == domain.TargetPlanKind.EMPTY
+ return resolution
+end
+
+function SequenceCoordinator:resolve_explicit_same()
+ return self:resolve_explicit("explicit_same", "same_direction_request")
+end
+
+function SequenceCoordinator:resolve_explicit_opposite()
+ return self:resolve_explicit(
+ "explicit_opposite",
+ "opposite_direction_request"
+ )
+end
+
+function SequenceCoordinator:primary(value)
+ local descriptor = self:validate_primary_descriptor(value)
+ local invocation = self:read_primary_invocation()
+ invocation.fold_state = self:inspect_fold_open_policy(invocation)
+ invocation.opened_folds = self:open_enclosing_folds(
+ invocation,
+ invocation.fold_state
+ )
+ invocation.repeat_decision = self:decide_primary(invocation)
+ if invocation.repeat_decision == repeat_resolver_factory.Decision.ACQUIRE then
+ local acquired = self:resolve_acquisition(descriptor, invocation)
+ if domain.ActionOutcome.is(acquired) then
+ return acquired
+ end
+ return self:execute_primary_resolution(acquired)
+ end
+ local timeout = self:evaluate_repeat_timeout(invocation)
+ if timeout.decision == repeat_resolver_factory.Decision.ACQUIRE then
+ local record = coordinator_records[self]
+ if timeout.cleanup ~= nil then
+ if type(record.feedback.release_transition_cleanup) ~= "function" then
+ fail("FeedbackService must release reset cleanup", 2)
+ end
+ record.feedback:release_transition_cleanup(timeout.cleanup)
+ end
+ local acquired = self:resolve_acquisition(descriptor, invocation)
+ if domain.ActionOutcome.is(acquired) then
+ return acquired
+ end
+ return self:execute_primary_resolution(acquired)
+ end
+ return self:execute_primary_resolution(
+ self:stored_primary_resolution(invocation, descriptor, timeout)
+ )
+end
+
+function SequenceCoordinator:execute_explicit_resolution(resolution)
+ if type(resolution) ~= "table"
+ or not domain.ExplicitRepeatRequest.is(resolution.request)
+ then
+ fail("explicit execution requires a resolved repeat request", 2)
+ end
+ coordinator_records[self].last_explicit_resolution = resolution
+ if resolution.request.neutral then
+ return domain.ActionOutcome.empty(resolution.invocation.position)
+ end
+ return self:execute_resolved_motion(resolution)
+end
+
+function SequenceCoordinator:repeat_same_direction()
+ return self:execute_explicit_resolution(self:resolve_explicit_same())
+end
+
+SequenceCoordinator.RepeatSameDirection =
+ SequenceCoordinator.repeat_same_direction
+
+function SequenceCoordinator:repeat_opposite_direction()
+ return self:execute_explicit_resolution(self:resolve_explicit_opposite())
+end
+
+SequenceCoordinator.RepeatOppositeDirection =
+ SequenceCoordinator.repeat_opposite_direction
+
+function M.new(options)
+ return SequenceCoordinator.new(options)
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return SequenceCoordinator.new(options)
+ end,
+})
+
+return M