summaryrefslogtreecommitdiff
path: root/lua/clever_tee
diff options
context:
space:
mode:
Diffstat (limited to 'lua/clever_tee')
-rw-r--r--lua/clever_tee/acquisition_service.lua927
-rw-r--r--lua/clever_tee/action_facade.lua134
-rw-r--r--lua/clever_tee/capabilities.lua163
-rw-r--r--lua/clever_tee/case_policy.lua190
-rw-r--r--lua/clever_tee/composition_root.lua232
-rw-r--r--lua/clever_tee/destination_engine.lua201
-rw-r--r--lua/clever_tee/direct_preview_planner.lua250
-rw-r--r--lua/clever_tee/domain.lua1390
-rw-r--r--lua/clever_tee/feedback_service.lua1003
-rw-r--r--lua/clever_tee/host_adapter.lua1165
-rw-r--r--lua/clever_tee/init.lua118
-rw-r--r--lua/clever_tee/migemo_catalog.lua527
-rw-r--r--lua/clever_tee/motion_executor.lua436
-rw-r--r--lua/clever_tee/motion_plan.lua188
-rw-r--r--lua/clever_tee/policy.lua488
-rw-r--r--lua/clever_tee/repeat_resolver.lua333
-rw-r--r--lua/clever_tee/sequence_coordinator.lua675
-rw-r--r--lua/clever_tee/sequence_state.lua302
-rw-r--r--lua/clever_tee/state_transitions.lua493
-rw-r--r--lua/clever_tee/target_plan.lua541
-rw-r--r--lua/clever_tee/testing/memory_host.lua1075
-rw-r--r--lua/clever_tee/text_topology.lua1031
22 files changed, 11862 insertions, 0 deletions
diff --git a/lua/clever_tee/acquisition_service.lua b/lua/clever_tee/acquisition_service.lua
new file mode 100644
index 0000000..6e91159
--- /dev/null
+++ b/lua/clever_tee/acquisition_service.lua
@@ -0,0 +1,927 @@
+local domain = require("clever_tee.domain")
+local direct_preview_planner = require("clever_tee.direct_preview_planner")
+local feedback_service = require("clever_tee.feedback_service")
+local motion_plan_factory = require("clever_tee.motion_plan")
+local policy = require("clever_tee.policy")
+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 AcquisitionRequest = {}
+local AcquisitionResult = {}
+local AcquisitionService = {}
+local TemporaryResourceScope = {}
+AcquisitionService.__index = AcquisitionService
+M.AcquisitionRequest = AcquisitionRequest
+M.AcquisitionResult = AcquisitionResult
+M.AcquisitionService = AcquisitionService
+M.TemporaryResourceScope = TemporaryResourceScope
+M.RepeatedDirection = {
+ SAME = "same",
+}
+M.PROMPT = "clever-tee: "
+M.PREVIOUS_INPUT_NOT_FOUND = "Previous input not found."
+
+local request_records = setmetatable({}, { __mode = "k" })
+local result_records = setmetatable({}, { __mode = "k" })
+local service_records = setmetatable({}, { __mode = "k" })
+local scope_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function normalize_macro_state(value)
+ if type(value) == "table" and not domain.MacroState.is(value) then
+ value = value.register
+ end
+ return domain.MacroState.new(value)
+end
+
+local request_metatable = {
+ __index = function(request, key)
+ local method = AcquisitionRequest[key]
+ if method ~= nil then
+ return method
+ end
+ return request_records[request][key]
+ end,
+ __newindex = function()
+ fail("AcquisitionRequest values are immutable", 2)
+ end,
+ __tostring = function(request)
+ return "acquisition-request:" .. request_records[request].descriptor.value
+ end,
+ __metatable = "clever_tee.acquisition_service.AcquisitionRequest",
+}
+
+function AcquisitionRequest.new(descriptor, context, position, count, macro_state)
+ if AcquisitionRequest.is(descriptor) then
+ return descriptor
+ end
+ if type(descriptor) == "table" and not domain.Descriptor.is(descriptor) then
+ local options = descriptor
+ descriptor = options.descriptor
+ context = options.context
+ position = options.position or options.origin
+ count = options.count
+ macro_state = options.macro_state
+ end
+
+ local request = setmetatable({}, request_metatable)
+ request_records[request] = {
+ descriptor = domain.Descriptor.from_string(descriptor),
+ context = domain.ModeContext.from_full_mode(context),
+ position = domain.Position.coerce(position),
+ count = domain.Count.new(count),
+ macro_state = normalize_macro_state(macro_state),
+ repeated_direction = M.RepeatedDirection.SAME,
+ }
+ return request
+end
+
+function AcquisitionRequest.is(value)
+ return type(value) == "table" and request_records[value] ~= nil
+end
+
+function AcquisitionRequest:to_table()
+ return {
+ descriptor = self.descriptor.value,
+ context = self.context.key,
+ position = self.position:to_table(),
+ count = self.count.value,
+ macro_register = self.macro_state.register,
+ repeated_direction = self.repeated_direction,
+ }
+end
+
+local result_metatable = {
+ __index = function(result, key)
+ local method = AcquisitionResult[key]
+ if method ~= nil then
+ return method
+ end
+ return result_records[result][key]
+ end,
+ __newindex = function()
+ fail("AcquisitionResult values are immutable", 2)
+ end,
+ __tostring = function(result)
+ local outcome = result_records[result].outcome
+ return outcome and tostring(outcome) or "acquisition:resolved"
+ end,
+ __metatable = "clever_tee.acquisition_service.AcquisitionResult",
+}
+
+function AcquisitionResult.new(request, options)
+ if AcquisitionResult.is(request) and options == nil then
+ return request
+ end
+ if not AcquisitionRequest.is(request) then
+ fail("acquisition result requires an AcquisitionRequest", 2)
+ end
+ options = options or {}
+ if type(options) ~= "table" then
+ fail("acquisition result options must be a table", 2)
+ end
+ local outcome = options.outcome
+ if outcome ~= nil and not domain.ActionOutcome.is(outcome) then
+ fail("acquisition result outcome must be an ActionOutcome", 2)
+ end
+ local target = options.target
+ if target ~= nil and not domain.TargetValue.is(target) then
+ fail("acquisition result target must be a TargetValue", 2)
+ end
+ local target_plan = options.target_plan
+ if target_plan ~= nil and not domain.TargetPlan.is(target_plan) then
+ fail("acquisition result target_plan must be a TargetPlan", 2)
+ end
+ local motion_plan = options.motion_plan
+ if motion_plan ~= nil and not domain.ResolvedMotionPlan.is(motion_plan) then
+ fail("acquisition result motion_plan must be a ResolvedMotionPlan", 2)
+ end
+ local result = setmetatable({}, result_metatable)
+ result_records[result] = {
+ request = request,
+ outcome = outcome,
+ target = target,
+ target_plan = target_plan,
+ motion_plan = motion_plan,
+ resolved_motion_plan = motion_plan,
+ previous_input_trigger = options.previous_input_trigger,
+ previous_target_source = options.previous_target_source,
+ cached_target = options.cached_target,
+ missing_previous_input = options.missing_previous_input == true,
+ acquisition_time_ms = options.acquisition_time_ms,
+ persistent_feedback_request = options.persistent_feedback_request,
+ resolved = target ~= nil and target_plan ~= nil and motion_plan ~= nil,
+ completed = outcome ~= nil
+ or (target ~= nil and target_plan ~= nil and motion_plan ~= nil),
+ }
+ return result
+end
+
+function AcquisitionResult.is(value)
+ return type(value) == "table" and result_records[value] ~= nil
+end
+
+function AcquisitionResult:has_outcome()
+ return self.outcome ~= nil
+end
+
+function AcquisitionResult:resolved_values()
+ return self.target, self.target_plan, self.motion_plan
+end
+
+AcquisitionResult.unpack = AcquisitionResult.resolved_values
+
+local scope_metatable = {
+ __index = function(scope, key)
+ local method = TemporaryResourceScope[key]
+ if method ~= nil then
+ return method
+ end
+ return scope_records[scope][key]
+ end,
+ __newindex = function()
+ fail("TemporaryResourceScope values are read-only", 2)
+ end,
+ __metatable = "clever_tee.acquisition_service.TemporaryResourceScope",
+}
+
+function TemporaryResourceScope.new(request, feedback, host)
+ if not AcquisitionRequest.is(request) then
+ fail("temporary resource scope requires an AcquisitionRequest", 2)
+ end
+ local scope = setmetatable({}, scope_metatable)
+ scope_records[scope] = {
+ request = request,
+ feedback = feedback,
+ host = host,
+ active = true,
+ interactive = not request.macro_state.executing,
+ prompt_shown = false,
+ input_completed = false,
+ acquisition_completed = false,
+ cursor_marker = nil,
+ direct_marker = nil,
+ cursor_presentation_lease = nil,
+ input_packet = nil,
+ acquired_target = nil,
+ resolved_target = nil,
+ previous_input_trigger = nil,
+ previous_target_source = nil,
+ cached_target = nil,
+ missing_previous_input = false,
+ text_view = nil,
+ target_plan = nil,
+ motion_plan = nil,
+ outcome = nil,
+ }
+ return scope
+end
+
+function TemporaryResourceScope.is(value)
+ return type(value) == "table" and scope_records[value] ~= nil
+end
+
+local function set_scope_resource(scope, field, resource)
+ local record = scope_records[scope]
+ if record == nil or not record.active then
+ fail("temporary resource scope must be active", 3)
+ end
+ record[field] = resource
+ return resource
+end
+
+function TemporaryResourceScope:set_cursor_marker(marker)
+ return set_scope_resource(self, "cursor_marker", marker)
+end
+
+function TemporaryResourceScope:set_direct_marker(marker)
+ return set_scope_resource(self, "direct_marker", marker)
+end
+
+function TemporaryResourceScope:set_cursor_presentation_lease(lease)
+ return set_scope_resource(self, "cursor_presentation_lease", lease)
+end
+
+function TemporaryResourceScope:set_input_packet(packet)
+ return set_scope_resource(self, "input_packet", packet)
+end
+
+function TemporaryResourceScope:request_redraw(kind)
+ local record = scope_records[self]
+ if record == nil then
+ fail("temporary resource scope is invalid", 2)
+ end
+ if not record.interactive then
+ return false
+ end
+ record.host:redraw(kind)
+ return true
+end
+
+function TemporaryResourceScope:mark_prompt_shown()
+ return set_scope_resource(self, "prompt_shown", true)
+end
+
+function TemporaryResourceScope:mark_input_completed()
+ return set_scope_resource(self, "input_completed", true)
+end
+
+function TemporaryResourceScope:mark_acquisition_completed()
+ return set_scope_resource(self, "acquisition_completed", true)
+end
+
+function TemporaryResourceScope:set_acquired_target(target)
+ return set_scope_resource(self, "acquired_target", target)
+end
+
+function TemporaryResourceScope:set_resolved_target(target)
+ return set_scope_resource(self, "resolved_target", target)
+end
+
+function TemporaryResourceScope:set_previous_input_trigger(trigger)
+ return set_scope_resource(self, "previous_input_trigger", trigger)
+end
+
+function TemporaryResourceScope:set_cached_target(context, target)
+ set_scope_resource(self, "previous_target_source", context)
+ return set_scope_resource(self, "cached_target", target)
+end
+
+function TemporaryResourceScope:set_missing_previous_input(missing)
+ if type(missing) ~= "boolean" then
+ fail("missing previous-input state must be a Boolean", 2)
+ end
+ return set_scope_resource(self, "missing_previous_input", missing)
+end
+
+function TemporaryResourceScope:set_text_view(view)
+ if not text_topology.TextView.is(view) then
+ fail("temporary resource scope text must be a TextView", 2)
+ end
+ return set_scope_resource(self, "text_view", view)
+end
+
+function TemporaryResourceScope:set_target_plan(target_plan)
+ if not domain.TargetPlan.is(target_plan) then
+ fail("temporary resource scope target plan must be a TargetPlan", 2)
+ end
+ return set_scope_resource(self, "target_plan", target_plan)
+end
+
+function TemporaryResourceScope:set_motion_plan(motion_plan)
+ if not domain.ResolvedMotionPlan.is(motion_plan) then
+ fail("temporary resource scope motion plan must be a ResolvedMotionPlan", 2)
+ end
+ return set_scope_resource(self, "motion_plan", motion_plan)
+end
+
+function TemporaryResourceScope:set_outcome(outcome)
+ if not domain.ActionOutcome.is(outcome) then
+ fail("temporary resource scope outcome must be an ActionOutcome", 2)
+ end
+ return set_scope_resource(self, "outcome", outcome)
+end
+
+function TemporaryResourceScope:release()
+ local record = scope_records[self]
+ if record == nil then
+ fail("temporary resource scope is invalid", 2)
+ end
+ if not record.active then
+ return false
+ end
+ record.active = false
+
+ local first_error
+ local function release_operation(operation)
+ local ok, failure = pcall(operation)
+ if not ok and first_error == nil then
+ first_error = failure
+ end
+ end
+
+ if record.interactive
+ and record.prompt_shown
+ and record.input_completed
+ and record.acquisition_completed
+ then
+ release_operation(function()
+ self:request_redraw("full")
+ end)
+ end
+ if record.direct_marker ~= nil then
+ release_operation(function()
+ record.feedback:remove_temporary_overlay(record.direct_marker)
+ end)
+ end
+ if record.cursor_marker ~= nil then
+ release_operation(function()
+ record.feedback:remove_temporary_overlay(record.cursor_marker)
+ end)
+ end
+ if record.cursor_presentation_lease ~= nil then
+ release_operation(function()
+ record.cursor_presentation_lease:release()
+ end)
+ end
+ if first_error ~= nil then
+ error(first_error, 0)
+ end
+ return true
+end
+
+local function require_policy(service, host)
+ service = service or policy.new(host)
+ if type(service) ~= "table" or type(service.sample_acquisition) ~= "function" then
+ fail("AcquisitionService policy must sample acquisition settings", 3)
+ end
+ return service
+end
+
+local function require_state(state)
+ state = state or sequence_state.get()
+ if not sequence_state.is(state) then
+ fail("AcquisitionService 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.BeginAcquisition) ~= "function"
+ or type(transitions.CommitAcquiredTarget) ~= "function"
+ then
+ fail("AcquisitionService transitions must commit acquisition state", 3)
+ end
+ return transitions
+end
+
+local function require_direct_planner(planner)
+ planner = planner or direct_preview_planner.new()
+ if type(planner) ~= "table" or type(planner.plan) ~= "function" then
+ fail("AcquisitionService direct planner must provide plan", 3)
+ end
+ return planner
+end
+
+local function require_target_factory(factory, policy_service)
+ factory = factory or target_plan_factory.new({ policy = policy_service })
+ if type(factory) ~= "table" or type(factory.build) ~= "function" then
+ fail("AcquisitionService target factory must provide build", 3)
+ end
+ return factory
+end
+
+local function require_motion_factory(factory, policy_service)
+ factory = factory or motion_plan_factory.new({ policy = policy_service })
+ if type(factory) ~= "table" or type(factory.build_for_context) ~= "function" then
+ fail("AcquisitionService motion factory must build contextual plans", 3)
+ end
+ return factory
+end
+
+local function require_feedback(feedback, host, policy_service, transitions)
+ feedback = feedback or feedback_service.new({
+ host = host,
+ policy = policy_service,
+ transitions = transitions,
+ })
+ if type(feedback) ~= "table"
+ or type(feedback.create_cursor_marker) ~= "function"
+ or type(feedback.request_persistent) ~= "function"
+ then
+ fail("AcquisitionService feedback must manage acquisition markers", 3)
+ end
+ return feedback
+end
+
+local function normalize_options(options, dependencies)
+ if AcquisitionService.is(options) and dependencies == nil then
+ return options
+ end
+ if type(options) ~= "table" then
+ fail("AcquisitionService options must be a table", 3)
+ end
+ if options.host ~= nil then
+ if dependencies ~= nil then
+ fail("AcquisitionService dependencies must be part of its options", 3)
+ end
+ return options
+ end
+ local result = {}
+ for key, value in pairs(dependencies or {}) do
+ result[key] = value
+ end
+ result.host = options
+ return result
+end
+
+function AcquisitionService.new(options, dependencies)
+ options = normalize_options(options, dependencies)
+ if AcquisitionService.is(options) then
+ return options
+ end
+ if type(options.host) ~= "table" then
+ fail("AcquisitionService host must be a table", 2)
+ end
+ local state = require_state(options.state)
+ local transitions = require_transitions(
+ options.transitions or options.state_transitions,
+ state
+ )
+ local policy_service = require_policy(
+ options.policy or options.policy_service,
+ options.host
+ )
+ local service = setmetatable({}, AcquisitionService)
+ service_records[service] = {
+ host = options.host,
+ policy = policy_service,
+ state = state,
+ transitions = transitions,
+ feedback = require_feedback(
+ options.feedback or options.feedback_service,
+ options.host,
+ policy_service,
+ transitions
+ ),
+ direct_planner = require_direct_planner(
+ options.direct_planner or options.direct_preview_planner
+ ),
+ target_factory = require_target_factory(
+ options.target_factory or options.target_plan_factory,
+ policy_service
+ ),
+ motion_factory = require_motion_factory(
+ options.motion_factory or options.motion_plan_factory,
+ policy_service
+ ),
+ window = options.window or options.current_window,
+ last_scope = nil,
+ started_scope_count = 0,
+ }
+ return service
+end
+
+function AcquisitionService.is(value)
+ return type(value) == "table" and service_records[value] ~= nil
+end
+
+function AcquisitionService:request(descriptor, context, position, count, macro_state)
+ return AcquisitionRequest.new(descriptor, context, position, count, macro_state)
+end
+
+local function current_window(record)
+ local window = record.window
+ if type(window) == "function" then
+ window = window()
+ end
+ if window == nil and type(record.host.read_window) == "function" then
+ window = record.host:read_window()
+ end
+ if window == nil then
+ fail("AcquisitionService requires a current window identity", 3)
+ end
+ return window
+end
+
+function AcquisitionService:start_temporary_scope(request)
+ request = AcquisitionRequest.new(request)
+ local record = service_records[self]
+ local scope = TemporaryResourceScope.new(request, record.feedback, record.host)
+ record.last_scope = scope
+ record.started_scope_count = record.started_scope_count + 1
+ return scope
+end
+
+function AcquisitionService:last_temporary_scope()
+ return service_records[self].last_scope
+end
+
+function AcquisitionService:started_scope_count()
+ return service_records[self].started_scope_count
+end
+
+local function utf8_first_code(character)
+ local first = string.byte(character, 1)
+ if first < 0x80 then
+ return first
+ end
+ local length
+ local code
+ if first >= 0xc2 and first <= 0xdf then
+ length = 2
+ code = first - 0xc0
+ elseif first >= 0xe0 and first <= 0xef then
+ length = 3
+ code = first - 0xe0
+ elseif first >= 0xf0 and first <= 0xf4 then
+ length = 4
+ code = first - 0xf0
+ else
+ fail("ordinary input must start with a valid editor character", 3)
+ end
+ for index = 2, length do
+ local byte = string.byte(character, index)
+ if byte == nil or byte < 0x80 or byte > 0xbf then
+ fail("ordinary input must contain a complete editor character", 3)
+ end
+ code = code * 0x40 + byte - 0x80
+ end
+ return code
+end
+
+local function first_editor_character(text)
+ local characters = text_topology.split_editor_characters(text)
+ if #characters == 0 then
+ fail("ordinary input must contain an editor character", 3)
+ end
+ return characters[1]
+end
+
+function M.editor_character_code(character)
+ character = first_editor_character(character)
+ local runtime = rawget(_G, "vim")
+ if type(runtime) == "table"
+ and type(runtime.fn) == "table"
+ and type(runtime.fn.char2nr) == "function"
+ then
+ return runtime.fn.char2nr(character)
+ end
+ return utf8_first_code(character)
+end
+
+function M.normalize_ordinary_input(packet)
+ packet = domain.InputPacket.from_table(packet)
+ local text
+ if packet.kind == domain.InputPacketKind.TEXT then
+ text = packet.text
+ elseif packet.kind == domain.InputPacketKind.RAW_BYTES then
+ local bytes = packet:bytes()
+ local characters = {}
+ for index = 1, #bytes do
+ characters[index] = string.char(bytes[index])
+ end
+ text = table.concat(characters)
+ else
+ fail("ordinary input packet must contain text or raw bytes", 2)
+ end
+ local character = first_editor_character(text)
+ return domain.TargetValue.character(
+ character,
+ M.editor_character_code(character)
+ )
+end
+
+local function encoded_packet_value(packet)
+ if packet.encoded ~= nil then
+ return packet.encoded
+ end
+ local bytes = packet:bytes()
+ if bytes == nil then
+ return nil
+ end
+ local characters = {}
+ for index = 1, #bytes do
+ characters[index] = string.char(bytes[index])
+ end
+ return table.concat(characters)
+end
+
+function M.normalize_input_packet(packet)
+ packet = domain.InputPacket.from_table(packet)
+ if packet.kind == domain.InputPacketKind.ERROR then
+ fail(packet.message, 2)
+ end
+ if packet.kind == domain.InputPacketKind.TEXT then
+ return M.normalize_ordinary_input(packet)
+ end
+
+ local encoded = encoded_packet_value(packet)
+ if encoded == nil then
+ return domain.TargetValue.code_fallback(0)
+ end
+ if string.byte(encoded, 1) == 0x80 then
+ return domain.TargetValue.special_key(encoded, 0x80)
+ end
+ return M.normalize_ordinary_input(domain.InputPacket.text(encoded))
+end
+
+function M.read_previous_target(state)
+ if not sequence_state.is(state) then
+ fail("previous-input reuse requires SequenceState", 2)
+ end
+ local context = state.last_input_context
+ if context == nil then
+ return nil, nil
+ end
+ return state:get_previous_target(context), context
+end
+
+function M.match_previous_input_trigger(first_code, triggers)
+ if type(first_code) ~= "number" or first_code < 0 then
+ fail("acquired first code must be nonnegative", 2)
+ end
+ if type(triggers) ~= "table" then
+ fail("previous-input triggers must be a list", 2)
+ end
+ for index, trigger in ipairs(triggers) do
+ if type(trigger) ~= "string" then
+ fail("previous-input triggers must contain strings", 2)
+ end
+ if trigger ~= "" and M.editor_character_code(trigger) == first_code then
+ return trigger, index
+ end
+ end
+ return nil
+end
+
+function M.is_escape(packet)
+ packet = domain.InputPacket.from_table(packet)
+ if packet.kind == domain.InputPacketKind.SPECIAL_KEY
+ and (packet.name == "Escape" or packet.name == "Esc")
+ then
+ return true
+ end
+ if packet.kind == domain.InputPacketKind.TEXT then
+ return packet.text == string.char(27)
+ end
+ local bytes = packet:bytes()
+ return bytes ~= nil and #bytes == 1 and bytes[1] == 27
+end
+
+function M.is_terminal_artifact(packet)
+ packet = domain.InputPacket.from_table(packet)
+ if packet.kind ~= domain.InputPacketKind.RAW_BYTES then
+ return false
+ end
+ local bytes = packet:bytes()
+ return #bytes == 3
+ and bytes[1] == 0x80
+ and bytes[2] == 0xfd
+ and bytes[3] == 0x60
+end
+
+local function read_input_packet(host)
+ while true do
+ local packet = domain.InputPacket.from_table(host:read_input())
+ if not M.is_terminal_artifact(packet) then
+ return packet
+ end
+ end
+end
+
+local function direct_preview_settings(policy_service)
+ if type(policy_service.sample_direct_preview) == "function" then
+ return policy_service:sample_direct_preview()
+ end
+ if type(policy_service.get_boolean) == "function" then
+ return {
+ ignore_case = policy_service:get_boolean("ignore_case"),
+ smart_case = policy_service:get_boolean("smart_case"),
+ }
+ end
+ fail("AcquisitionService policy must sample direct preview settings", 3)
+end
+
+local function acquire_in_scope(record, request, scope)
+ local acquisition = record.policy:sample_acquisition()
+ local interactive = not request.macro_state.executing
+ scope:set_cursor_presentation_lease(
+ record.feedback:create_cursor_presentation_lease(
+ interactive and acquisition.hide_cursor_on_cmdline
+ )
+ )
+ if not interactive then
+ record.host:redraw("suppressed")
+ end
+ if acquisition.mark_cursor and interactive then
+ scope:set_cursor_marker(record.feedback:create_cursor_marker(
+ request.position,
+ current_window(record)
+ ))
+ scope:request_redraw("screen")
+ end
+ if acquisition.mark_direct and interactive then
+ local view = scope:set_text_view(text_topology.from_host(record.host))
+ local positions = record.direct_planner:plan(
+ view,
+ request.position,
+ request.descriptor,
+ request.count,
+ direct_preview_settings(record.policy)
+ )
+ local window = scope.cursor_marker
+ and scope.cursor_marker.window
+ or current_window(record)
+ scope:set_direct_marker(record.feedback:create_direct_markers(
+ positions,
+ window
+ ))
+ scope:request_redraw("screen")
+ end
+ if acquisition.show_prompt and interactive then
+ record.host:show_prompt(M.PROMPT)
+ scope:mark_prompt_shown()
+ end
+ record.transitions:BeginAcquisition(request.context, request.descriptor)
+ local packet = scope:set_input_packet(read_input_packet(record.host))
+ scope:mark_input_completed()
+ if M.is_escape(packet) then
+ local outcome = scope:set_outcome(domain.ActionOutcome.escape(request.position))
+ return AcquisitionResult.new(request, { outcome = outcome })
+ end
+ local target = scope:set_acquired_target(M.normalize_input_packet(packet))
+ local previous_input = record.policy:sample_previous_input()
+ local trigger = scope:set_previous_input_trigger(
+ M.match_previous_input_trigger(
+ target.first_code,
+ previous_input.repeat_last_char_inputs
+ )
+ )
+ if trigger ~= nil then
+ local cached_target, source = M.read_previous_target(record.state)
+ scope:set_cached_target(source, cached_target)
+ if cached_target ~= nil then
+ target = cached_target
+ else
+ target = nil
+ scope:set_missing_previous_input(true)
+ record.host:emit_diagnostic("error", M.PREVIOUS_INPUT_NOT_FOUND)
+ local outcome = scope:set_outcome(
+ domain.ActionOutcome.empty(request.position)
+ )
+ return AcquisitionResult.new(request, {
+ outcome = outcome,
+ previous_input_trigger = trigger,
+ previous_target_source = scope.previous_target_source,
+ missing_previous_input = true,
+ })
+ end
+ end
+ scope:set_resolved_target(target)
+ local acquisition_time_ms
+ if record.policy:sample_timeouts().repeat_timeout_ms > 0 then
+ acquisition_time_ms = record.host:read_time_ms()
+ end
+ record.transitions:CommitAcquiredTarget(
+ request.context,
+ target,
+ acquisition_time_ms
+ )
+ local view = scope.text_view
+ or scope:set_text_view(text_topology.from_host(record.host))
+ local search_scope = record.policy:sample_search().search_scope
+ local target_plan = scope:set_target_plan(record.target_factory:build(
+ target,
+ nil,
+ {
+ text_view = view,
+ origin = request.position,
+ search_scope = search_scope,
+ effective_encoding = view.effective_encoding,
+ }
+ ))
+ local selection = request.context.visual and record.host:read_selection() or nil
+ local motion_plan = scope:set_motion_plan(
+ record.motion_factory:build_for_context(
+ target_plan,
+ request.descriptor,
+ request.context,
+ selection,
+ search_scope
+ )
+ )
+ local persistent_feedback_request
+ if interactive
+ and record.policy:sample_markers().mark_char
+ and feedback_service.persistent_context_eligible(request.context)
+ then
+ local window = scope.cursor_marker
+ and scope.cursor_marker.window
+ or scope.direct_marker
+ and scope.direct_marker.window
+ or current_window(record)
+ persistent_feedback_request = record.feedback:request_persistent({
+ context = request.context,
+ anchor = request.position,
+ target_plan = target_plan,
+ motion_plan = motion_plan,
+ window = window,
+ })
+ end
+ local result = AcquisitionResult.new(request, {
+ target = target,
+ target_plan = target_plan,
+ motion_plan = motion_plan,
+ acquisition_time_ms = acquisition_time_ms,
+ persistent_feedback_request = persistent_feedback_request,
+ previous_input_trigger = trigger,
+ previous_target_source = scope.previous_target_source,
+ cached_target = scope.cached_target,
+ missing_previous_input = scope.missing_previous_input,
+ })
+ scope:mark_acquisition_completed()
+ return result
+end
+
+local function error_message(failure)
+ local message = tostring(failure)
+ if message == "" then
+ return "clever-tee: Acquisition failed"
+ end
+ return message
+end
+
+function AcquisitionService:acquire(descriptor, context, position, count, macro_state)
+ local request = self:request(descriptor, context, position, count, macro_state)
+ local scope = self:start_temporary_scope(request)
+ local record = service_records[self]
+ local ok, result = xpcall(function()
+ return acquire_in_scope(record, request, scope)
+ end, function(failure)
+ return failure
+ end)
+
+ if not ok then
+ local diagnostic = error_message(result)
+ result = AcquisitionResult.new(request, {
+ outcome = domain.ActionOutcome.error(request.position, diagnostic),
+ })
+ end
+
+ local cleanup_ok, cleanup_error = pcall(function()
+ scope:release()
+ end)
+ if not cleanup_ok and ok then
+ local diagnostic = error_message(cleanup_error)
+ result = AcquisitionResult.new(request, {
+ outcome = domain.ActionOutcome.error(request.position, diagnostic),
+ })
+ ok = false
+ end
+
+ if not ok then
+ pcall(record.host.emit_diagnostic, record.host, "error", result.outcome.diagnostic)
+ end
+ return result
+end
+
+function M.new(options, dependencies)
+ return AcquisitionService.new(options, dependencies)
+end
+
+setmetatable(M, {
+ __call = function(_, options, dependencies)
+ return AcquisitionService.new(options, dependencies)
+ end,
+})
+
+return M
diff --git a/lua/clever_tee/action_facade.lua b/lua/clever_tee/action_facade.lua
new file mode 100644
index 0000000..fe7a035
--- /dev/null
+++ b/lua/clever_tee/action_facade.lua
@@ -0,0 +1,134 @@
+local domain = require("clever_tee.domain")
+local sequence_coordinator = require("clever_tee.sequence_coordinator")
+
+local M = {}
+local ActionFacade = {}
+ActionFacade.__index = ActionFacade
+M.ActionFacade = ActionFacade
+
+local facade_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+function ActionFacade.new(options)
+ if ActionFacade.is(options) then
+ return options
+ end
+ local coordinator
+ if sequence_coordinator.SequenceCoordinator.is(options) then
+ coordinator = options
+ elseif type(options) == "table" then
+ coordinator = options.coordinator
+ or options.sequence_coordinator
+ or sequence_coordinator.new(options)
+ else
+ fail("ActionFacade options must be a table", 2)
+ end
+ if not sequence_coordinator.SequenceCoordinator.is(coordinator) then
+ fail("ActionFacade requires a SequenceCoordinator", 2)
+ end
+
+ local facade = setmetatable({}, ActionFacade)
+ facade_records[facade] = { coordinator = coordinator }
+ return facade
+end
+
+function ActionFacade.is(value)
+ return type(value) == "table" and facade_records[value] ~= nil
+end
+
+function ActionFacade:coordinator()
+ return facade_records[self].coordinator
+end
+
+function ActionFacade:primary(descriptor)
+ local outcome = self:coordinator():primary(descriptor)
+ if not domain.ActionOutcome.is(outcome) then
+ fail("SequenceCoordinator must return an ActionOutcome", 2)
+ end
+ return outcome
+end
+
+function ActionFacade:invoke_descriptor(value)
+ local descriptor = sequence_coordinator.validate_primary_descriptor(value)
+ return self:primary(descriptor)
+end
+
+ActionFacade.invoke_primary = ActionFacade.invoke_descriptor
+ActionFacade.start = ActionFacade.invoke_descriptor
+ActionFacade.FreeForm = ActionFacade.invoke_descriptor
+
+function ActionFacade:start_find_forward()
+ return self:primary("f")
+end
+
+function ActionFacade:start_find_backward()
+ return self:primary("F")
+end
+
+function ActionFacade:start_till_forward()
+ return self:primary("t")
+end
+
+function ActionFacade:start_till_backward()
+ return self:primary("T")
+end
+
+ActionFacade.StartFindForward = ActionFacade.start_find_forward
+ActionFacade.StartFindBackward = ActionFacade.start_find_backward
+ActionFacade.StartTillForward = ActionFacade.start_till_forward
+ActionFacade.StartTillBackward = ActionFacade.start_till_backward
+
+function ActionFacade:reset()
+ local outcome = self:coordinator():reset()
+ if not domain.ActionOutcome.is(outcome) then
+ fail("SequenceCoordinator must return an ActionOutcome", 2)
+ end
+ return outcome
+end
+
+ActionFacade.Reset = ActionFacade.reset
+
+function ActionFacade:diagnostic_full_reset()
+ local outcome = self:coordinator():diagnostic_full_reset()
+ if not domain.ActionOutcome.is(outcome) then
+ fail("SequenceCoordinator must return an ActionOutcome", 2)
+ end
+ return outcome
+end
+
+ActionFacade.DiagnosticFullReset = ActionFacade.diagnostic_full_reset
+
+local function explicit_outcome(facade, method_name)
+ local coordinator = facade:coordinator()
+ local outcome = coordinator[method_name](coordinator)
+ if not domain.ActionOutcome.is(outcome) then
+ fail("SequenceCoordinator must return an ActionOutcome", 3)
+ end
+ return outcome
+end
+
+function ActionFacade:repeat_same_direction()
+ return explicit_outcome(self, "repeat_same_direction")
+end
+
+function ActionFacade:repeat_opposite_direction()
+ return explicit_outcome(self, "repeat_opposite_direction")
+end
+
+ActionFacade.RepeatSameDirection = ActionFacade.repeat_same_direction
+ActionFacade.RepeatOppositeDirection = ActionFacade.repeat_opposite_direction
+
+function M.new(options)
+ return ActionFacade.new(options)
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return ActionFacade.new(options)
+ end,
+})
+
+return M
diff --git a/lua/clever_tee/capabilities.lua b/lua/clever_tee/capabilities.lua
new file mode 100644
index 0000000..325d015
--- /dev/null
+++ b/lua/clever_tee/capabilities.lua
@@ -0,0 +1,163 @@
+local M = {}
+
+M.read_methods = {
+ text = { "read_text" },
+ buffer = { "read_buffer", "read_window" },
+ cursor = { "read_cursor" },
+ mode = { "read_mode", "read_pending_operator" },
+ selection = { "read_selection" },
+ count = { "read_count" },
+ configuration = { "read_configuration", "configuration_present" },
+ encoding = { "read_encoding" },
+ case_conversion = { "lowercase" },
+ macro_state = { "read_macro_state" },
+ fold_state = { "read_fold_state" },
+ time = { "read_time_ms" },
+ highlight_groups = { "read_highlight_group" },
+}
+
+M.effect_methods = {
+ movement = { "apply_cursor", "apply_selection", "set_operator_inclusive" },
+ configuration = { "write_configuration" },
+ input = { "read_input" },
+ folds = { "open_fold" },
+ prompt = { "show_prompt" },
+ redraw = { "redraw" },
+ diagnostics = { "emit_diagnostic" },
+ highlights = {
+ "define_highlight_group",
+ "create_highlight",
+ "remove_highlight",
+ },
+ cursor_presentation = {
+ "supports_cursor_presentation",
+ "suppress_cursor_presentation",
+ "restore_cursor_presentation",
+ },
+ timers = { "supports_timers", "start_timer", "stop_timer" },
+ events = {
+ "register_events",
+ "remove_event_registration",
+ "deliver_event",
+ "begin_action_transition",
+ "commit_action_transition",
+ },
+ mappings = { "register_action", "register_mapping" },
+ dot_repeat = { "register_dot_repeat" },
+}
+
+local function collect_methods(groups)
+ local result = {}
+ local group_names = {}
+ for group_name in pairs(groups) do
+ group_names[#group_names + 1] = group_name
+ end
+ table.sort(group_names)
+ for _, group_name in ipairs(group_names) do
+ for _, method_name in ipairs(groups[group_name]) do
+ result[#result + 1] = method_name
+ end
+ end
+ return result
+end
+
+local all_methods = collect_methods(M.read_methods)
+for _, method_name in ipairs(collect_methods(M.effect_methods)) do
+ all_methods[#all_methods + 1] = method_name
+end
+table.sort(all_methods)
+
+function M.required_methods()
+ local result = {}
+ for index = 1, #all_methods do
+ result[index] = all_methods[index]
+ end
+ return result
+end
+
+function M.missing_methods(host)
+ local missing = {}
+ for _, method_name in ipairs(all_methods) do
+ if type(host) ~= "table" or type(host[method_name]) ~= "function" then
+ missing[#missing + 1] = method_name
+ end
+ end
+ return missing
+end
+
+function M.assert_implements(host)
+ local missing = M.missing_methods(host)
+ if #missing > 0 then
+ error("host is missing semantic capabilities: " .. table.concat(missing, ", "), 2)
+ end
+ return host
+end
+
+local EventQueue = {}
+EventQueue.__index = EventQueue
+M.EventQueue = EventQueue
+
+function EventQueue.new(deliver)
+ if type(deliver) ~= "function" then
+ error("event delivery must be a function", 2)
+ end
+ return setmetatable({
+ _deliver = deliver,
+ _active_token = nil,
+ _pending = {},
+ _next_token = 1,
+ }, EventQueue)
+end
+
+function EventQueue:begin_transition()
+ if self._active_token ~= nil then
+ error("an action transition is already active", 2)
+ end
+ local token = "action-transition-" .. tostring(self._next_token)
+ self._next_token = self._next_token + 1
+ self._active_token = token
+ self._pending = {}
+ return token
+end
+
+function EventQueue:is_transition_active()
+ return self._active_token ~= nil
+end
+
+function EventQueue:pending_count()
+ return #self._pending
+end
+
+function EventQueue:emit(name, payload)
+ if type(name) ~= "string" or name == "" then
+ error("event name must be a nonempty string", 2)
+ end
+ if self._active_token ~= nil then
+ self._pending[#self._pending + 1] = {
+ name = name,
+ payload = payload,
+ }
+ return false
+ end
+ self._deliver(name, payload)
+ return true
+end
+
+function EventQueue:commit_transition(token)
+ if self._active_token == nil then
+ error("no action transition is active", 2)
+ end
+ if token ~= self._active_token then
+ error("action transition token does not match", 2)
+ end
+
+ local pending = self._pending
+ self._active_token = nil
+ self._pending = {}
+ for index = 1, #pending do
+ local event = pending[index]
+ self._deliver(event.name, event.payload)
+ end
+end
+
+return M
diff --git a/lua/clever_tee/case_policy.lua b/lua/clever_tee/case_policy.lua
new file mode 100644
index 0000000..07e4fb1
--- /dev/null
+++ b/lua/clever_tee/case_policy.lua
@@ -0,0 +1,190 @@
+local domain = require("clever_tee.domain")
+
+local M = {}
+local CasePolicyResolver = {}
+M.CasePolicyResolver = CasePolicyResolver
+
+local resolver_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function require_target(target)
+ if not domain.TargetValue.is(target) then
+ fail("case policy target must be a TargetValue", 2)
+ end
+ return target
+end
+
+local function require_boolean(value, name)
+ if type(value) ~= "boolean" then
+ fail((name or "value") .. " must be a Boolean", 2)
+ end
+ return value
+end
+
+local function require_string(value, name, allow_empty)
+ if type(value) ~= "string" or (not allow_empty and value == "") then
+ fail((name or "value") .. " must be a string", 2)
+ end
+ return value
+end
+
+local function default_lowercase(value)
+ local runtime = rawget(_G, "vim")
+ if type(runtime) ~= "table"
+ or type(runtime.fn) ~= "table"
+ or type(runtime.fn.tolower) ~= "function"
+ then
+ fail("editor-compatible case conversion requires Nvim or a lowercase converter", 2)
+ end
+ return runtime.fn.tolower(value)
+end
+
+local function lowercase_function(options)
+ if options == nil then
+ return default_lowercase
+ end
+ if type(options) == "function" then
+ return options
+ end
+ if type(options) ~= "table" then
+ fail("case policy options must be a table or lowercase function", 3)
+ end
+ local lowercase = options.lowercase or options.to_lower
+ if lowercase == nil then
+ return default_lowercase
+ end
+ if type(lowercase) ~= "function" then
+ fail("case policy lowercase converter must be a function", 3)
+ end
+ return lowercase
+end
+
+local resolver_metatable = {
+ __index = CasePolicyResolver,
+ __newindex = function()
+ fail("CasePolicyResolver values are immutable", 2)
+ end,
+ __tostring = function()
+ return "case-policy-resolver"
+ end,
+ __metatable = "clever_tee.case_policy.CasePolicyResolver",
+}
+
+function CasePolicyResolver.new(options)
+ if CasePolicyResolver.is(options) then
+ return options
+ end
+ local resolver = setmetatable({}, resolver_metatable)
+ resolver_records[resolver] = {
+ lowercase = lowercase_function(options),
+ }
+ return resolver
+end
+
+function CasePolicyResolver.is(value)
+ return type(value) == "table" and resolver_records[value] ~= nil
+end
+
+function M.new(options)
+ return CasePolicyResolver.new(options)
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return CasePolicyResolver.new(options)
+ end,
+})
+
+function M.is_lower_ascii(value)
+ if domain.TargetValue.is(value) then
+ value = value.value
+ end
+ if type(value) ~= "string" or #value ~= 1 then
+ return false
+ end
+ local byte = value:byte(1)
+ return byte >= string.byte("a") and byte <= string.byte("z")
+end
+
+function M.resolve_case_mode(target, ignore_case, smart_case)
+ target = require_target(target)
+ require_boolean(ignore_case, "ignore_case")
+ require_boolean(smart_case, "smart_case")
+
+ if ignore_case then
+ return domain.CaseMode.INSENSITIVE
+ end
+ if smart_case and M.is_lower_ascii(target) then
+ return domain.CaseMode.INSENSITIVE
+ end
+ return domain.CaseMode.SENSITIVE
+end
+
+function CasePolicyResolver:resolve(target, ignore_case, smart_case)
+ if type(ignore_case) == "table" and smart_case == nil then
+ local match_policy = ignore_case
+ ignore_case = match_policy.ignore_case
+ smart_case = match_policy.smart_case
+ end
+ return M.resolve_case_mode(target, ignore_case, smart_case)
+end
+
+function CasePolicyResolver:lowercase(value)
+ require_string(value, "case comparison value", true)
+ local lowercase = resolver_records[self].lowercase(value)
+ if type(lowercase) ~= "string" then
+ fail("case policy lowercase converter must return a string", 2)
+ end
+ return lowercase
+end
+
+function CasePolicyResolver:equal(left, right, case_mode)
+ require_string(left, "left case comparison value", true)
+ require_string(right, "right case comparison value", true)
+ case_mode = domain.CaseMode.from_string(case_mode)
+
+ if case_mode == domain.CaseMode.SENSITIVE then
+ return left == right
+ end
+ return self:lowercase(left) == self:lowercase(right)
+end
+
+function CasePolicyResolver:comparator(target_character, case_mode)
+ require_string(target_character, "target character", true)
+ case_mode = domain.CaseMode.from_string(case_mode)
+
+ if target_character == "" then
+ return function()
+ return false
+ end
+ end
+
+ if case_mode == domain.CaseMode.SENSITIVE then
+ return function(candidate_character)
+ return type(candidate_character) == "string"
+ and candidate_character ~= ""
+ and candidate_character == target_character
+ end
+ end
+
+ local folded_target = self:lowercase(target_character)
+ local lowercase = resolver_records[self].lowercase
+ return function(candidate_character)
+ if type(candidate_character) ~= "string" or candidate_character == "" then
+ return false
+ end
+ local folded_candidate = lowercase(candidate_character)
+ if type(folded_candidate) ~= "string" then
+ fail("case policy lowercase converter must return a string", 2)
+ end
+ return folded_candidate == folded_target
+ end
+end
+
+M.resolve = M.resolve_case_mode
+M.is_lowercase_ascii = M.is_lower_ascii
+
+return M
diff --git a/lua/clever_tee/composition_root.lua b/lua/clever_tee/composition_root.lua
new file mode 100644
index 0000000..4741bd4
--- /dev/null
+++ b/lua/clever_tee/composition_root.lua
@@ -0,0 +1,232 @@
+local action_facade = require("clever_tee.action_facade")
+local capabilities = require("clever_tee.capabilities")
+local feedback_service = require("clever_tee.feedback_service")
+local policy = require("clever_tee.policy")
+local sequence_coordinator = require("clever_tee.sequence_coordinator")
+local sequence_state = require("clever_tee.sequence_state")
+local state_transitions = require("clever_tee.state_transitions")
+
+local M = {}
+local CompositionRoot = {}
+CompositionRoot.__index = CompositionRoot
+M.CompositionRoot = CompositionRoot
+M.ACTION_NAMES = {
+ "StartFindForward",
+ "StartFindBackward",
+ "StartTillForward",
+ "StartTillBackward",
+ "Reset",
+ "RepeatSameDirection",
+ "RepeatOppositeDirection",
+}
+M.DEFAULT_MAPPING_MODES = { "n", "x", "o" }
+M.DEFAULT_MAPPING_OPTIONS = {
+ silent = true,
+ remap = false,
+ preserve_count = true,
+}
+M.DEFAULT_MAPPINGS = {
+ { lhs = "f", action = "StartFindForward" },
+ { lhs = "F", action = "StartFindBackward" },
+ { lhs = "t", action = "StartTillForward" },
+ { lhs = "T", action = "StartTillBackward" },
+}
+
+local ACTION_INVOCATIONS = {
+ StartFindForward = function(facade)
+ return facade:start_find_forward()
+ end,
+ StartFindBackward = function(facade)
+ return facade:start_find_backward()
+ end,
+ StartTillForward = function(facade)
+ return facade:start_till_forward()
+ end,
+ StartTillBackward = function(facade)
+ return facade:start_till_backward()
+ end,
+ Reset = function(facade)
+ return facade:reset()
+ end,
+ RepeatSameDirection = function(facade)
+ return facade:repeat_same_direction()
+ end,
+ RepeatOppositeDirection = function(facade)
+ return facade:repeat_opposite_direction()
+ end,
+}
+
+local root_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function normalize_options(options)
+ if type(options) ~= "table" then
+ fail("CompositionRoot options must be a table", 3)
+ end
+ if options.host == nil then
+ return { host = options }
+ end
+ return options
+end
+
+function CompositionRoot.new(options)
+ if CompositionRoot.is(options) then
+ return options
+ end
+ options = normalize_options(options)
+ local host = capabilities.assert_implements(options.host)
+ local state = sequence_state.new()
+ 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 feedback = options.feedback
+ or options.feedback_service
+ or feedback_service.new({
+ host = host,
+ state = state,
+ transitions = transitions,
+ policy = policy_service,
+ })
+ local coordinator = options.coordinator
+ or options.sequence_coordinator
+ or sequence_coordinator.new({
+ host = host,
+ state = state,
+ transitions = transitions,
+ policy = policy_service,
+ feedback = feedback,
+ })
+ local facade = options.facade
+ or options.action_facade
+ or action_facade.new({ coordinator = coordinator })
+
+ local root = setmetatable({}, CompositionRoot)
+ root_records[root] = {
+ host = host,
+ state = state,
+ transitions = transitions,
+ policy = policy_service,
+ feedback = feedback,
+ coordinator = coordinator,
+ facade = facade,
+ activation = nil,
+ last_highlight_refresh = nil,
+ }
+ return root
+end
+
+function CompositionRoot.is(value)
+ return type(value) == "table" and root_records[value] ~= nil
+end
+
+local function register_logical_actions(record)
+ local registrations = {}
+ for _, name in ipairs(M.ACTION_NAMES) do
+ local invoke = ACTION_INVOCATIONS[name]
+ registrations[name] = record.host:register_action(name, function(...)
+ return invoke(record.facade, ...)
+ end)
+ end
+ return registrations
+end
+
+local function register_default_mappings(record, setup)
+ local registrations = {}
+ if not setup.install_default_mappings then
+ return registrations
+ end
+ for _, mapping in ipairs(M.DEFAULT_MAPPINGS) do
+ registrations[mapping.lhs] = record.host:register_mapping(
+ M.DEFAULT_MAPPING_MODES,
+ mapping.lhs,
+ mapping.action,
+ M.DEFAULT_MAPPING_OPTIONS
+ )
+ end
+ return registrations
+end
+
+function CompositionRoot:activate()
+ local record = root_records[self]
+ if record.activation == nil then
+ local setup = record.policy:capture_activation()
+ local feedback_activation = record.feedback:activate()
+ local highlights = record.feedback:evaluate_highlights()
+ local colorscheme_registration = record.host:register_events(
+ "ColorScheme",
+ function()
+ record.last_highlight_refresh = record.feedback:evaluate_highlights()
+ end,
+ { owner = "clever_tee", lifecycle = "colorscheme" }
+ )
+ local actions = register_logical_actions(record)
+ record.activation = {
+ state = record.state,
+ setup = setup,
+ feedback = feedback_activation,
+ highlights = highlights,
+ colorscheme_registration = colorscheme_registration,
+ actions = actions,
+ mappings = register_default_mappings(record, setup),
+ }
+ end
+ return record.activation
+end
+
+function CompositionRoot:last_highlight_refresh()
+ return root_records[self].last_highlight_refresh
+end
+
+function CompositionRoot:host()
+ return root_records[self].host
+end
+
+function CompositionRoot:state()
+ return root_records[self].state
+end
+
+function CompositionRoot:transitions()
+ return root_records[self].transitions
+end
+
+function CompositionRoot:policy()
+ return root_records[self].policy
+end
+
+function CompositionRoot:feedback()
+ return root_records[self].feedback
+end
+
+function CompositionRoot:coordinator()
+ return root_records[self].coordinator
+end
+
+function CompositionRoot:facade()
+ return root_records[self].facade
+end
+
+function CompositionRoot:invoke_descriptor(value)
+ return self:facade():invoke_descriptor(value)
+end
+
+function CompositionRoot:diagnostic_full_reset()
+ return self:facade():diagnostic_full_reset()
+end
+
+function M.new(options)
+ return CompositionRoot.new(options)
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return CompositionRoot.new(options)
+ end,
+})
+
+return M
diff --git a/lua/clever_tee/destination_engine.lua b/lua/clever_tee/destination_engine.lua
new file mode 100644
index 0000000..0469bb2
--- /dev/null
+++ b/lua/clever_tee/destination_engine.lua
@@ -0,0 +1,201 @@
+local domain = require("clever_tee.domain")
+local text_topology = require("clever_tee.text_topology")
+
+local M = {}
+local DestinationEngine = {}
+M.DestinationEngine = DestinationEngine
+
+local engines = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local engine_metatable = {
+ __index = DestinationEngine,
+ __newindex = function()
+ fail("DestinationEngine values are immutable", 2)
+ end,
+ __tostring = function()
+ return "destination-engine"
+ end,
+ __metatable = "clever_tee.destination_engine.DestinationEngine",
+}
+
+function DestinationEngine.new()
+ local engine = setmetatable({}, engine_metatable)
+ engines[engine] = true
+ return engine
+end
+
+function DestinationEngine.is(value)
+ return type(value) == "table" and engines[value] == true
+end
+
+local function calculation_inputs(view, origin, plan, count, first_move)
+ if not text_topology.TextView.is(view) then
+ fail("destination calculation view must be a TextView", 3)
+ end
+
+ origin = domain.Position.coerce(origin)
+ if not view:is_valid_cursor_position(origin) then
+ fail("destination calculation origin must be a valid cursor position", 3)
+ end
+ if not domain.ResolvedMotionPlan.is(plan) then
+ fail("destination calculation plan must be a ResolvedMotionPlan", 3)
+ end
+ count = domain.Count.new(count)
+ if type(first_move) ~= "boolean" then
+ fail("destination calculation first_move must be a Boolean", 3)
+ end
+
+ local bounds = view:match_start_bounds(plan.search_scope, origin)
+ return {
+ view = view,
+ origin = origin,
+ plan = plan,
+ count = count,
+ first_move = first_move,
+ bounds = bounds,
+ }
+end
+
+local function candidate_starts(request, origin)
+ return request.view:iter_strict(
+ origin,
+ request.plan.descriptor.direction,
+ request.bounds
+ )
+end
+
+local function regular_destination(request, target_position)
+ local descriptor = request.plan.descriptor
+ if descriptor.family == domain.Family.FIND then
+ return target_position
+ end
+ if descriptor.direction == domain.Direction.FORWARD then
+ return request.view:predecessor(target_position)
+ end
+ return request.view:successor(target_position)
+end
+
+local function target_destination(request, target_position)
+ local descriptor = request.plan.descriptor
+ if request.plan.endpoint_policy == domain.EndpointPolicy.VISUAL_EXCLUSIVE
+ and descriptor.direction == domain.Direction.FORWARD
+ then
+ if descriptor.family == domain.Family.FIND then
+ return request.view:successor(target_position)
+ end
+ return target_position
+ end
+ return regular_destination(request, target_position)
+end
+
+local function strict_destination(descriptor, destination, origin)
+ local comparison = domain.Position.compare(destination, origin)
+ if descriptor.direction == domain.Direction.FORWARD then
+ return comparison > 0
+ end
+ return comparison < 0
+end
+
+local function acceptable_destination(
+ request,
+ destination,
+ origin,
+ allow_till_equality
+)
+ local descriptor = request.plan.descriptor
+ if strict_destination(descriptor, destination, origin) then
+ return true
+ end
+ return descriptor.family == domain.Family.TILL
+ and allow_till_equality
+ and domain.Position.equal(destination, origin)
+end
+
+local function next_destination(request, origin, allow_till_equality)
+ local candidates = candidate_starts(request, origin)
+
+ while true do
+ local target_position, character = candidates()
+ if target_position == nil then
+ return nil
+ end
+ if request.plan.target_plan:matches(
+ character,
+ target_position,
+ request.view
+ ) then
+ local destination = target_destination(request, target_position)
+ if destination ~= nil
+ and acceptable_destination(
+ request,
+ destination,
+ origin,
+ allow_till_equality
+ )
+ then
+ return destination
+ end
+ end
+ end
+end
+
+local function till_equality_allowed(request, successful_steps)
+ return request.first_move and successful_steps == 0
+end
+
+local function boundary_outcome(request, endpoint, successful_steps)
+ if successful_steps > 0 then
+ return domain.SearchOutcome.boundary_after_partial(
+ endpoint,
+ successful_steps
+ )
+ end
+ return domain.SearchOutcome.boundary_before_any(request.origin)
+end
+
+function DestinationEngine:calculate(view, origin, plan, count, first_move)
+ local request = calculation_inputs(view, origin, plan, count, first_move)
+ local current_origin = request.origin
+ local successful_steps = 0
+
+ while successful_steps < request.count.value do
+ local destination = next_destination(
+ request,
+ current_origin,
+ till_equality_allowed(request, successful_steps)
+ )
+ if destination == nil then
+ break
+ end
+ current_origin = destination
+ successful_steps = successful_steps + 1
+ end
+
+ if successful_steps == request.count.value then
+ return domain.SearchOutcome.complete(current_origin, successful_steps)
+ end
+ return boundary_outcome(request, current_origin, successful_steps)
+end
+
+function M.new()
+ return DestinationEngine.new()
+end
+
+function M.calculate(view, origin, plan, count, first_move)
+ return DestinationEngine.new():calculate(view, origin, plan, count, first_move)
+end
+
+M.resolve = M.calculate
+M.search = M.calculate
+
+setmetatable(M, {
+ __call = function()
+ return DestinationEngine.new()
+ end,
+})
+
+return M
diff --git a/lua/clever_tee/direct_preview_planner.lua b/lua/clever_tee/direct_preview_planner.lua
new file mode 100644
index 0000000..699fcf4
--- /dev/null
+++ b/lua/clever_tee/direct_preview_planner.lua
@@ -0,0 +1,250 @@
+local case_policy = require("clever_tee.case_policy")
+local domain = require("clever_tee.domain")
+local text_topology = require("clever_tee.text_topology")
+
+local M = {}
+local DirectPreviewPlanner = {}
+DirectPreviewPlanner.__index = DirectPreviewPlanner
+M.DirectPreviewPlanner = DirectPreviewPlanner
+
+local planner_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function require_view(view)
+ if not text_topology.TextView.is(view) then
+ fail("direct preview text must be a TextView", 3)
+ end
+ return view
+end
+
+local function require_case_resolver(options)
+ options = options or {}
+ if type(options) == "function" then
+ options = { lowercase = options }
+ end
+ if type(options) ~= "table" then
+ fail("DirectPreviewPlanner options must be a table", 3)
+ end
+ local resolver = options.case_resolver
+ if resolver == nil then
+ resolver = case_policy.new({ lowercase = options.lowercase })
+ end
+ if type(resolver) ~= "table" or type(resolver.lowercase) ~= "function" then
+ fail("DirectPreviewPlanner case resolver must provide lowercase", 3)
+ end
+ return resolver
+end
+
+function DirectPreviewPlanner.new(options)
+ if DirectPreviewPlanner.is(options) then
+ return options
+ end
+ local planner = setmetatable({}, DirectPreviewPlanner)
+ planner_records[planner] = {
+ case_resolver = require_case_resolver(options),
+ }
+ return planner
+end
+
+function DirectPreviewPlanner.is(value)
+ return type(value) == "table" and planner_records[value] ~= nil
+end
+
+function DirectPreviewPlanner:scan_current_line(view, origin, direction)
+ require_view(view)
+ origin = domain.Position.coerce(origin)
+ direction = domain.Direction.from_string(direction)
+ local records = {}
+ for position, character, span in view:iter_strict(
+ origin,
+ direction,
+ domain.SearchScope.CURRENT_LINE
+ ) do
+ records[#records + 1] = {
+ position = position,
+ character = character,
+ span = span,
+ }
+ end
+ return records
+end
+
+function M.marker_position(descriptor, target_position)
+ domain.Descriptor.from_string(descriptor)
+ return domain.Position.coerce(target_position)
+end
+
+function M.normalize_count(count)
+ return domain.Count.to_number(count)
+end
+
+function DirectPreviewPlanner:normalize_count(count)
+ return M.normalize_count(count)
+end
+
+function M.direction_for_descriptor(descriptor)
+ descriptor = domain.Descriptor.from_string(descriptor)
+ if domain.Descriptor.is_uppercase(descriptor) then
+ return domain.Direction.BACKWARD
+ end
+ return domain.Direction.FORWARD
+end
+
+function DirectPreviewPlanner:scan_for_descriptor(view, origin, descriptor)
+ return self:scan_current_line(
+ view,
+ origin,
+ M.direction_for_descriptor(descriptor)
+ )
+end
+
+local function increment_counter(counters, character)
+ local value = (counters[character] or 0) + 1
+ counters[character] = value
+ return value
+end
+
+local function is_upper_ascii(character)
+ if type(character) ~= "string" or #character ~= 1 then
+ return false
+ end
+ local code = character:byte(1)
+ return code >= string.byte("A") and code <= string.byte("Z")
+end
+
+local function increment_smart_counters(counters, character)
+ local exact_count = increment_counter(counters, character)
+ local lowercase_count
+ if is_upper_ascii(character) then
+ local lowercase = string.char(character:byte(1) + 32)
+ lowercase_count = increment_counter(counters, lowercase)
+ end
+ return exact_count, lowercase_count
+end
+
+M.is_upper_ascii = is_upper_ascii
+
+local function require_case_setting(settings, name)
+ local value = settings[name]
+ if value == nil then
+ return false
+ end
+ if type(value) ~= "boolean" then
+ fail("direct preview " .. name .. " must be a Boolean", 3)
+ end
+ return value
+end
+
+function M.case_grouping_settings(settings)
+ settings = settings or {}
+ if type(settings) ~= "table" then
+ fail("direct preview case settings must be a table", 2)
+ end
+ return {
+ ignore_case = require_case_setting(settings, "ignore_case"),
+ smart_case = require_case_setting(settings, "smart_case"),
+ }
+end
+
+function M.validate_marker_positions(view, positions)
+ require_view(view)
+ if type(positions) ~= "table" then
+ fail("direct preview markers must be a list of positions", 2)
+ end
+
+ local result = {}
+ local seen = {}
+ local item_count = 0
+ for key, position in pairs(positions) do
+ if type(key) ~= "number"
+ or key ~= math.floor(key)
+ or key < 1
+ or key > #positions
+ then
+ fail("direct preview markers must be a list of positions", 2)
+ end
+ position = domain.Position.coerce(position)
+ if not view:is_character_start(position) then
+ fail("direct preview marker must start an editor character", 2)
+ end
+ local identity = tostring(position.line) .. ":" .. tostring(position.byte_column)
+ if seen[identity] then
+ fail("direct preview marker positions must be unique", 2)
+ end
+ seen[identity] = true
+ result[key] = position
+ item_count = item_count + 1
+ end
+ if item_count ~= #positions then
+ fail("direct preview markers must be a list of positions", 2)
+ end
+ return result
+end
+
+function DirectPreviewPlanner:plan(view, origin, descriptor, count, settings)
+ local grouping = M.case_grouping_settings(settings)
+ local ignore_case = grouping.ignore_case
+ local smart_case = grouping.smart_case
+ local selected_occurrence = self:normalize_count(count)
+ local counters = {}
+ local positions = {}
+ local resolver = planner_records[self].case_resolver
+ for _, record in ipairs(self:scan_for_descriptor(view, origin, descriptor)) do
+ local selected
+ if ignore_case then
+ selected = increment_counter(
+ counters,
+ resolver:lowercase(record.character)
+ ) == selected_occurrence
+ elseif smart_case then
+ local exact_count, lowercase_count = increment_smart_counters(
+ counters,
+ record.character
+ )
+ selected = exact_count == selected_occurrence
+ or lowercase_count == selected_occurrence
+ else
+ selected = increment_counter(
+ counters,
+ record.character
+ ) == selected_occurrence
+ end
+ if selected then
+ positions[#positions + 1] = M.marker_position(descriptor, record.position)
+ end
+ end
+ return M.validate_marker_positions(view, positions)
+end
+
+function M.new(options)
+ return DirectPreviewPlanner.new(options)
+end
+
+function M.scan_current_line(view, origin, direction)
+ return DirectPreviewPlanner.new():scan_current_line(view, origin, direction)
+end
+
+function M.scan_for_descriptor(view, origin, descriptor)
+ return DirectPreviewPlanner.new():scan_for_descriptor(view, origin, descriptor)
+end
+
+function M.plan(view, origin, descriptor, count, settings, options)
+ return DirectPreviewPlanner.new(options):plan(
+ view,
+ origin,
+ descriptor,
+ count,
+ settings
+ )
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return DirectPreviewPlanner.new(options)
+ end,
+})
+
+return M
diff --git a/lua/clever_tee/domain.lua b/lua/clever_tee/domain.lua
new file mode 100644
index 0000000..ff0e513
--- /dev/null
+++ b/lua/clever_tee/domain.lua
@@ -0,0 +1,1390 @@
+local M = {}
+
+local records = setmetatable({}, { __mode = "k" })
+local record_types = setmetatable({}, { __mode = "k" })
+local methods = {}
+local formatters = {}
+local equalities = {}
+local metatables = {}
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function is_integer(value)
+ return type(value) == "number"
+ and value > -math.huge
+ and value < math.huge
+ and value == math.floor(value)
+end
+
+local function register_type(type_name, type_methods, formatter, equality)
+ methods[type_name] = type_methods or {}
+ formatters[type_name] = formatter
+ equalities[type_name] = equality
+
+ local mt = {
+ __index = function(value, key)
+ local field = records[value][key]
+ if field ~= nil then
+ return field
+ end
+ return methods[type_name][key]
+ end,
+ __newindex = function()
+ fail(type_name .. " values are immutable", 2)
+ end,
+ __tostring = function(value)
+ local format = formatters[type_name]
+ if format then
+ return format(records[value])
+ end
+ return type_name
+ end,
+ __eq = function(left, right)
+ if record_types[left] ~= type_name or record_types[right] ~= type_name then
+ return false
+ end
+ local equal = equalities[type_name]
+ if equal then
+ return equal(records[left], records[right])
+ end
+ return rawequal(left, right)
+ end,
+ __metatable = "clever_tee.domain." .. type_name,
+ }
+ metatables[type_name] = mt
+end
+
+local function new_record(type_name, fields)
+ local value = {}
+ records[value] = fields
+ record_types[value] = type_name
+ return setmetatable(value, metatables[type_name])
+end
+
+local function is_record(value, type_name)
+ return record_types[value] == type_name
+end
+
+local function require_record(value, type_name, name)
+ if not is_record(value, type_name) then
+ fail((name or "value") .. " must be a " .. type_name, 2)
+ end
+ return value
+end
+
+local function require_string(value, name, allow_empty)
+ if type(value) ~= "string" or (not allow_empty and value == "") then
+ fail(name .. " must be " .. (allow_empty and "a string" or "a nonempty string"), 2)
+ end
+ return value
+end
+
+local function require_boolean(value, name)
+ if type(value) ~= "boolean" then
+ fail(name .. " must be a Boolean", 2)
+ end
+ return value
+end
+
+local function require_nonnegative_integer(value, name)
+ if not is_integer(value) or value < 0 then
+ fail(name .. " must be a nonnegative integer", 2)
+ end
+ return value
+end
+
+function M.type_of(value)
+ return record_types[value]
+end
+
+local function define_enum(type_name, entries)
+ local enum_methods = {}
+ local namespace = {}
+ local by_value = {}
+
+ register_type(type_name, enum_methods, function(data)
+ return data.value
+ end)
+
+ for constant, serialized in pairs(entries) do
+ local value = new_record(type_name, {
+ name = constant,
+ value = serialized,
+ })
+ namespace[constant] = value
+ by_value[serialized] = value
+ end
+
+ function namespace.from_string(value)
+ if is_record(value, type_name) then
+ return value
+ end
+ local result = by_value[value]
+ if result == nil then
+ fail("value must be a valid " .. type_name, 2)
+ end
+ return result
+ end
+
+ function namespace.is(value)
+ return is_record(value, type_name)
+ end
+
+ function enum_methods:to_string()
+ return records[self].value
+ end
+
+ return namespace
+end
+
+M.Family = define_enum("Family", {
+ FIND = "FIND",
+ TILL = "TILL",
+})
+
+M.Direction = define_enum("Direction", {
+ FORWARD = "forward",
+ BACKWARD = "backward",
+})
+
+M.SelectionKind = define_enum("SelectionKind", {
+ NONE = "none",
+ CHARACTER = "character",
+ LINE = "line",
+ BLOCK = "block",
+})
+
+M.SelectionOption = define_enum("SelectionOption", {
+ INCLUSIVE = "inclusive",
+ EXCLUSIVE = "exclusive",
+})
+
+M.TargetKind = define_enum("TargetKind", {
+ CHARACTER = "character",
+ SPECIAL_KEY = "special_key",
+ CODE_FALLBACK = "code_fallback",
+})
+
+M.CaseMode = define_enum("CaseMode", {
+ SENSITIVE = "sensitive",
+ INSENSITIVE = "insensitive",
+})
+
+M.TargetPlanKind = define_enum("TargetPlanKind", {
+ EMPTY = "empty",
+ LITERAL = "literal",
+ BACKSLASH = "backslash",
+ SYMBOL = "symbol",
+ MIGEMO = "migemo",
+})
+
+M.SearchScope = define_enum("SearchScope", {
+ BUFFER = "buffer",
+ CURRENT_LINE = "current_line",
+})
+
+M.EndpointPolicy = define_enum("EndpointPolicy", {
+ REGULAR = "regular",
+ VISUAL_EXCLUSIVE = "visual_exclusive",
+})
+
+M.SearchStatus = define_enum("SearchStatus", {
+ COMPLETE = "complete",
+ BOUNDARY_AFTER_PARTIAL = "boundary_after_partial",
+ BOUNDARY_BEFORE_ANY = "boundary_before_any",
+})
+
+M.ActionKind = define_enum("ActionKind", {
+ MOVEMENT = "movement",
+ NEUTRAL = "neutral",
+ ESCAPE = "escape",
+ FAILED_SEARCH = "failed_search",
+ ERROR = "error",
+ EMPTY = "empty",
+})
+
+local Position = {}
+M.Position = Position
+
+register_type("Position", Position, function(data)
+ return string.format("(%d,%d)", data.line, data.byte_column)
+end, function(left, right)
+ return left.line == right.line and left.byte_column == right.byte_column
+end)
+
+function Position.new(line, byte_column)
+ if not is_integer(line) or line < 1 then
+ fail("line must be a positive one-based integer", 2)
+ end
+ if not is_integer(byte_column) or byte_column < 1 then
+ fail("byte_column must be a positive one-based integer", 2)
+ end
+ return new_record("Position", {
+ line = line,
+ byte_column = byte_column,
+ })
+end
+
+function Position.coerce(value)
+ if Position.is(value) then
+ return value
+ end
+ if type(value) ~= "table" then
+ fail("position must be a Position or position table", 2)
+ end
+ return Position.new(value.line, value.byte_column)
+end
+
+function Position.is(value)
+ return is_record(value, "Position")
+end
+
+function Position.compare(left, right)
+ require_record(left, "Position", "left")
+ require_record(right, "Position", "right")
+ if left.line < right.line then
+ return -1
+ end
+ if left.line > right.line then
+ return 1
+ end
+ if left.byte_column < right.byte_column then
+ return -1
+ end
+ if left.byte_column > right.byte_column then
+ return 1
+ end
+ return 0
+end
+
+function Position.equal(left, right)
+ return Position.compare(left, right) == 0
+end
+
+function Position.stationary(left, right)
+ return Position.equal(left, right)
+end
+
+function Position.is_forward(candidate, origin)
+ return Position.compare(candidate, origin) > 0
+end
+
+function Position.is_backward(candidate, origin)
+ return Position.compare(candidate, origin) < 0
+end
+
+function Position:to_table()
+ return {
+ line = self.line,
+ byte_column = self.byte_column,
+ }
+end
+
+local Descriptor = {}
+M.Descriptor = Descriptor
+
+register_type("Descriptor", Descriptor, function(data)
+ return data.value
+end)
+
+local descriptors_by_string = {}
+local descriptors_by_parts = {}
+
+local function descriptor_key(family, direction)
+ return family.value .. ":" .. direction.value
+end
+
+local function define_descriptor(name, serialized, family, direction)
+ local descriptor = new_record("Descriptor", {
+ name = name,
+ value = serialized,
+ family = family,
+ direction = direction,
+ uppercase = serialized:match("%u") ~= nil,
+ })
+ descriptors_by_string[serialized] = descriptor
+ descriptors_by_parts[descriptor_key(family, direction)] = descriptor
+ Descriptor[name] = descriptor
+ Descriptor[serialized] = descriptor
+ return descriptor
+end
+
+Descriptor.FIND_FORWARD = define_descriptor(
+ "FIND_FORWARD",
+ "f",
+ M.Family.FIND,
+ M.Direction.FORWARD
+)
+Descriptor.FIND_BACKWARD = define_descriptor(
+ "FIND_BACKWARD",
+ "F",
+ M.Family.FIND,
+ M.Direction.BACKWARD
+)
+Descriptor.TILL_FORWARD = define_descriptor(
+ "TILL_FORWARD",
+ "t",
+ M.Family.TILL,
+ M.Direction.FORWARD
+)
+Descriptor.TILL_BACKWARD = define_descriptor(
+ "TILL_BACKWARD",
+ "T",
+ M.Family.TILL,
+ M.Direction.BACKWARD
+)
+
+function Descriptor.is(value)
+ return is_record(value, "Descriptor")
+end
+
+function Descriptor.is_valid(value)
+ return Descriptor.is(value) or descriptors_by_string[value] ~= nil
+end
+
+function Descriptor.from_string(value)
+ if Descriptor.is(value) then
+ return value
+ end
+ local descriptor = descriptors_by_string[value]
+ if descriptor == nil then
+ fail("descriptor must be one of f, F, t, or T", 2)
+ end
+ return descriptor
+end
+
+function Descriptor.try_from_string(value)
+ if Descriptor.is(value) then
+ return value
+ end
+ return descriptors_by_string[value]
+end
+
+function Descriptor.from_parts(family, direction)
+ family = M.Family.from_string(family)
+ direction = M.Direction.from_string(direction)
+ return descriptors_by_parts[descriptor_key(family, direction)]
+end
+
+function Descriptor.to_string(value)
+ return Descriptor.from_string(value).value
+end
+
+function Descriptor.is_uppercase(value)
+ return Descriptor.from_string(value).uppercase
+end
+
+function Descriptor.is_lowercase(value)
+ return not Descriptor.is_uppercase(value)
+end
+
+function Descriptor.swap(value)
+ local descriptor = Descriptor.from_string(value)
+ local direction = descriptor.direction == M.Direction.FORWARD
+ and M.Direction.BACKWARD
+ or M.Direction.FORWARD
+ return Descriptor.from_parts(descriptor.family, direction)
+end
+
+function Descriptor.lowercase(value)
+ local descriptor = Descriptor.from_string(value)
+ return Descriptor.from_parts(descriptor.family, M.Direction.FORWARD)
+end
+
+function Descriptor.uppercase(value)
+ local descriptor = Descriptor.from_string(value)
+ return Descriptor.from_parts(descriptor.family, M.Direction.BACKWARD)
+end
+
+local Count = {}
+M.Count = Count
+
+register_type("Count", Count, function(data)
+ return tostring(data.value)
+end, function(left, right)
+ return left.value == right.value
+end)
+
+local count_one
+
+function Count.new(value)
+ if Count.is(value) then
+ return value
+ end
+ if value == nil then
+ value = 1
+ end
+ if not is_integer(value) or value < 1 then
+ fail("count must be a positive integer", 2)
+ end
+ if value == 1 and count_one ~= nil then
+ return count_one
+ end
+ local count = new_record("Count", { value = value })
+ if value == 1 then
+ count_one = count
+ end
+ return count
+end
+
+function Count.is(value)
+ return is_record(value, "Count")
+end
+
+function Count.to_number(value)
+ return Count.new(value).value
+end
+
+Count.ONE = Count.new(1)
+
+local ModeContext = {}
+M.ModeContext = ModeContext
+
+register_type("ModeContext", ModeContext, function(data)
+ return data.key
+end)
+
+local mode_contexts = {}
+local CTRL_V = string.char(0x16)
+local CTRL_S = string.char(0x13)
+
+local operator_modes = {
+ no = true,
+ nov = true,
+ noV = true,
+ ["no" .. CTRL_V] = true,
+}
+
+local function mode_traits(full_mode)
+ local operator = operator_modes[full_mode] == true
+ local visual_kind
+ local select_kind
+
+ if not operator then
+ local lead = full_mode:sub(1, 1)
+ if lead == "v" then
+ visual_kind = M.SelectionKind.CHARACTER
+ elseif lead == "V" then
+ visual_kind = M.SelectionKind.LINE
+ elseif lead == CTRL_V then
+ visual_kind = M.SelectionKind.BLOCK
+ elseif lead == "s" then
+ select_kind = M.SelectionKind.CHARACTER
+ elseif lead == "S" then
+ select_kind = M.SelectionKind.LINE
+ elseif lead == CTRL_S then
+ select_kind = M.SelectionKind.BLOCK
+ end
+ end
+
+ return operator, visual_kind, select_kind
+end
+
+function ModeContext.from_full_mode(full_mode)
+ if ModeContext.is(full_mode) then
+ return full_mode
+ end
+ require_string(full_mode, "full_mode", false)
+
+ local operator, visual_kind, select_kind = mode_traits(full_mode)
+ local key = operator and "no" or full_mode
+ local context = mode_contexts[key]
+ if context ~= nil then
+ return context
+ end
+
+ context = new_record("ModeContext", {
+ key = key,
+ full_mode = key,
+ operator = operator,
+ visual_kind = visual_kind,
+ select_kind = select_kind,
+ visual = visual_kind ~= nil,
+ select = select_kind ~= nil,
+ command_path = visual_kind == nil,
+ })
+ mode_contexts[key] = context
+ return context
+end
+
+function ModeContext.is(value)
+ return is_record(value, "ModeContext")
+end
+
+function ModeContext.equal(left, right)
+ require_record(left, "ModeContext", "left")
+ require_record(right, "ModeContext", "right")
+ return left.key == right.key
+end
+
+function ModeContext:to_key()
+ return self.key
+end
+
+local Selection = {}
+M.Selection = Selection
+
+register_type("Selection", Selection, function(data)
+ return data.active and ("selection:" .. data.kind.value) or "selection:none"
+end, function(left, right)
+ return left.active == right.active
+ and left.kind == right.kind
+ and left.anchor == right.anchor
+ and left.focus == right.focus
+ and left.option == right.option
+end)
+
+function Selection.new(options)
+ if Selection.is(options) then
+ return options
+ end
+ if type(options) ~= "table" then
+ fail("selection options must be a table", 2)
+ end
+
+ local active = require_boolean(options.active, "selection.active")
+ local kind = M.SelectionKind.from_string(options.kind)
+ local option = M.SelectionOption.from_string(options.option or "inclusive")
+ local anchor = options.anchor
+ local focus = options.focus
+
+ if active then
+ if kind == M.SelectionKind.NONE then
+ fail("an active selection must have a selection kind", 2)
+ end
+ anchor = Position.coerce(anchor)
+ focus = Position.coerce(focus)
+ else
+ if kind ~= M.SelectionKind.NONE then
+ fail("an inactive selection must use the none kind", 2)
+ end
+ if anchor ~= nil or focus ~= nil then
+ fail("an inactive selection must have empty endpoints", 2)
+ end
+ end
+
+ return new_record("Selection", {
+ active = active,
+ kind = kind,
+ anchor = anchor,
+ focus = focus,
+ option = option,
+ })
+end
+
+function Selection.inactive(option)
+ return Selection.new({
+ active = false,
+ kind = M.SelectionKind.NONE,
+ option = option or M.SelectionOption.INCLUSIVE,
+ })
+end
+
+function Selection.active(kind, anchor, focus, option)
+ return Selection.new({
+ active = true,
+ kind = kind,
+ anchor = anchor,
+ focus = focus,
+ option = option or M.SelectionOption.INCLUSIVE,
+ })
+end
+
+function Selection.is(value)
+ return is_record(value, "Selection")
+end
+
+function Selection:with_focus(focus, kind)
+ if not self.active then
+ fail("selection must be active", 2)
+ end
+ return Selection.active(kind or self.kind, self.anchor, focus, self.option)
+end
+
+function Selection:to_table()
+ return {
+ active = self.active,
+ kind = self.kind.value,
+ anchor = self.anchor and self.anchor:to_table() or nil,
+ focus = self.focus and self.focus:to_table() or nil,
+ option = self.option.value,
+ }
+end
+
+local TextSnapshot = {}
+M.TextSnapshot = TextSnapshot
+local text_lines = setmetatable({}, { __mode = "k" })
+
+register_type("TextSnapshot", TextSnapshot, function(data)
+ return "text:" .. tostring(data.line_count) .. " lines"
+end, function(left, right)
+ if left.line_count ~= right.line_count then
+ return false
+ end
+ local left_lines = text_lines[left.identity]
+ local right_lines = text_lines[right.identity]
+ for index = 1, left.line_count do
+ if left_lines[index] ~= right_lines[index] then
+ return false
+ end
+ end
+ return true
+end)
+
+function TextSnapshot.new(lines)
+ if TextSnapshot.is(lines) then
+ return lines
+ end
+ if type(lines) ~= "table" or #lines < 1 then
+ fail("text lines must be a nonempty list", 2)
+ end
+ local copy = {}
+ for index = 1, #lines do
+ if type(lines[index]) ~= "string" then
+ fail("each text line must be a string", 2)
+ end
+ copy[index] = lines[index]
+ end
+ local identity = {}
+ text_lines[identity] = copy
+ return new_record("TextSnapshot", {
+ identity = identity,
+ line_count = #copy,
+ })
+end
+
+function TextSnapshot.is(value)
+ return is_record(value, "TextSnapshot")
+end
+
+function TextSnapshot:line(line_number)
+ if not is_integer(line_number) or line_number < 1 or line_number > self.line_count then
+ fail("line_number must identify a line in the text snapshot", 2)
+ end
+ return text_lines[self.identity][line_number]
+end
+
+function TextSnapshot:lines()
+ local result = {}
+ local source = text_lines[self.identity]
+ for index = 1, self.line_count do
+ result[index] = source[index]
+ end
+ return result
+end
+
+function TextSnapshot:to_table()
+ return { lines = self:lines() }
+end
+
+local MacroState = {}
+M.MacroState = MacroState
+
+register_type("MacroState", MacroState, function(data)
+ return data.executing and ("macro:" .. data.register) or "macro:inactive"
+end, function(left, right)
+ return left.register == right.register
+end)
+
+function MacroState.new(register)
+ if MacroState.is(register) then
+ return register
+ end
+ if register == "" then
+ register = nil
+ end
+ if register ~= nil then
+ require_string(register, "macro register", false)
+ end
+ return new_record("MacroState", {
+ register = register,
+ executing = register ~= nil,
+ })
+end
+
+function MacroState.is(value)
+ return is_record(value, "MacroState")
+end
+
+local FoldState = {}
+M.FoldState = FoldState
+local fold_policies = setmetatable({}, { __mode = "k" })
+
+register_type("FoldState", FoldState, function(data)
+ return "folds:" .. tostring(data.closed_levels)
+end)
+
+function FoldState.new(open_policy, closed_levels)
+ if FoldState.is(open_policy) and closed_levels == nil then
+ return open_policy
+ end
+ if type(open_policy) ~= "table" then
+ fail("fold open policy must be a list", 2)
+ end
+ require_nonnegative_integer(closed_levels, "closed fold levels")
+
+ local identity = {}
+ local policies = {}
+ local seen = {}
+ for index = 1, #open_policy do
+ local policy = require_string(open_policy[index], "fold policy item", false)
+ if not seen[policy] then
+ seen[policy] = true
+ policies[#policies + 1] = policy
+ end
+ end
+ fold_policies[identity] = {
+ list = policies,
+ set = seen,
+ }
+ return new_record("FoldState", {
+ identity = identity,
+ closed_levels = closed_levels,
+ })
+end
+
+function FoldState.is(value)
+ return is_record(value, "FoldState")
+end
+
+function FoldState:opens(policy)
+ require_string(policy, "fold policy", false)
+ return fold_policies[self.identity].set[policy] == true
+end
+
+function FoldState:policies()
+ local result = {}
+ local source = fold_policies[self.identity].list
+ for index = 1, #source do
+ result[index] = source[index]
+ end
+ return result
+end
+
+local InputPacket = {}
+M.InputPacket = InputPacket
+local packet_bytes = setmetatable({}, { __mode = "k" })
+
+M.InputPacketKind = define_enum("InputPacketKind", {
+ TEXT = "text",
+ RAW_BYTES = "raw_bytes",
+ SPECIAL_KEY = "special_key",
+ ERROR = "error",
+})
+
+register_type("InputPacket", InputPacket, function(data)
+ return "input:" .. data.kind.value
+end)
+
+function InputPacket.text(text)
+ require_string(text, "input text", false)
+ return new_record("InputPacket", {
+ kind = M.InputPacketKind.TEXT,
+ text = text,
+ })
+end
+
+local function validated_packet_bytes(bytes, kind)
+ if type(bytes) ~= "table" or #bytes < 1 then
+ fail(kind .. " bytes must be a nonempty list", 3)
+ end
+ local copy = {}
+ for index = 1, #bytes do
+ local byte = bytes[index]
+ if not is_integer(byte) or byte < 0 or byte > 255 then
+ fail(kind .. " bytes must contain byte values", 3)
+ end
+ copy[index] = byte
+ end
+ return copy
+end
+
+local function bytes_from_string(value)
+ local bytes = {}
+ for index = 1, #value do
+ bytes[index] = string.byte(value, index)
+ end
+ return bytes
+end
+
+local function string_from_bytes(bytes)
+ local characters = {}
+ for index = 1, #bytes do
+ characters[index] = string.char(bytes[index])
+ end
+ return table.concat(characters)
+end
+
+function InputPacket.raw_bytes(bytes)
+ local identity = {}
+ packet_bytes[identity] = validated_packet_bytes(bytes, "raw input")
+ return new_record("InputPacket", {
+ kind = M.InputPacketKind.RAW_BYTES,
+ identity = identity,
+ })
+end
+
+function InputPacket.special_key(name, encoded)
+ require_string(name, "special key name", false)
+ local bytes
+ if type(encoded) == "table" then
+ bytes = validated_packet_bytes(encoded, "special key")
+ encoded = string_from_bytes(bytes)
+ elseif encoded ~= nil then
+ require_string(encoded, "encoded special key", false)
+ bytes = bytes_from_string(encoded)
+ end
+ local identity
+ if bytes ~= nil then
+ identity = {}
+ packet_bytes[identity] = bytes
+ end
+ return new_record("InputPacket", {
+ kind = M.InputPacketKind.SPECIAL_KEY,
+ name = name,
+ encoded = encoded,
+ identity = identity,
+ })
+end
+
+function InputPacket.error(message)
+ require_string(message, "input error message", false)
+ return new_record("InputPacket", {
+ kind = M.InputPacketKind.ERROR,
+ message = message,
+ })
+end
+
+function InputPacket.from_table(packet)
+ if InputPacket.is(packet) then
+ return packet
+ end
+ if type(packet) ~= "table" then
+ fail("input packet must be an InputPacket or packet table", 2)
+ end
+ local kind = M.InputPacketKind.from_string(packet.kind)
+ if kind == M.InputPacketKind.TEXT then
+ return InputPacket.text(packet.text)
+ end
+ if kind == M.InputPacketKind.RAW_BYTES then
+ return InputPacket.raw_bytes(packet.bytes)
+ end
+ if kind == M.InputPacketKind.SPECIAL_KEY then
+ return InputPacket.special_key(packet.name, packet.bytes or packet.encoded)
+ end
+ return InputPacket.error(packet.message)
+end
+
+function InputPacket.is(value)
+ return is_record(value, "InputPacket")
+end
+
+function InputPacket:bytes()
+ if self.kind ~= M.InputPacketKind.RAW_BYTES
+ and self.kind ~= M.InputPacketKind.SPECIAL_KEY
+ then
+ return nil
+ end
+ local source = self.identity and packet_bytes[self.identity] or nil
+ if source == nil then
+ return nil
+ end
+ local result = {}
+ for index = 1, #source do
+ result[index] = source[index]
+ end
+ return result
+end
+
+function InputPacket:to_table()
+ local result = { kind = self.kind.value }
+ if self.kind == M.InputPacketKind.TEXT then
+ result.text = self.text
+ elseif self.kind == M.InputPacketKind.RAW_BYTES then
+ result.bytes = self:bytes()
+ elseif self.kind == M.InputPacketKind.SPECIAL_KEY then
+ result.name = self.name
+ result.bytes = self:bytes()
+ else
+ result.message = self.message
+ end
+ return result
+end
+
+local TargetValue = {}
+M.TargetValue = TargetValue
+
+register_type("TargetValue", TargetValue, function(data)
+ return "target:" .. data.kind.value .. ":" .. tostring(data.first_code)
+end, function(left, right)
+ return left.kind == right.kind
+ and left.value == right.value
+ and left.first_code == right.first_code
+end)
+
+function TargetValue.character(value, first_code)
+ require_string(value, "target character", false)
+ require_nonnegative_integer(first_code, "target first code")
+ return new_record("TargetValue", {
+ kind = M.TargetKind.CHARACTER,
+ value = value,
+ first_code = first_code,
+ })
+end
+
+function TargetValue.special_key(value, first_code)
+ require_string(value, "encoded special key", false)
+ first_code = first_code or string.byte(value, 1)
+ require_nonnegative_integer(first_code, "target first code")
+ if first_code ~= 0x80 then
+ fail("an encoded special key must start with hexadecimal 80", 2)
+ end
+ return new_record("TargetValue", {
+ kind = M.TargetKind.SPECIAL_KEY,
+ value = value,
+ first_code = first_code,
+ })
+end
+
+function TargetValue.code_fallback(first_code)
+ first_code = first_code or 0
+ require_nonnegative_integer(first_code, "fallback character code")
+ return new_record("TargetValue", {
+ kind = M.TargetKind.CODE_FALLBACK,
+ value = "",
+ first_code = first_code,
+ })
+end
+
+function TargetValue.from_table(target)
+ if TargetValue.is(target) then
+ return target
+ end
+ if type(target) ~= "table" then
+ fail("target must be a TargetValue or target table", 2)
+ end
+ local kind = M.TargetKind.from_string(target.kind)
+ if kind == M.TargetKind.CHARACTER then
+ return TargetValue.character(target.value, target.first_code)
+ end
+ if kind == M.TargetKind.SPECIAL_KEY then
+ return TargetValue.special_key(target.value, target.first_code)
+ end
+ return TargetValue.code_fallback(target.first_code)
+end
+
+function TargetValue.is(value)
+ return is_record(value, "TargetValue")
+end
+
+function TargetValue:to_table()
+ return {
+ kind = self.kind.value,
+ value = self.value,
+ first_code = self.first_code,
+ }
+end
+
+local TargetPlan = {}
+M.TargetPlan = TargetPlan
+
+register_type("TargetPlan", TargetPlan, function(data)
+ return "target-plan:" .. data.kind.value
+end)
+
+function TargetPlan.new(options)
+ if TargetPlan.is(options) then
+ return options
+ end
+ if type(options) ~= "table" then
+ fail("target plan options must be a table", 2)
+ end
+ local target = require_record(options.target, "TargetValue", "target plan target")
+ local kind = M.TargetPlanKind.from_string(options.kind)
+ local case_mode = M.CaseMode.from_string(options.case_mode)
+ if type(options.matcher) ~= "function" then
+ fail("target plan matcher must be a function", 2)
+ end
+ return new_record("TargetPlan", {
+ target = target,
+ kind = kind,
+ case_mode = case_mode,
+ matcher = options.matcher,
+ })
+end
+
+function TargetPlan.is(value)
+ return is_record(value, "TargetPlan")
+end
+
+function TargetPlan:matches(...)
+ return self.matcher(...)
+end
+
+function TargetPlan:matches_at(text_view, position)
+ if type(text_view) ~= "table" or type(text_view.character_at) ~= "function" then
+ fail("target plan match requires a text view", 2)
+ end
+ position = Position.coerce(position)
+ return self.matcher(text_view:character_at(position), position, text_view)
+end
+
+function TargetPlan:to_table()
+ return {
+ target = self.target:to_table(),
+ kind = self.kind.value,
+ case_mode = self.case_mode.value,
+ }
+end
+
+local ResolvedMotionPlan = {}
+M.ResolvedMotionPlan = ResolvedMotionPlan
+
+register_type("ResolvedMotionPlan", ResolvedMotionPlan, function(data)
+ return "motion-plan:" .. data.descriptor.value
+end)
+
+function ResolvedMotionPlan.new(options)
+ if ResolvedMotionPlan.is(options) then
+ return options
+ end
+ if type(options) ~= "table" then
+ fail("resolved motion plan options must be a table", 2)
+ end
+ return new_record("ResolvedMotionPlan", {
+ target_plan = require_record(options.target_plan, "TargetPlan", "target plan"),
+ descriptor = Descriptor.from_string(options.descriptor),
+ search_scope = M.SearchScope.from_string(options.search_scope),
+ endpoint_policy = M.EndpointPolicy.from_string(options.endpoint_policy),
+ })
+end
+
+function ResolvedMotionPlan.is(value)
+ return is_record(value, "ResolvedMotionPlan")
+end
+
+function ResolvedMotionPlan:to_table()
+ return {
+ target_plan = self.target_plan:to_table(),
+ descriptor = self.descriptor.value,
+ search_scope = self.search_scope.value,
+ endpoint_policy = self.endpoint_policy.value,
+ }
+end
+
+local MotionRequest = {}
+M.MotionRequest = MotionRequest
+
+register_type("MotionRequest", MotionRequest, function(data)
+ return "motion-request:" .. data.descriptor.value
+end)
+
+function MotionRequest.new(options)
+ if MotionRequest.is(options) then
+ return options
+ end
+ if type(options) ~= "table" then
+ fail("motion request options must be a table", 2)
+ end
+ if options.policy == nil then
+ fail("motion request policy is required", 2)
+ end
+ return new_record("MotionRequest", {
+ context = require_record(options.context, "ModeContext", "motion context"),
+ origin = Position.coerce(options.origin),
+ descriptor = Descriptor.from_string(options.descriptor),
+ target = require_record(options.target, "TargetValue", "motion target"),
+ count = Count.new(options.count),
+ policy = options.policy,
+ first_move = require_boolean(options.first_move, "first_move"),
+ })
+end
+
+function MotionRequest.is(value)
+ return is_record(value, "MotionRequest")
+end
+
+local SearchOutcome = {}
+M.SearchOutcome = SearchOutcome
+
+register_type("SearchOutcome", SearchOutcome, function(data)
+ return "search:" .. data.status.value
+end)
+
+local function new_search_outcome(status, endpoint, successful_steps)
+ status = M.SearchStatus.from_string(status)
+ endpoint = Position.coerce(endpoint)
+ require_nonnegative_integer(successful_steps, "successful_steps")
+
+ if status == M.SearchStatus.COMPLETE and successful_steps < 1 then
+ fail("a complete search must contain a successful step", 3)
+ end
+ if status == M.SearchStatus.BOUNDARY_AFTER_PARTIAL and successful_steps < 1 then
+ fail("a partial search must contain a successful step", 3)
+ end
+ if status == M.SearchStatus.BOUNDARY_BEFORE_ANY and successful_steps ~= 0 then
+ fail("a boundary-before-any search must contain zero successful steps", 3)
+ end
+
+ return new_record("SearchOutcome", {
+ status = status,
+ endpoint = endpoint,
+ successful_steps = successful_steps,
+ complete = status == M.SearchStatus.COMPLETE,
+ })
+end
+
+function SearchOutcome.new(options)
+ if SearchOutcome.is(options) then
+ return options
+ end
+ if type(options) ~= "table" then
+ fail("search outcome options must be a table", 2)
+ end
+ return new_search_outcome(options.status, options.endpoint, options.successful_steps)
+end
+
+function SearchOutcome.complete(endpoint, successful_steps)
+ return new_search_outcome(M.SearchStatus.COMPLETE, endpoint, successful_steps)
+end
+
+function SearchOutcome.boundary_after_partial(endpoint, successful_steps)
+ return new_search_outcome(
+ M.SearchStatus.BOUNDARY_AFTER_PARTIAL,
+ endpoint,
+ successful_steps
+ )
+end
+
+function SearchOutcome.boundary_before_any(origin)
+ return new_search_outcome(M.SearchStatus.BOUNDARY_BEFORE_ANY, origin, 0)
+end
+
+function SearchOutcome.is(value)
+ return is_record(value, "SearchOutcome")
+end
+
+function SearchOutcome:to_table()
+ return {
+ status = self.status.value,
+ endpoint = self.endpoint:to_table(),
+ successful_steps = self.successful_steps,
+ complete = self.complete,
+ }
+end
+
+local DotPayload = {}
+M.DotPayload = DotPayload
+
+register_type("DotPayload", DotPayload, function(data)
+ return "dot:" .. data.descriptor.value
+end, function(left, right)
+ return left.descriptor == right.descriptor and left.target == right.target
+end)
+
+function DotPayload.new(descriptor, target)
+ if DotPayload.is(descriptor) and target == nil then
+ return descriptor
+ end
+ return new_record("DotPayload", {
+ descriptor = Descriptor.from_string(descriptor),
+ target = require_record(target, "TargetValue", "dot target"),
+ })
+end
+
+function DotPayload.is(value)
+ return is_record(value, "DotPayload")
+end
+
+function DotPayload:to_table()
+ return {
+ descriptor = self.descriptor.value,
+ target = self.target:to_table(),
+ }
+end
+
+local ExplicitRepeatRequest = {}
+M.ExplicitRepeatRequest = ExplicitRepeatRequest
+
+register_type("ExplicitRepeatRequest", ExplicitRepeatRequest, function(data)
+ if data.neutral then
+ return "explicit-repeat:neutral"
+ end
+ return "explicit-repeat:" .. data.descriptor.value
+end, function(left, right)
+ return left.neutral == right.neutral
+ and left.descriptor == right.descriptor
+ and left.target == right.target
+end)
+
+local neutral_explicit_repeat_request
+
+function ExplicitRepeatRequest.new(descriptor, target)
+ if ExplicitRepeatRequest.is(descriptor) and target == nil then
+ return descriptor
+ end
+ descriptor = Descriptor.from_string(descriptor)
+ target = require_record(target, "TargetValue", "explicit repeat target")
+ return new_record("ExplicitRepeatRequest", {
+ descriptor = descriptor,
+ effective_descriptor = descriptor,
+ target = target,
+ neutral = false,
+ })
+end
+
+function ExplicitRepeatRequest.neutral()
+ if neutral_explicit_repeat_request == nil then
+ neutral_explicit_repeat_request = new_record("ExplicitRepeatRequest", {
+ descriptor = nil,
+ effective_descriptor = nil,
+ target = nil,
+ neutral = true,
+ })
+ end
+ return neutral_explicit_repeat_request
+end
+
+function ExplicitRepeatRequest.is(value)
+ return is_record(value, "ExplicitRepeatRequest")
+end
+
+function ExplicitRepeatRequest:is_neutral()
+ return self.neutral
+end
+
+function ExplicitRepeatRequest:to_table()
+ if self.neutral then
+ return { neutral = true }
+ end
+ return {
+ descriptor = self.descriptor.value,
+ target = self.target:to_table(),
+ neutral = false,
+ }
+end
+
+local ActionOutcome = {}
+M.ActionOutcome = ActionOutcome
+
+register_type("ActionOutcome", ActionOutcome, function(data)
+ return "action:" .. data.kind.value
+end)
+
+local function new_action_outcome(options)
+ local kind = M.ActionKind.from_string(options.kind)
+ local position = Position.coerce(options.position)
+ local search_outcome = options.search_outcome
+ local descriptor = options.effective_descriptor
+ local dot_payload = options.dot_payload
+
+ if search_outcome ~= nil then
+ require_record(search_outcome, "SearchOutcome", "search outcome")
+ end
+ if descriptor ~= nil then
+ descriptor = Descriptor.from_string(descriptor)
+ end
+ if dot_payload ~= nil then
+ require_record(dot_payload, "DotPayload", "dot payload")
+ end
+ if options.diagnostic ~= nil then
+ require_string(options.diagnostic, "diagnostic", false)
+ end
+
+ if kind == M.ActionKind.MOVEMENT then
+ if search_outcome == nil or not search_outcome.complete then
+ fail("a movement action requires a complete search outcome", 3)
+ end
+ elseif kind == M.ActionKind.FAILED_SEARCH then
+ if search_outcome == nil or search_outcome.complete then
+ fail("a failed-search action requires an incomplete search outcome", 3)
+ end
+ elseif search_outcome ~= nil then
+ fail("only movement and failed-search actions can contain a search outcome", 3)
+ end
+
+ if kind == M.ActionKind.ERROR and options.diagnostic == nil then
+ fail("an error action requires a diagnostic", 3)
+ end
+
+ local complete
+ if search_outcome ~= nil then
+ complete = search_outcome.complete
+ end
+
+ return new_record("ActionOutcome", {
+ kind = kind,
+ position = position,
+ search_outcome = search_outcome,
+ complete = complete,
+ successful_steps = search_outcome and search_outcome.successful_steps or 0,
+ effective_descriptor = descriptor,
+ dot_payload = dot_payload,
+ diagnostic = options.diagnostic,
+ })
+end
+
+function ActionOutcome.new(options)
+ if ActionOutcome.is(options) then
+ return options
+ end
+ if type(options) ~= "table" then
+ fail("action outcome options must be a table", 2)
+ end
+ return new_action_outcome(options)
+end
+
+function ActionOutcome.from_search(search_outcome, descriptor, dot_payload)
+ require_record(search_outcome, "SearchOutcome", "search outcome")
+ return new_action_outcome({
+ kind = search_outcome.complete and M.ActionKind.MOVEMENT or M.ActionKind.FAILED_SEARCH,
+ position = search_outcome.endpoint,
+ search_outcome = search_outcome,
+ effective_descriptor = descriptor,
+ dot_payload = dot_payload,
+ })
+end
+
+local function simple_action(kind, position, diagnostic)
+ return new_action_outcome({
+ kind = kind,
+ position = position,
+ diagnostic = diagnostic,
+ })
+end
+
+function ActionOutcome.neutral(position)
+ return simple_action(M.ActionKind.NEUTRAL, position)
+end
+
+function ActionOutcome.escape(position)
+ return simple_action(M.ActionKind.ESCAPE, position)
+end
+
+function ActionOutcome.empty(position)
+ return simple_action(M.ActionKind.EMPTY, position)
+end
+
+function ActionOutcome.error(position, diagnostic)
+ return simple_action(M.ActionKind.ERROR, position, diagnostic)
+end
+
+function ActionOutcome.is(value)
+ return is_record(value, "ActionOutcome")
+end
+
+function ActionOutcome:to_table()
+ return {
+ kind = self.kind.value,
+ position = self.position:to_table(),
+ complete = self.complete,
+ successful_steps = self.successful_steps,
+ effective_descriptor = self.effective_descriptor and self.effective_descriptor.value or nil,
+ dot_payload = self.dot_payload and self.dot_payload:to_table() or nil,
+ diagnostic = self.diagnostic,
+ }
+end
+
+return M
diff --git a/lua/clever_tee/feedback_service.lua b/lua/clever_tee/feedback_service.lua
new file mode 100644
index 0000000..b99fe04
--- /dev/null
+++ b/lua/clever_tee/feedback_service.lua
@@ -0,0 +1,1003 @@
+local domain = require("clever_tee.domain")
+local policy = require("clever_tee.policy")
+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 FeedbackService = {}
+FeedbackService.__index = FeedbackService
+M.FeedbackService = FeedbackService
+local CursorPresentationLease = {}
+M.CursorPresentationLease = CursorPresentationLease
+
+M.DEFAULT_LABEL_GROUP = "CleverTeeDefaultLabel"
+M.Priority = {
+ HIGH = "high",
+ ORDINARY = "ordinary",
+}
+M.FINALIZER_EVENTS = {
+ "CursorMoved",
+ "InsertEnter",
+ "TextChanged",
+}
+M.EAGER_EVENTS = {
+ "WinEnter",
+ "WinLeave",
+ "CmdwinLeave",
+}
+M.FinalizerAction = {
+ PRESERVE = "preserve",
+ FINALIZE = "finalize",
+}
+M.MigrationReason = {
+ LINE_CHANGE = "line_change",
+ TILL_DIRECTION_CHANGE = "till_direction_change",
+}
+
+local OVERLAY_PRIORITIES = {
+ CleverTeeCursor = M.Priority.HIGH,
+ CleverTeeChar = M.Priority.HIGH,
+ CleverTeeDirect = M.Priority.ORDINARY,
+}
+
+local service_records = setmetatable({}, { __mode = "k" })
+local cursor_lease_records = setmetatable({}, { __mode = "k" })
+local temporary_release_records = setmetatable({}, { __mode = "k" })
+
+local FEATURE_GROUPS = {
+ "CleverTeeCursor",
+ "CleverTeeChar",
+ "CleverTeeDirect",
+}
+
+local LEGACY_NORMAL_EX_CONTEXTS = {
+ cv = true,
+ cvr = true,
+}
+
+local DIRECT_FINALIZER_EVENTS = {
+ InsertEnter = true,
+ TextChanged = true,
+}
+
+local EAGER_EVENT_SET = {}
+for _, event_name in ipairs(M.EAGER_EVENTS) do
+ EAGER_EVENT_SET[event_name] = true
+end
+
+local DEFAULT_LABEL_DEFINITION = {
+ guifg = "red",
+ guibg = "NONE",
+ gui = {
+ bold = true,
+ underline = true,
+ },
+ ctermfg = "red",
+ ctermbg = "NONE",
+ cterm = {
+ bold = true,
+ underline = true,
+ },
+}
+
+local function copy(value)
+ if type(value) ~= "table" then
+ return value
+ end
+ local result = {}
+ for key, item in pairs(value) do
+ result[key] = copy(item)
+ end
+ return result
+end
+
+function M.default_label_definition()
+ return copy(DEFAULT_LABEL_DEFINITION)
+end
+
+function M.overlay_priority(group)
+ local priority = OVERLAY_PRIORITIES[group]
+ if priority == nil then
+ error("unknown feedback overlay group '" .. tostring(group) .. "'", 2)
+ end
+ return priority
+end
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function normalize_options(options)
+ if type(options) ~= "table" then
+ fail("FeedbackService options must be a table", 3)
+ end
+ if options.host == nil then
+ return { host = options }
+ end
+ return options
+end
+
+local function require_host(host)
+ if type(host) ~= "table"
+ or type(host.read_highlight_group) ~= "function"
+ or type(host.define_highlight_group) ~= "function"
+ or type(host.create_highlight) ~= "function"
+ or type(host.remove_highlight) ~= "function"
+ or type(host.read_buffer) ~= "function"
+ or type(host.read_cursor) ~= "function"
+ or type(host.read_window) ~= "function"
+ or type(host.register_events) ~= "function"
+ or type(host.remove_event_registration) ~= "function"
+ or type(host.start_timer) ~= "function"
+ or type(host.stop_timer) ~= "function"
+ or type(host.supports_timers) ~= "function"
+ or type(host.supports_cursor_presentation) ~= "function"
+ or type(host.suppress_cursor_presentation) ~= "function"
+ or type(host.restore_cursor_presentation) ~= "function"
+ then
+ fail("FeedbackService host must provide highlight groups", 3)
+ end
+ return host
+end
+
+local function require_transitions(transitions, state)
+ transitions = transitions or state_transitions.new(state)
+ if type(transitions) ~= "table"
+ or type(transitions.AddTemporaryOverlay) ~= "function"
+ or type(transitions.RemoveTemporaryOverlay) ~= "function"
+ or type(transitions.AddTargetOverlay) ~= "function"
+ or type(transitions.ClearTargetOverlays) ~= "function"
+ or type(transitions.AddFinalizer) ~= "function"
+ or type(transitions.RemoveFinalizer) ~= "function"
+ or type(transitions.FullFinalization) ~= "function"
+ or type(transitions.ClearTargetFeedback) ~= "function"
+ or type(transitions.SetHighlightTimer) ~= "function"
+ or type(transitions.ClearHighlightTimer) ~= "function"
+ then
+ fail("FeedbackService transitions must manage overlay resources", 3)
+ end
+ return transitions
+end
+
+local function require_policy(service, host)
+ service = service or policy.new(host)
+ if type(service) ~= "table"
+ or type(service.evaluate_highlight_links) ~= "function"
+ or type(service.sample_acquisition) ~= "function"
+ or type(service.sample_markers) ~= "function"
+ or type(service.sample_timeouts) ~= "function"
+ or type(service.capture_activation) ~= "function"
+ then
+ fail("FeedbackService policy must evaluate highlight links", 3)
+ end
+ return service
+end
+
+function FeedbackService.new(options)
+ if FeedbackService.is(options) then
+ return options
+ end
+ options = normalize_options(options)
+ local service = setmetatable({}, FeedbackService)
+ local host = require_host(options.host)
+ local state = options.state or sequence_state.get()
+ if not sequence_state.is(state) then
+ fail("FeedbackService state must be the plugin-global SequenceState", 2)
+ end
+ service_records[service] = {
+ host = host,
+ policy = require_policy(options.policy or options.policy_service, host),
+ state = state,
+ transitions = require_transitions(
+ options.transitions or options.state_transitions,
+ state
+ ),
+ persistent_requests = {},
+ owned_finalizer = nil,
+ activation = nil,
+ eager_registration = nil,
+ last_eager_decision = nil,
+ }
+ return service
+end
+
+function FeedbackService.is(value)
+ return type(value) == "table" and service_records[value] ~= nil
+end
+
+local cursor_lease_metatable = {
+ __index = function(lease, key)
+ local method = CursorPresentationLease[key]
+ if method ~= nil then
+ return method
+ end
+ local record = cursor_lease_records[lease]
+ if key == "identity" then
+ return record.identity
+ end
+ if key == "active" then
+ return record.active
+ end
+ return nil
+ end,
+ __newindex = function()
+ fail("cursor presentation leases are read-only", 2)
+ end,
+ __metatable = "clever_tee.feedback_service.CursorPresentationLease",
+}
+
+local function new_cursor_presentation_lease(host, suppress)
+ local lease = setmetatable({}, cursor_lease_metatable)
+ local identity = suppress and host:suppress_cursor_presentation() or nil
+ cursor_lease_records[lease] = {
+ host = host,
+ identity = identity,
+ active = identity ~= nil,
+ }
+ return lease
+end
+
+function CursorPresentationLease.is(value)
+ return type(value) == "table" and cursor_lease_records[value] ~= nil
+end
+
+function CursorPresentationLease:release()
+ local record = cursor_lease_records[self]
+ if record == nil then
+ fail("cursor presentation lease is invalid", 2)
+ end
+ if not record.active then
+ return false
+ end
+ record.active = false
+ record.host:restore_cursor_presentation(record.identity)
+ return true
+end
+
+function FeedbackService:create_cursor_presentation_lease(enabled)
+ local record = service_records[self]
+ if enabled == nil then
+ enabled = record.policy:sample_acquisition().hide_cursor_on_cmdline
+ elseif type(enabled) ~= "boolean" then
+ fail("cursor presentation policy must be a Boolean", 2)
+ end
+ local supported = enabled and record.host:supports_cursor_presentation()
+ return new_cursor_presentation_lease(record.host, supported == true)
+end
+
+local function position_list(positions)
+ if type(positions) ~= "table" then
+ fail("direct marker positions must be a list", 3)
+ end
+ local result = {}
+ local item_count = 0
+ for key, position in pairs(positions) do
+ if type(key) ~= "number"
+ or key ~= math.floor(key)
+ or key < 1
+ or key > #positions
+ then
+ fail("direct marker positions must be a list", 3)
+ end
+ result[key] = domain.Position.coerce(position)
+ item_count = item_count + 1
+ end
+ if item_count ~= #positions then
+ fail("direct marker positions must be a list", 3)
+ end
+ return result
+end
+
+function FeedbackService:create_direct_markers(positions, window)
+ positions = position_list(positions)
+ if #positions == 0 then
+ return nil
+ end
+ if window == nil then
+ fail("direct marker window must identify its host window", 2)
+ end
+
+ local record = service_records[self]
+ local identity = record.host:create_highlight({
+ group = "CleverTeeDirect",
+ window = window,
+ positions = positions,
+ priority = M.overlay_priority("CleverTeeDirect"),
+ })
+ record.transitions:AddTemporaryOverlay(identity, window, "CleverTeeDirect")
+ local resource = {
+ identity = identity,
+ window = window,
+ group = "CleverTeeDirect",
+ positions = positions,
+ }
+ temporary_release_records[resource] = false
+ return resource
+end
+
+function FeedbackService:create_cursor_marker(position, window)
+ position = domain.Position.coerce(position)
+ if window == nil then
+ fail("cursor marker window must identify its host window", 2)
+ end
+
+ local record = service_records[self]
+ local identity = record.host:create_highlight({
+ group = "CleverTeeCursor",
+ window = window,
+ position = position,
+ priority = M.overlay_priority("CleverTeeCursor"),
+ })
+ record.transitions:AddTemporaryOverlay(identity, window, "CleverTeeCursor")
+ local resource = {
+ identity = identity,
+ window = window,
+ group = "CleverTeeCursor",
+ position = position,
+ }
+ temporary_release_records[resource] = false
+ return resource
+end
+
+function FeedbackService:remove_temporary_overlay(resource)
+ if type(resource) ~= "table" or resource.identity == nil then
+ fail("temporary overlay resource must identify its highlight", 2)
+ end
+ if temporary_release_records[resource] == true then
+ return false
+ end
+ temporary_release_records[resource] = true
+ local record = service_records[self]
+ local ok, removed = pcall(
+ record.host.remove_highlight,
+ record.host,
+ resource.identity
+ )
+ record.transitions:RemoveTemporaryOverlay(resource.identity, resource.window)
+ if not ok then
+ error(removed, 0)
+ end
+ return removed
+end
+
+function M.persistent_context_eligible(context)
+ context = domain.ModeContext.from_full_mode(context)
+ return context.key == "n"
+ or context.visual_kind ~= nil
+ or context.select_kind ~= nil
+ or LEGACY_NORMAL_EX_CONTEXTS[context.key] == true
+end
+
+function M.persistent_destination(
+ view,
+ target_position,
+ descriptor,
+ endpoint_policy
+)
+ if not text_topology.TextView.is(view) then
+ fail("persistent feedback requires a TextView", 2)
+ end
+ target_position = domain.Position.coerce(target_position)
+ descriptor = domain.Descriptor.from_string(descriptor)
+ endpoint_policy = domain.EndpointPolicy.from_string(endpoint_policy)
+
+ if endpoint_policy == domain.EndpointPolicy.VISUAL_EXCLUSIVE
+ and descriptor.direction == domain.Direction.FORWARD
+ then
+ if descriptor.family == domain.Family.FIND then
+ return view:successor(target_position)
+ end
+ return target_position
+ end
+ if descriptor.family == domain.Family.FIND then
+ return target_position
+ end
+ if descriptor.direction == domain.Direction.FORWARD then
+ return view:predecessor(target_position)
+ end
+ return view:successor(target_position)
+end
+
+function M.persistent_match_positions(
+ view,
+ match_start_line,
+ target_plan,
+ descriptor,
+ endpoint_policy
+)
+ if not text_topology.TextView.is(view) then
+ fail("persistent feedback requires a TextView", 2)
+ end
+ if not domain.TargetPlan.is(target_plan) then
+ fail("persistent feedback requires a TargetPlan", 2)
+ end
+ local positions = {}
+ local seen = {}
+ local candidates = view:iter_line_forward(match_start_line)
+ while true do
+ local position, character = candidates()
+ if position == nil then
+ break
+ end
+ if target_plan:matches(character, position, view) then
+ local destination = M.persistent_destination(
+ view,
+ position,
+ descriptor,
+ endpoint_policy
+ )
+ if destination ~= nil then
+ local key = tostring(destination.line)
+ .. ":"
+ .. tostring(destination.byte_column)
+ if not seen[key] then
+ seen[key] = true
+ positions[#positions + 1] = destination
+ end
+ end
+ end
+ end
+ return positions
+end
+
+function FeedbackService:build_persistent(specification)
+ if type(specification) ~= "table" then
+ fail("persistent feedback request must be a table", 2)
+ end
+ local context = domain.ModeContext.from_full_mode(specification.context)
+ if not M.persistent_context_eligible(context) then
+ fail("persistent feedback request requires an eligible context", 2)
+ end
+ if not domain.TargetPlan.is(specification.target_plan) then
+ fail("persistent feedback request requires a TargetPlan", 2)
+ end
+ if not domain.ResolvedMotionPlan.is(specification.motion_plan) then
+ fail("persistent feedback request requires a ResolvedMotionPlan", 2)
+ end
+ if specification.motion_plan.target_plan ~= specification.target_plan then
+ fail("persistent feedback must reuse the movement TargetPlan", 2)
+ end
+ local descriptor = domain.Descriptor.from_string(
+ specification.descriptor or specification.motion_plan.descriptor
+ )
+ local endpoint_policy = domain.EndpointPolicy.from_string(
+ specification.endpoint_policy or specification.motion_plan.endpoint_policy
+ )
+ local anchor = domain.Position.coerce(specification.anchor)
+ local view = specification.text_view
+ or text_topology.from_host(service_records[self].host)
+ return {
+ context = context,
+ anchor = anchor,
+ target_plan = specification.target_plan,
+ motion_plan = specification.motion_plan,
+ descriptor = descriptor,
+ endpoint_policy = endpoint_policy,
+ anchor_line = anchor.line,
+ match_start_line = anchor.line,
+ positions = M.persistent_match_positions(
+ view,
+ anchor.line,
+ specification.target_plan,
+ descriptor,
+ endpoint_policy
+ ),
+ text_view = view,
+ window = specification.window,
+ }
+end
+
+local function release_target_overlays(record, resources)
+ for _, resource in ipairs(resources) do
+ record.host:remove_highlight(resource.identity)
+ end
+end
+
+local function remove_target_overlays(record, window)
+ local resources = record.transitions:ClearTargetOverlays(window)
+ release_target_overlays(record, resources)
+ return resources
+end
+
+function FeedbackService:remove_character_overlays(window)
+ if window == nil then
+ fail("character overlay window must identify its host window", 2)
+ end
+ return remove_target_overlays(service_records[self], window)
+end
+
+function FeedbackService:cursor_moved_decision()
+ local record = service_records[self]
+ local context = record.state.last_input_context
+ local expected = context and record.state:get_previous_landing(context) or nil
+ local actual = record.host:read_cursor()
+ return {
+ context = context,
+ expected = expected,
+ actual = actual,
+ equal = expected ~= nil and domain.Position.equal(actual, expected),
+ }
+end
+
+local function release_finalizers(record, resources)
+ for _, resource in ipairs(resources) do
+ record.host:remove_event_registration(resource.identity)
+ end
+end
+
+local function release_highlight_timer(record, identity)
+ if identity == nil then
+ return false
+ end
+ return record.host:stop_timer(identity)
+end
+
+function FeedbackService:release_transition_cleanup(cleanup)
+ if type(cleanup) ~= "table" then
+ fail("feedback transition cleanup must be a table", 2)
+ end
+ local record = service_records[self]
+ release_finalizers(record, cleanup.finalizers or {})
+ release_highlight_timer(record, cleanup.highlight_timer)
+ release_target_overlays(record, cleanup.target_overlays or {})
+ if #(cleanup.finalizers or {}) > 0 then
+ record.owned_finalizer = nil
+ end
+ return cleanup
+end
+
+function FeedbackService:full_finalize(window)
+ local record = service_records[self]
+ window = window or record.host:read_window()
+ local cleanup = record.transitions:FullFinalization(window)
+ self:release_transition_cleanup(cleanup)
+ record.owned_finalizer = nil
+ return cleanup
+end
+
+function FeedbackService:handle_finalizer_event(name, payload)
+ if name == "CursorMoved" then
+ local decision = self:cursor_moved_decision()
+ if decision.equal then
+ decision.action = M.FinalizerAction.PRESERVE
+ else
+ decision.action = M.FinalizerAction.FINALIZE
+ decision.cleanup = self:full_finalize(payload and payload.window)
+ end
+ return decision
+ end
+ if DIRECT_FINALIZER_EVENTS[name] then
+ return {
+ action = M.FinalizerAction.FINALIZE,
+ cleanup = self:full_finalize(payload and payload.window),
+ }
+ end
+ return false
+end
+
+local function register_finalizers(service, record)
+ local buffer = record.host:read_buffer()
+ local owned = record.owned_finalizer
+ if owned ~= nil and owned.buffer == buffer then
+ return owned
+ end
+ if owned ~= nil then
+ record.host:remove_event_registration(owned.identity)
+ record.transitions:RemoveFinalizer(owned.identity, owned.buffer)
+ end
+
+ local identity = record.host:register_events(
+ M.FINALIZER_EVENTS,
+ function(name, payload)
+ service:handle_finalizer_event(name, payload)
+ end,
+ { buffer = buffer }
+ )
+ record.transitions:AddFinalizer(identity, buffer)
+ owned = {
+ identity = identity,
+ buffer = buffer,
+ }
+ record.owned_finalizer = owned
+ return owned
+end
+
+local function materialize_persistent(service, request)
+ if request.window == nil then
+ fail("persistent feedback window must identify its host window", 3)
+ end
+ local record = service_records[service]
+ remove_target_overlays(record, request.window)
+ request.identity = record.host:create_highlight({
+ group = "CleverTeeChar",
+ window = request.window,
+ positions = request.positions,
+ priority = M.overlay_priority("CleverTeeChar"),
+ target_plan = request.target_plan,
+ descriptor = request.descriptor,
+ endpoint_policy = request.endpoint_policy,
+ anchor_line = request.anchor_line,
+ match_start_line = request.match_start_line,
+ })
+ request.group = "CleverTeeChar"
+ request.priority = M.overlay_priority("CleverTeeChar")
+ local owned, ownership_error = pcall(function()
+ record.transitions:AddTargetOverlay(
+ request.identity,
+ request.window,
+ request.anchor.line
+ )
+ request.finalizers = register_finalizers(service, record)
+ end)
+ if not owned then
+ local resources = record.transitions:RemoveTargetOverlay(
+ request.identity,
+ request.window
+ )
+ if #resources == 0 then
+ resources[1] = {
+ identity = request.identity,
+ window = request.window,
+ }
+ end
+ pcall(release_target_overlays, record, resources)
+ error(ownership_error, 0)
+ end
+ local requests = record.persistent_requests
+ requests[#requests + 1] = request
+ return request
+end
+
+function FeedbackService:request_persistent(specification)
+ return materialize_persistent(self, self:build_persistent(specification))
+end
+
+function FeedbackService:restore_primary(specification)
+ local restoration = self:build_primary_restoration(specification)
+ if restoration == nil then
+ return nil
+ end
+ return materialize_persistent(self, restoration)
+end
+
+function FeedbackService:persistent_requests()
+ local result = {}
+ for index, request in ipairs(service_records[self].persistent_requests) do
+ result[index] = request
+ end
+ return result
+end
+
+function M.repeated_till_migration_candidate(request)
+ if type(request) ~= "table" then
+ fail("command feedback migration request must be a table", 2)
+ end
+ local plan = request.resolved_motion_plan or request.plan
+ return domain.ResolvedMotionPlan.is(plan)
+ and plan.descriptor.family == domain.Family.TILL
+ and request.first_move == false
+end
+
+function M.till_direction_changed(request)
+ if not M.repeated_till_migration_candidate(request) then
+ return false
+ end
+ if type(request.moved_forward) ~= "boolean"
+ or type(request.previous_moved_forward) ~= "boolean"
+ then
+ fail("TILL feedback migration requires movement directions", 2)
+ end
+ return request.moved_forward ~= request.previous_moved_forward
+end
+
+function M.command_migration_reason(request)
+ if type(request) ~= "table" then
+ fail("command feedback migration request must be a table", 2)
+ end
+ local origin = domain.Position.coerce(request.origin)
+ local destination = domain.Position.coerce(request.destination)
+ if request.outcome ~= nil and request.outcome.complete ~= true then
+ return nil
+ end
+ if origin.line ~= destination.line then
+ return M.MigrationReason.LINE_CHANGE
+ end
+ if M.till_direction_changed(request) then
+ return M.MigrationReason.TILL_DIRECTION_CHANGE
+ end
+ return nil
+end
+
+local function has_target_overlay(record, window)
+ for _, resource in ipairs(record.state.target_overlays) do
+ if resource.window == window then
+ return true
+ end
+ end
+ return false
+end
+
+function FeedbackService:primary_restoration_active(context, window)
+ local record = service_records[self]
+ context = domain.ModeContext.from_full_mode(context)
+ local mark_char = record.policy:sample_markers().mark_char
+ if not mark_char or not M.persistent_context_eligible(context) then
+ return false
+ end
+ window = window or record.host:read_window()
+ return not has_target_overlay(record, window)
+end
+
+function FeedbackService:build_primary_restoration(specification)
+ if type(specification) ~= "table" then
+ fail("primary feedback restoration must be a table", 2)
+ end
+ local window = specification.window
+ or service_records[self].host:read_window()
+ if not self:primary_restoration_active(specification.context, window) then
+ return nil
+ end
+ if not domain.TargetPlan.is(specification.target_plan) then
+ fail("primary feedback restoration requires an action TargetPlan", 2)
+ end
+ local action_motion_plan = specification.motion_plan
+ local search_scope = specification.search_scope
+ or (domain.ResolvedMotionPlan.is(action_motion_plan)
+ and action_motion_plan.search_scope)
+ or domain.SearchScope.BUFFER
+ local motion_plan = domain.ResolvedMotionPlan.new({
+ target_plan = specification.target_plan,
+ descriptor = specification.stored_descriptor,
+ search_scope = search_scope,
+ endpoint_policy = specification.endpoint_policy,
+ })
+ return self:build_persistent({
+ context = specification.context,
+ anchor = specification.anchor,
+ target_plan = specification.target_plan,
+ motion_plan = motion_plan,
+ descriptor = specification.stored_descriptor,
+ endpoint_policy = specification.endpoint_policy,
+ text_view = specification.text_view,
+ window = window,
+ })
+end
+
+function FeedbackService:migrate_command(request)
+ local reason = M.command_migration_reason(request)
+ local record = service_records[self]
+ local window = request.window or record.host:read_window()
+ if reason == nil or not has_target_overlay(record, window) then
+ return {
+ migrated = false,
+ reason = reason,
+ }
+ end
+
+ local plan = request.resolved_motion_plan or request.plan
+ if not domain.ResolvedMotionPlan.is(plan) then
+ fail("command feedback migration requires a ResolvedMotionPlan", 2)
+ end
+ local overlay = self:request_persistent({
+ context = request.context,
+ anchor = request.destination,
+ target_plan = plan.target_plan,
+ motion_plan = plan,
+ descriptor = plan.descriptor,
+ endpoint_policy = plan.endpoint_policy,
+ window = window,
+ })
+ return {
+ migrated = true,
+ reason = reason,
+ overlay = overlay,
+ }
+end
+
+function FeedbackService:highlight_timer_delay()
+ local record = service_records[self]
+ if not record.policy:sample_markers().mark_char then
+ return nil
+ end
+ local delay = record.policy:sample_timeouts().highlight_timeout_ms
+ if delay == 0 or record.host:supports_timers() ~= true then
+ return nil
+ end
+ return delay
+end
+
+function FeedbackService:cancel_highlight_timer()
+ local record = service_records[self]
+ local identity = record.transitions:ClearHighlightTimer()
+ release_highlight_timer(record, identity)
+ return identity
+end
+
+function FeedbackService:handle_highlight_timer(callback_identity, window)
+ if callback_identity == nil then
+ fail("highlight timer callback identity must be present", 2)
+ end
+ local record = service_records[self]
+ if callback_identity ~= record.state.highlight_timer then
+ return false
+ end
+ local _, current = record.transitions:ClearHighlightTimer(callback_identity)
+ if not current then
+ return false
+ end
+ self:remove_character_overlays(window or record.host:read_window())
+ return true
+end
+
+function FeedbackService:start_highlight_timer(window)
+ local record = service_records[self]
+ local delay = self:highlight_timer_delay()
+ if delay == nil then
+ return nil
+ end
+ window = window or record.host:read_window()
+ self:cancel_highlight_timer()
+ local identity
+ identity = record.host:start_timer(delay, function(callback_identity)
+ self:handle_highlight_timer(callback_identity or identity, window)
+ end)
+ record.transitions:SetHighlightTimer(identity)
+ return identity
+end
+
+function FeedbackService:refresh_primary(resolved_target, window)
+ if resolved_target == nil then
+ return nil
+ end
+ if not domain.TargetValue.is(resolved_target) then
+ fail("primary timer refresh requires a resolved TargetValue", 2)
+ end
+ return self:start_highlight_timer(window)
+end
+
+function FeedbackService:handle_eager_event(name, payload)
+ if not EAGER_EVENT_SET[name] then
+ return false
+ end
+ local record = service_records[self]
+ local decision = {
+ event = name,
+ payload = payload,
+ mark_char = record.policy:sample_markers().mark_char,
+ cleaned = false,
+ }
+ if decision.mark_char then
+ local window = payload and payload.window or record.host:read_window()
+ local cleanup = record.transitions:ClearTargetFeedback(window)
+ release_highlight_timer(record, cleanup.highlight_timer)
+ release_target_overlays(record, cleanup.target_overlays)
+ decision.cleaned = true
+ decision.cleanup = cleanup
+ end
+ record.last_eager_decision = decision
+ return decision
+end
+
+function FeedbackService:last_eager_decision()
+ local decision = service_records[self].last_eager_decision
+ return decision and copy(decision) or nil
+end
+
+function FeedbackService:activate()
+ local record = service_records[self]
+ if record.activation ~= nil then
+ return copy(record.activation)
+ end
+ local sampled = record.policy:capture_activation()
+ local activation = {
+ clean_labels_eagerly = sampled.clean_labels_eagerly,
+ eager_registration = nil,
+ }
+ if sampled.clean_labels_eagerly then
+ local identity = record.host:register_events(
+ M.EAGER_EVENTS,
+ function(name, payload)
+ self:handle_eager_event(name, payload)
+ end,
+ { owner = "clever_tee", lifecycle = "eager" }
+ )
+ record.eager_registration = identity
+ activation.eager_registration = identity
+ end
+ record.activation = activation
+ return copy(activation)
+end
+
+function FeedbackService:evaluate_feature_links()
+ local record = service_records[self]
+ local rules = record.policy:evaluate_highlight_links()
+ local results = {}
+ for _, group in ipairs(FEATURE_GROUPS) do
+ local rule = rules[group]
+ if rule.enabled then
+ if rule.configured_target ~= nil then
+ record.host:define_highlight_group(
+ group,
+ { link = rule.configured_target },
+ { force = true }
+ )
+ results[group] = {
+ group = group,
+ target = rule.configured_target,
+ source = "configured",
+ applied = true,
+ }
+ else
+ local existing = record.host:read_highlight_group(group)
+ if existing ~= nil then
+ results[group] = {
+ group = group,
+ definition = existing,
+ source = "colorscheme",
+ applied = false,
+ }
+ else
+ record.host:define_highlight_group(
+ group,
+ { link = rule.target },
+ { default = true }
+ )
+ results[group] = {
+ group = group,
+ target = rule.target,
+ source = "fallback",
+ applied = true,
+ }
+ end
+ end
+ end
+ end
+ return results
+end
+
+function FeedbackService:ensure_default_label()
+ local existing = service_records[self].host:read_highlight_group(
+ M.DEFAULT_LABEL_GROUP
+ )
+ if existing ~= nil then
+ return {
+ group = M.DEFAULT_LABEL_GROUP,
+ definition = existing,
+ source = "colorscheme",
+ applied = false,
+ }
+ end
+
+ local definition = M.default_label_definition()
+ service_records[self].host:define_highlight_group(
+ M.DEFAULT_LABEL_GROUP,
+ definition,
+ { default = true }
+ )
+ return {
+ group = M.DEFAULT_LABEL_GROUP,
+ definition = definition,
+ source = "fallback",
+ applied = true,
+ }
+end
+
+function FeedbackService:evaluate_highlights()
+ return {
+ default_label = self:ensure_default_label(),
+ feature_links = self:evaluate_feature_links(),
+ }
+end
+
+function M.new(options)
+ return FeedbackService.new(options)
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return FeedbackService.new(options)
+ end,
+})
+
+return M
diff --git a/lua/clever_tee/host_adapter.lua b/lua/clever_tee/host_adapter.lua
new file mode 100644
index 0000000..c746f60
--- /dev/null
+++ b/lua/clever_tee/host_adapter.lua
@@ -0,0 +1,1165 @@
+local capabilities = require("clever_tee.capabilities")
+local domain = require("clever_tee.domain")
+
+local M = {}
+local unpack_values = table.unpack or unpack
+local HostAdapter = {}
+HostAdapter.__index = HostAdapter
+M.HostAdapter = HostAdapter
+
+M.ActionEffect = {
+ NONE = "none",
+ ESCAPE = "escape",
+ ERROR = "error",
+}
+M.CONFIGURATION_PREFIX = "clever_tee_"
+M.CONFIGURATION_GLOBALS = {
+ suppress_default_mappings = "clever_tee_not_overwrites_standard_mappings",
+}
+
+local BOOLEAN_CONFIGURATION = {
+ search_current_line_only = true,
+ ignore_case = true,
+ smart_case = true,
+ use_migemo = true,
+ fix_key_direction = true,
+ show_prompt = true,
+ mark_cursor = true,
+ hide_cursor_on_cmdline = true,
+ mark_char = true,
+ mark_direct = true,
+ clean_labels_eagerly = true,
+}
+
+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" then
+ if options.runtime ~= nil then
+ return options.runtime
+ end
+ if options.api ~= nil then
+ return options
+ end
+ return rawget(_G, "vim")
+ 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)
+ local record = {
+ runtime = require_runtime(current_runtime(options)),
+ next_identity = 1,
+ highlights = {},
+ timers = {},
+ cursor_presentations = {},
+ events = {},
+ actions = {},
+ mappings = {},
+ dot_repeat = nil,
+ dot_bridge = nil,
+ event_order = {},
+ action_diagnostics = nil,
+ augroup = nil,
+ }
+ record.event_queue = capabilities.EventQueue.new(function(_, delivery)
+ delivery.callback(delivery.name, delivery.payload)
+ end)
+ adapter_records[adapter] = record
+ 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
+
+function HostAdapter:read_text()
+ local lines = self:runtime().api.nvim_buf_get_lines(0, 0, -1, true)
+ if #lines == 0 then
+ lines = { "" }
+ end
+ return domain.TextSnapshot.new(lines)
+end
+
+function HostAdapter:read_buffer()
+ return self:runtime().api.nvim_get_current_buf()
+end
+
+function HostAdapter:read_window()
+ return self:runtime().api.nvim_get_current_win()
+end
+
+function HostAdapter:read_cursor()
+ local position = self:runtime().api.nvim_win_get_cursor(0)
+ return domain.Position.new(position[1], position[2] + 1)
+end
+
+function HostAdapter:read_mode()
+ return self:runtime().api.nvim_get_mode().mode
+end
+
+local function selection_option(runtime)
+ local value = runtime.api.nvim_get_option_value(
+ "selection",
+ { scope = "global" }
+ )
+ if value == "exclusive" then
+ return domain.SelectionOption.EXCLUSIVE
+ end
+ return domain.SelectionOption.INCLUSIVE
+end
+
+function HostAdapter:read_selection()
+ local runtime = self:runtime()
+ local context = domain.ModeContext.from_full_mode(self:read_mode())
+ local kind = context.visual_kind or context.select_kind
+ local option = selection_option(runtime)
+ if kind == nil then
+ return domain.Selection.inactive(option)
+ end
+ local raw_anchor = runtime.fn.getpos("v")
+ local focus = self:read_cursor()
+ local anchor
+ if type(raw_anchor) == "table"
+ and type(raw_anchor[2]) == "number"
+ and raw_anchor[2] > 0
+ and type(raw_anchor[3]) == "number"
+ and raw_anchor[3] > 0
+ then
+ anchor = domain.Position.new(raw_anchor[2], raw_anchor[3])
+ else
+ anchor = focus
+ end
+ return domain.Selection.active(kind, anchor, focus, option)
+end
+
+function HostAdapter:read_count()
+ local runtime = self:runtime()
+ local count = runtime.v.count1
+ if type(count) ~= "number" or count < 1 then
+ count = 1
+ end
+ return domain.Count.new(count)
+end
+
+local function configuration_global(name)
+ if type(name) ~= "string" or name == "" then
+ fail("configuration name must be a nonempty string", 3)
+ end
+ return M.CONFIGURATION_GLOBALS[name] or M.CONFIGURATION_PREFIX .. name
+end
+
+function M.configuration_global(name)
+ return configuration_global(name)
+end
+
+function HostAdapter:configuration_present(name)
+ local global = configuration_global(name)
+ return self:runtime().fn.exists("g:" .. global) == 1
+end
+
+local function normalize_configuration(name, value)
+ if BOOLEAN_CONFIGURATION[name] and type(value) == "number" then
+ return value ~= 0
+ end
+ return value
+end
+
+function HostAdapter:read_configuration(name)
+ local global = configuration_global(name)
+ return normalize_configuration(name, self:runtime().g[global])
+end
+
+function HostAdapter:write_configuration(name, value)
+ local global = configuration_global(name)
+ self:runtime().g[global] = value
+end
+
+function HostAdapter:read_encoding()
+ local runtime = self:runtime()
+ return runtime.api.nvim_get_option_value(
+ "encoding",
+ { scope = "global" }
+ )
+end
+
+function HostAdapter:lowercase(value)
+ if type(value) ~= "string" then
+ fail("case conversion value must be a string", 2)
+ end
+ local result = self:runtime().fn.tolower(value)
+ if type(result) ~= "string" then
+ fail("Nvim case conversion must return a string", 2)
+ end
+ return result
+end
+
+function HostAdapter:read_macro_state()
+ local register = self:runtime().fn.reg_executing()
+ return domain.MacroState.new(register ~= "" and register or nil)
+end
+
+local function fold_open_policies(value)
+ local result = {}
+ for item in tostring(value):gmatch("[^,]+") do
+ if item == "hor" then
+ item = "horizontal"
+ end
+ result[#result + 1] = item
+ end
+ return result
+end
+
+function HostAdapter:read_fold_state()
+ local runtime = self:runtime()
+ local foldopen = runtime.api.nvim_get_option_value(
+ "foldopen",
+ { scope = "global" }
+ )
+ local line = self:read_cursor().line
+ local closed_levels = runtime.fn.foldclosed(line) == -1 and 0 or 1
+ return domain.FoldState.new(
+ fold_open_policies(foldopen),
+ closed_levels
+ )
+end
+
+function HostAdapter:read_time_ms()
+ local runtime = self:runtime()
+ local uv = runtime.uv or runtime.loop
+ if type(uv) ~= "table" or type(uv.hrtime) ~= "function" then
+ fail("HostAdapter runtime must provide a monotonic clock", 2)
+ end
+ return uv.hrtime() / 1000000
+end
+
+function HostAdapter:read_pending_operator()
+ local operator = self:runtime().v.operator
+ if operator == nil then
+ return ""
+ end
+ return operator
+end
+
+function HostAdapter:apply_cursor(position)
+ position = domain.Position.coerce(position)
+ self:runtime().api.nvim_win_set_cursor(
+ 0,
+ { position.line, position.byte_column - 1 }
+ )
+end
+
+function HostAdapter:apply_selection(position)
+ if domain.Selection.is(position) then
+ if not position.active then
+ fail("selection movement requires an active selection", 2)
+ end
+ position = position.focus
+ end
+ return self:apply_cursor(position)
+end
+
+function HostAdapter:set_operator_inclusive(enabled)
+ if type(enabled) ~= "boolean" then
+ fail("operator inclusivity must be a Boolean", 2)
+ end
+ if not enabled then
+ return
+ end
+
+ local runtime = self:runtime()
+ local api = runtime.api
+ local selection
+ if type(api.nvim_get_option_value) == "function" then
+ selection = api.nvim_get_option_value("selection", { scope = "global" })
+ end
+ if selection ~= "exclusive" then
+ api.nvim_cmd({
+ cmd = "normal",
+ bang = true,
+ args = { "v" },
+ }, {})
+ return
+ end
+ if type(api.nvim_set_option_value) ~= "function"
+ or type(runtime.schedule) ~= "function"
+ then
+ fail("HostAdapter cannot preserve exclusive selection during an operator", 2)
+ end
+
+ api.nvim_set_option_value("selection", "inclusive", { scope = "global" })
+ local ok, command_error = pcall(api.nvim_cmd, {
+ cmd = "normal",
+ bang = true,
+ args = { "v" },
+ }, {})
+ if not ok then
+ api.nvim_set_option_value("selection", selection, { scope = "global" })
+ error(command_error, 0)
+ end
+ runtime.schedule(function()
+ api.nvim_set_option_value("selection", selection, { scope = "global" })
+ end)
+end
+
+local function string_bytes(value)
+ local bytes = {}
+ for index = 1, #value do
+ bytes[index] = string.byte(value, index)
+ end
+ return bytes
+end
+
+function HostAdapter:read_input()
+ local runtime = self:runtime()
+ local value = runtime.fn.getcharstr()
+ if type(value) ~= "string" or value == "" then
+ fail("Nvim target input must be a nonempty string", 2)
+ end
+ local bytes = string_bytes(value)
+ if #bytes == 3
+ and bytes[1] == 0x80
+ and bytes[2] == 0xfd
+ and bytes[3] == 0x60
+ then
+ return domain.InputPacket.raw_bytes(bytes)
+ end
+ if #bytes == 1 and bytes[1] == 27 then
+ return domain.InputPacket.special_key("Escape", bytes)
+ end
+ if bytes[1] == 0x80 then
+ local name = type(runtime.fn.keytrans) == "function"
+ and runtime.fn.keytrans(value)
+ or "Special"
+ return domain.InputPacket.special_key(name, value)
+ end
+ return domain.InputPacket.text(value)
+end
+
+function HostAdapter:open_fold(position)
+ position = position and domain.Position.coerce(position) or self:read_cursor()
+ local runtime = self:runtime()
+ if runtime.fn.foldclosed(position.line) == -1 then
+ return false
+ end
+ runtime.api.nvim_cmd({
+ cmd = "normal",
+ bang = true,
+ args = { "zo" },
+ }, {})
+ return true
+end
+
+function HostAdapter:show_prompt(text)
+ if type(text) ~= "string" then
+ fail("prompt must be a string", 2)
+ end
+ self:runtime().api.nvim_echo({ { text } }, false, {})
+end
+
+function HostAdapter:redraw(kind)
+ if kind == "suppressed" then
+ return false
+ end
+ if kind ~= "screen" and kind ~= "full" then
+ fail("redraw kind must be screen, full, or suppressed", 2)
+ end
+ self:runtime().api.nvim_cmd({
+ cmd = "redraw",
+ bang = kind == "full",
+ }, {})
+ return true
+end
+
+local DIAGNOSTIC_LEVELS = {
+ error = "ERROR",
+ warning = "WARN",
+ info = "INFO",
+}
+
+function HostAdapter:emit_diagnostic(level, text)
+ local level_name = DIAGNOSTIC_LEVELS[level]
+ if level_name == nil then
+ fail("diagnostic level must be error, warning, or info", 2)
+ end
+ if type(text) ~= "string" or text == "" then
+ fail("diagnostic text must be a nonempty string", 2)
+ end
+ local record = adapter_records[self]
+ local runtime = record.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 {}
+ runtime.notify(text, levels[level_name], { title = "clever-tee" })
+ if record.action_diagnostics ~= nil then
+ record.action_diagnostics[level .. "\0" .. text] = true
+ end
+end
+
+local install_dot_bridge
+
+function HostAdapter:register_dot_repeat(payload, callback)
+ if not domain.DotPayload.is(payload) then
+ fail("dot-repeat payload must be a DotPayload", 2)
+ end
+ if callback ~= nil and type(callback) ~= "function" then
+ fail("dot-repeat callback must be a function", 2)
+ end
+ local record = adapter_records[self]
+ record.dot_repeat = {
+ payload = payload,
+ callback = callback,
+ operator = self:read_pending_operator(),
+ }
+ if install_dot_bridge ~= nil then
+ install_dot_bridge(self)
+ end
+ if record.dot_bridge ~= nil then
+ record.dot_bridge.awaiting_change = true
+ end
+ return payload
+end
+
+function HostAdapter:dot_repeat_payload()
+ local registration = adapter_records[self].dot_repeat
+ return registration and registration.payload or nil
+end
+
+function HostAdapter:replay_dot(count)
+ local registration = adapter_records[self].dot_repeat
+ if registration == nil or registration.callback == nil then
+ fail("dot repeat is not executable", 2)
+ end
+ return registration.callback(
+ registration.payload,
+ domain.Count.new(count)
+ )
+end
+
+local function next_identity(adapter, prefix)
+ local record = adapter_records[adapter]
+ local identity = prefix .. "-" .. tostring(record.next_identity)
+ record.next_identity = record.next_identity + 1
+ return identity
+end
+
+local function highlight_exists(runtime, name)
+ if type(runtime.fn) == "table" and type(runtime.fn.hlexists) == "function" then
+ return runtime.fn.hlexists(name) == 1
+ end
+ local definition = runtime.api.nvim_get_hl(0, {
+ name = name,
+ link = true,
+ create = false,
+ })
+ return next(definition) ~= nil
+end
+
+function HostAdapter:read_highlight_group(name)
+ if type(name) ~= "string" or name == "" then
+ fail("highlight group name must be a nonempty string", 2)
+ end
+ local runtime = self:runtime()
+ if not highlight_exists(runtime, name) then
+ return nil
+ end
+ return runtime.api.nvim_get_hl(0, {
+ name = name,
+ link = true,
+ create = false,
+ })
+end
+
+local function native_highlight_definition(definition, options)
+ if type(definition) ~= "table" then
+ fail("highlight group definition must be a table", 3)
+ end
+ options = options or {}
+ if type(options) ~= "table" then
+ fail("highlight group options must be a table", 3)
+ end
+ local native = {}
+ for key, value in pairs(definition) do
+ if key ~= "guifg" and key ~= "guibg" and key ~= "gui" then
+ native[key] = value
+ end
+ end
+ if definition.guifg ~= nil then
+ native.fg = definition.guifg
+ end
+ if definition.guibg ~= nil then
+ native.bg = definition.guibg
+ end
+ for key, value in pairs(definition.gui or {}) do
+ native[key] = value
+ end
+ if options.default ~= nil then
+ native.default = options.default
+ end
+ if options.force ~= nil then
+ native.force = options.force
+ end
+ return native
+end
+
+function HostAdapter:define_highlight_group(name, definition, options)
+ if type(name) ~= "string" or name == "" then
+ fail("highlight group name must be a nonempty string", 2)
+ end
+ options = options or {}
+ local runtime = self:runtime()
+ if options.default and highlight_exists(runtime, name) then
+ return false
+ end
+ runtime.api.nvim_set_hl(
+ 0,
+ name,
+ native_highlight_definition(definition, options)
+ )
+ return true
+end
+
+local function overlay_positions(specification)
+ local positions = specification.positions
+ if positions == nil and specification.position ~= nil then
+ positions = { specification.position }
+ end
+ if type(positions) ~= "table" then
+ fail("highlight positions must be a list", 3)
+ end
+ local native = {}
+ for index, position in ipairs(positions) do
+ position = domain.Position.coerce(position)
+ native[index] = { position.line, position.byte_column }
+ end
+ if #native == 0 then
+ native[1] = { 0 }
+ end
+ return native
+end
+
+local function overlay_priority(value)
+ if value == "high" then
+ return 100
+ end
+ if value == "ordinary" or value == nil then
+ return 10
+ end
+ if type(value) == "number" then
+ return value
+ end
+ fail("highlight priority must be high, ordinary, or numeric", 3)
+end
+
+function HostAdapter:create_highlight(specification)
+ if type(specification) ~= "table" then
+ fail("highlight specification must be a table", 2)
+ end
+ if type(specification.group) ~= "string" or specification.group == "" then
+ fail("highlight group must be a nonempty string", 2)
+ end
+ if specification.window == nil then
+ fail("highlight window must identify its Nvim window", 2)
+ end
+ local record = adapter_records[self]
+ local identity = specification.identity or next_identity(self, "highlight")
+ if record.highlights[identity] ~= nil then
+ fail("highlight identity is already active", 2)
+ end
+ local match_id = record.runtime.fn.matchaddpos(
+ specification.group,
+ overlay_positions(specification),
+ overlay_priority(specification.priority),
+ -1,
+ { window = specification.window }
+ )
+ if type(match_id) ~= "number" or match_id < 0 then
+ fail("Nvim could not create the window-local highlight", 2)
+ end
+ record.highlights[identity] = {
+ match_id = match_id,
+ window = specification.window,
+ }
+ return identity
+end
+
+function HostAdapter:remove_highlight(identity)
+ local record = adapter_records[self]
+ local resource = record.highlights[identity]
+ if resource == nil then
+ return false
+ end
+ record.highlights[identity] = nil
+ record.runtime.fn.matchdelete(resource.match_id, resource.window)
+ return true
+end
+
+local function nonnegative_integer(value, name)
+ if type(value) ~= "number"
+ or value < 0
+ or value ~= math.floor(value)
+ or value == math.huge
+ then
+ fail((name or "value") .. " must be a nonnegative integer", 3)
+ end
+ return value
+end
+
+function HostAdapter:supports_timers()
+ local fn = self:runtime().fn
+ return type(fn) == "table"
+ and type(fn.timer_start) == "function"
+ and type(fn.timer_stop) == "function"
+end
+
+function HostAdapter:start_timer(delay_ms, callback)
+ nonnegative_integer(delay_ms, "timer delay")
+ if type(callback) ~= "function" then
+ fail("timer callback must be a function", 2)
+ end
+ if not self:supports_timers() then
+ return nil
+ end
+ local record = adapter_records[self]
+ local identity = next_identity(self, "timer")
+ local timer_id = record.runtime.fn.timer_start(delay_ms, function()
+ local resource = record.timers[identity]
+ if resource == nil or not resource.active then
+ return
+ end
+ record.timers[identity] = nil
+ callback(identity)
+ end)
+ if type(timer_id) ~= "number" or timer_id < 0 then
+ fail("Nvim could not start the timer", 2)
+ end
+ record.timers[identity] = {
+ timer_id = timer_id,
+ active = true,
+ }
+ return identity
+end
+
+function HostAdapter:stop_timer(identity)
+ local record = adapter_records[self]
+ local resource = record.timers[identity]
+ if resource == nil or not resource.active then
+ return false
+ end
+ record.timers[identity] = nil
+ record.runtime.fn.timer_stop(resource.timer_id)
+ return true
+end
+
+local function event_names(value)
+ if type(value) == "string" then
+ value = { value }
+ end
+ if type(value) ~= "table" or #value == 0 then
+ fail("event names must be a nonempty list", 3)
+ end
+ local names = {}
+ local set = {}
+ for index, name in ipairs(value) do
+ if type(name) ~= "string" or name == "" then
+ fail("event name must be a nonempty string", 3)
+ end
+ if not set[name] then
+ names[#names + 1] = name
+ set[name] = true
+ end
+ end
+ return names, set
+end
+
+local function event_payload(adapter, event)
+ local payload = {
+ buffer = event.buf,
+ file = event.file,
+ match = event.match,
+ data = event.data,
+ }
+ local api = adapter:runtime().api
+ if type(api.nvim_get_current_win) == "function" then
+ payload.window = api.nvim_get_current_win()
+ end
+ return payload
+end
+
+local function event_augroup(record)
+ if record.augroup == nil then
+ record.augroup = record.runtime.api.nvim_create_augroup(
+ "clever_tee",
+ { clear = true }
+ )
+ end
+ return record.augroup
+end
+
+local function queue_event(record, name, payload, callback)
+ return record.event_queue:emit(name, {
+ name = name,
+ payload = payload,
+ callback = callback,
+ })
+end
+
+function HostAdapter:register_events(names, callback, options)
+ local name_set
+ names, name_set = event_names(names)
+ if type(callback) ~= "function" then
+ fail("event callback must be a function", 2)
+ end
+ options = options or {}
+ if type(options) ~= "table" then
+ fail("event registration options must be a table", 2)
+ end
+ local record = adapter_records[self]
+ local identity = next_identity(self, "event-registration")
+ local autocmd_options = {
+ group = event_augroup(record),
+ desc = "clever-tee " .. table.concat(names, "/"),
+ callback = function(event)
+ local resource = record.events[identity]
+ if resource ~= nil and resource.active then
+ queue_event(
+ record,
+ event.event,
+ event_payload(self, event),
+ resource.callback
+ )
+ end
+ end,
+ }
+ if options.buffer ~= nil then
+ autocmd_options.buffer = options.buffer
+ end
+ local autocmd_id = record.runtime.api.nvim_create_autocmd(
+ names,
+ autocmd_options
+ )
+ record.events[identity] = {
+ autocmd_id = autocmd_id,
+ names = names,
+ name_set = name_set,
+ callback = callback,
+ buffer = options.buffer,
+ active = true,
+ }
+ record.event_order[#record.event_order + 1] = identity
+ return identity
+end
+
+function HostAdapter:remove_event_registration(identity)
+ local record = adapter_records[self]
+ local resource = record.events[identity]
+ if resource == nil or not resource.active then
+ return false
+ end
+ resource.active = false
+ record.runtime.api.nvim_del_autocmd(resource.autocmd_id)
+ return true
+end
+
+local DOT_MOTION_MAPPING = "<Plug>(clever-tee-dot-motion)"
+
+local function dot_bridge_supported(runtime)
+ return type(runtime.keymap) == "table"
+ and type(runtime.keymap.set) == "function"
+ and type(runtime.keymap.del) == "function"
+ and type(runtime.fn.maparg) == "function"
+ and type(runtime.fn.mapset) == "function"
+ and type(runtime.api.nvim_feedkeys) == "function"
+end
+
+local function restore_dot_mapping(record)
+ local bridge = record.dot_bridge
+ if bridge == nil or not bridge.active then
+ return false
+ end
+ bridge.active = false
+ pcall(record.runtime.keymap.del, "n", ".")
+ if type(bridge.previous_mapping) == "table"
+ and next(bridge.previous_mapping) ~= nil
+ then
+ record.runtime.fn.mapset("n", false, bridge.previous_mapping)
+ end
+ return true
+end
+
+local function dot_replay_keys(runtime, count, operator)
+ local prefix = count > 0 and tostring(count) or ""
+ local keys = prefix .. operator .. DOT_MOTION_MAPPING
+ if type(runtime.keycode) == "function" then
+ return runtime.keycode(keys)
+ end
+ return runtime.api.nvim_replace_termcodes(keys, true, false, true)
+end
+
+install_dot_bridge = function(adapter)
+ local record = adapter_records[adapter]
+ local runtime = record.runtime
+ if not dot_bridge_supported(runtime) then
+ return nil
+ end
+ local bridge = record.dot_bridge
+ if bridge == nil then
+ bridge = {
+ active = false,
+ awaiting_change = false,
+ previous_mapping = nil,
+ }
+ record.dot_bridge = bridge
+ runtime.keymap.set("o", DOT_MOTION_MAPPING, function()
+ local registration = record.dot_repeat
+ if registration == nil or registration.callback == nil then
+ return
+ end
+ local outcome = registration.callback(
+ registration.payload,
+ adapter:read_count()
+ )
+ if domain.ActionOutcome.is(outcome) then
+ adapter:translate_action_outcome(outcome)
+ end
+ end, {
+ silent = true,
+ remap = false,
+ desc = "clever-tee dot motion",
+ })
+ local has_cmd_atom = type(runtime.fn.exists) == "function"
+ and runtime.fn.exists("##CmdAtom") == 1
+ local ownership_events = has_cmd_atom
+ and "CmdAtom"
+ or { "TextChanged", "TextChangedI", "TextChangedP" }
+ runtime.api.nvim_create_autocmd(ownership_events, {
+ group = event_augroup(record),
+ desc = "clever-tee dot ownership",
+ callback = function(event)
+ if has_cmd_atom and not (event.data and event.data.changed) then
+ return
+ end
+ if bridge.awaiting_change then
+ bridge.awaiting_change = false
+ return
+ end
+ restore_dot_mapping(record)
+ end,
+ })
+ end
+ if not bridge.active then
+ bridge.previous_mapping = runtime.fn.maparg(".", "n", false, true)
+ runtime.keymap.set("n", ".", function()
+ local registration = record.dot_repeat
+ if registration == nil or registration.operator == "" then
+ restore_dot_mapping(record)
+ runtime.api.nvim_feedkeys(".", "n", false)
+ return
+ end
+ bridge.awaiting_change = true
+ local count = runtime.v.count or 0
+ runtime.api.nvim_feedkeys(
+ dot_replay_keys(runtime, count, registration.operator),
+ "n",
+ false
+ )
+ end, {
+ silent = true,
+ remap = false,
+ desc = "clever-tee dot repeat",
+ })
+ bridge.active = true
+ end
+ return bridge
+end
+
+function HostAdapter:deliver_event(name, payload)
+ if type(name) ~= "string" or name == "" then
+ fail("event name must be a nonempty string", 2)
+ end
+ payload = payload or {}
+ local record = adapter_records[self]
+ local event_buffer = payload.buffer
+ for _, identity in ipairs(record.event_order) do
+ local resource = record.events[identity]
+ if resource.active
+ and resource.name_set[name]
+ and (resource.buffer == nil
+ or event_buffer == nil
+ or resource.buffer == event_buffer)
+ then
+ queue_event(record, name, payload, resource.callback)
+ end
+ end
+end
+
+function HostAdapter:begin_action_transition()
+ local record = adapter_records[self]
+ record.action_diagnostics = {}
+ return record.event_queue:begin_transition()
+end
+
+function HostAdapter:commit_action_transition(token)
+ local record = adapter_records[self]
+ local result = record.event_queue:commit_transition(token)
+ record.action_diagnostics = nil
+ return result
+end
+
+local function terminal_cursor_option(runtime)
+ return runtime.fn.eval("&t_ve")
+end
+
+local function set_terminal_cursor_option(runtime, value)
+ runtime.api.nvim_cmd({
+ cmd = "let",
+ args = { "&t_ve", "=", runtime.fn.string(value) },
+ }, {})
+end
+
+function HostAdapter:supports_cursor_presentation()
+ local runtime = self:runtime()
+ return type(runtime.api.nvim_get_option_value) == "function"
+ and type(runtime.api.nvim_set_option_value) == "function"
+ and type(runtime.api.nvim_cmd) == "function"
+ and type(runtime.fn) == "table"
+ and type(runtime.fn.exists) == "function"
+ and runtime.fn.exists("+t_ve") == 1
+ and type(runtime.fn.eval) == "function"
+ and type(runtime.fn.string) == "function"
+end
+
+function HostAdapter:suppress_cursor_presentation()
+ if not self:supports_cursor_presentation() then
+ return nil
+ end
+ local record = adapter_records[self]
+ local runtime = record.runtime
+ local identity = next_identity(self, "cursor-presentation")
+ local saved = {
+ guicursor = runtime.api.nvim_get_option_value(
+ "guicursor",
+ { scope = "global" }
+ ),
+ terminal_cursor = terminal_cursor_option(runtime),
+ }
+ runtime.api.nvim_set_option_value(
+ "guicursor",
+ "a:ver1",
+ { scope = "global" }
+ )
+ local ok, failure = pcall(set_terminal_cursor_option, runtime, "")
+ if not ok then
+ runtime.api.nvim_set_option_value(
+ "guicursor",
+ saved.guicursor,
+ { scope = "global" }
+ )
+ error(failure, 0)
+ end
+ record.cursor_presentations[identity] = saved
+ return identity
+end
+
+function HostAdapter:restore_cursor_presentation(identity)
+ local record = adapter_records[self]
+ local saved = record.cursor_presentations[identity]
+ if saved == nil then
+ return false
+ end
+ record.cursor_presentations[identity] = nil
+ record.runtime.api.nvim_set_option_value(
+ "guicursor",
+ saved.guicursor,
+ { scope = "global" }
+ )
+ set_terminal_cursor_option(record.runtime, saved.terminal_cursor)
+ return true
+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)
+ return self:emit_diagnostic("error", text)
+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
+ local diagnostics = adapter_records[self].action_diagnostics
+ local key = "error\0" .. outcome.diagnostic
+ if diagnostics == nil or not diagnostics[key] then
+ self:emit_action_error(outcome.diagnostic)
+ end
+ return M.ActionEffect.ERROR
+ end
+ return M.ActionEffect.NONE
+end
+
+local function packed(...)
+ return { n = select("#", ...), ... }
+end
+
+local function invoke_callback(adapter, callback, ...)
+ local arguments = packed(...)
+ local token = adapter:begin_action_transition()
+ local results = packed(pcall(function()
+ local values = packed(callback(unpack_values(arguments, 1, arguments.n)))
+ if domain.ActionOutcome.is(values[1]) then
+ adapter:translate_action_outcome(values[1])
+ end
+ return unpack_values(values, 1, values.n)
+ end))
+ local commit = packed(pcall(adapter.commit_action_transition, adapter, token))
+ if not results[1] then
+ error(results[2], 0)
+ end
+ if not commit[1] then
+ error(commit[2], 0)
+ end
+ return unpack_values(results, 2, results.n)
+end
+
+function HostAdapter:register_action(name, callback)
+ if type(name) ~= "string" or name == "" then
+ fail("action name must be a nonempty string", 2)
+ end
+ if type(callback) ~= "function" then
+ fail("action callback must be a function", 2)
+ end
+ local actions = adapter_records[self].actions
+ if actions[name] ~= nil then
+ fail("action is already registered", 2)
+ end
+ actions[name] = callback
+ return name
+end
+
+function HostAdapter:invoke_action(name, ...)
+ local callback = adapter_records[self].actions[name]
+ if callback == nil then
+ fail("action is not registered", 2)
+ end
+ return invoke_callback(self, callback, ...)
+end
+
+function HostAdapter:invoke_callback(callback, ...)
+ if type(callback) ~= "function" then
+ fail("action callback must be a function", 2)
+ end
+ return invoke_callback(self, callback, ...)
+end
+
+local function mapping_modes(value)
+ if type(value) == "string" then
+ value = { value }
+ end
+ if type(value) ~= "table" or #value == 0 then
+ fail("mapping modes must be a nonempty list", 3)
+ end
+ local result = {}
+ for index, mode in ipairs(value) do
+ if type(mode) ~= "string" or mode == "" then
+ fail("mapping mode must be a nonempty string", 3)
+ end
+ result[index] = mode
+ end
+ return result
+end
+
+function HostAdapter:register_mapping(modes, lhs, action, options)
+ modes = mapping_modes(modes)
+ if type(lhs) ~= "string" or lhs == "" then
+ fail("mapping lhs must be a nonempty string", 2)
+ end
+ if type(action) ~= "string" and type(action) ~= "function" then
+ fail("mapping action must be an action name or function", 2)
+ end
+ options = options or {}
+ if type(options) ~= "table" then
+ fail("mapping options must be a table", 2)
+ end
+ local callback
+ if type(action) == "string" then
+ callback = function()
+ return self:invoke_action(action)
+ end
+ else
+ callback = function(...)
+ return invoke_callback(self, action, ...)
+ end
+ end
+ local native_options = {
+ silent = options.silent == true,
+ remap = options.remap == true,
+ desc = options.desc
+ or ("clever-tee " .. (type(action) == "string" and action or lhs)),
+ }
+ self:runtime().keymap.set(modes, lhs, callback, native_options)
+ local identity = next_identity(self, "mapping")
+ adapter_records[self].mappings[identity] = {
+ modes = modes,
+ lhs = lhs,
+ action = action,
+ options = options,
+ callback = callback,
+ }
+ return identity
+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/lua/clever_tee/init.lua b/lua/clever_tee/init.lua
new file mode 100644
index 0000000..85abc86
--- /dev/null
+++ b/lua/clever_tee/init.lua
@@ -0,0 +1,118 @@
+local composition_root = require("clever_tee.composition_root")
+local host_adapter = require("clever_tee.host_adapter")
+
+local M = {}
+local active_root
+local active_activation
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function build_root(options)
+ if composition_root.CompositionRoot.is(options) then
+ return options
+ end
+ if host_adapter.HostAdapter.is(options) then
+ return composition_root.new({ host = options })
+ end
+ options = options or {}
+ if type(options) ~= "table" then
+ fail("clever-tee activation options must be a table", 3)
+ end
+ if options.host == nil then
+ local adapter_options = options.runtime ~= nil
+ and { runtime = options.runtime }
+ or nil
+ options = { host = host_adapter.new(adapter_options) }
+ end
+ return composition_root.new(options)
+end
+
+function M.activate(options)
+ if active_root == nil then
+ active_root = build_root(options)
+ active_activation = active_root:activate()
+ end
+ return active_activation
+end
+
+local function root()
+ M.activate()
+ return active_root
+end
+
+function M.root()
+ return root()
+end
+
+function M.state()
+ return root():state()
+end
+
+local function invoke(name)
+ local instance = root()
+ return instance:host():invoke_action(name)
+end
+
+function M.StartFindForward()
+ return invoke("StartFindForward")
+end
+
+function M.StartFindBackward()
+ return invoke("StartFindBackward")
+end
+
+function M.StartTillForward()
+ return invoke("StartTillForward")
+end
+
+function M.StartTillBackward()
+ return invoke("StartTillBackward")
+end
+
+function M.Reset()
+ return invoke("Reset")
+end
+
+function M.RepeatSameDirection()
+ return invoke("RepeatSameDirection")
+end
+
+function M.RepeatOppositeDirection()
+ return invoke("RepeatOppositeDirection")
+end
+
+local function invoke_direct(callback)
+ local instance = root()
+ local host = instance:host()
+ if type(host.invoke_callback) ~= "function" then
+ fail("clever-tee host must invoke direct action callbacks", 2)
+ end
+ return host:invoke_callback(function()
+ return callback(instance)
+ end)
+end
+
+function M.invoke_descriptor(value)
+ return invoke_direct(function(instance)
+ return instance:invoke_descriptor(value)
+ end)
+end
+
+function M._diagnostic_full_reset()
+ return invoke_direct(function(instance)
+ return instance:diagnostic_full_reset()
+ end)
+end
+
+M.start_find_forward = M.StartFindForward
+M.start_find_backward = M.StartFindBackward
+M.start_till_forward = M.StartTillForward
+M.start_till_backward = M.StartTillBackward
+M.reset = M.Reset
+M.repeat_same_direction = M.RepeatSameDirection
+M.repeat_opposite_direction = M.RepeatOppositeDirection
+M.free_form = M.invoke_descriptor
+
+return M
diff --git a/lua/clever_tee/migemo_catalog.lua b/lua/clever_tee/migemo_catalog.lua
new file mode 100644
index 0000000..0a7f87b
--- /dev/null
+++ b/lua/clever_tee/migemo_catalog.lua
@@ -0,0 +1,527 @@
+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 MigemoCatalog = {}
+local MigemoDictionary = {}
+M.MigemoCatalog = MigemoCatalog
+M.MigemoDictionary = MigemoDictionary
+
+local catalog_records = setmetatable({}, { __mode = "k" })
+local dictionary_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function copy_list(values)
+ local result = {}
+ for index = 1, #values do
+ result[index] = values[index]
+ end
+ return result
+end
+
+local EXPECTED_KEYS = {}
+for code = string.byte("a"), string.byte("z") do
+ EXPECTED_KEYS[#EXPECTED_KEYS + 1] = string.char(code)
+end
+for code = string.byte("A"), string.byte("Z") do
+ EXPECTED_KEYS[#EXPECTED_KEYS + 1] = string.char(code)
+end
+
+local EXPECTED_KEY_SET = {}
+for _, key in ipairs(EXPECTED_KEYS) do
+ EXPECTED_KEY_SET[key] = true
+end
+
+local ASSETS = {
+ ["utf-8"] = {
+ file = "utf8.vim",
+ function_name = "clever_tee#migemo#utf8#load_dict",
+ },
+ cp932 = {
+ file = "cp932.vim",
+ function_name = "clever_tee#migemo#cp932#load_dict",
+ },
+ ["euc-jp"] = {
+ file = "eucjp.vim",
+ function_name = "clever_tee#migemo#eucjp#load_dict",
+ },
+}
+
+local module_source = debug.getinfo(1, "S").source
+local module_file = module_source:sub(1, 1) == "@" and module_source:sub(2) or module_source
+local bundled_root = module_file:match("^(.*)/lua/clever_tee/migemo_catalog%.lua$")
+
+local function require_nonempty_string(value, name)
+ if type(value) ~= "string" or value == "" then
+ fail((name or "value") .. " must be a nonempty string", 2)
+ end
+ return value
+end
+
+local function asset_path(asset)
+ if bundled_root == nil then
+ fail("Migemo catalog could not locate its bundled assets", 2)
+ end
+ return bundled_root .. "/autoload/clever_tee/migemo/" .. asset.file
+end
+
+local function read_asset_key_order(path)
+ local handle, open_error = io.open(path, "rb")
+ if handle == nil then
+ fail("Migemo asset could not be opened: " .. tostring(open_error), 2)
+ end
+
+ local keys = {}
+ for line in handle:lines() do
+ local key = line:match("^%s*\\%s*'([A-Za-z])'%s*:")
+ if key ~= nil then
+ keys[#keys + 1] = key
+ end
+ end
+ handle:close()
+ return keys
+end
+
+local function assert_key_order(keys, encoding)
+ if type(keys) ~= "table" or #keys ~= #EXPECTED_KEYS then
+ fail(
+ "Migemo " .. encoding .. " asset must contain exactly 52 ordered keys",
+ 2
+ )
+ end
+ for index, expected in ipairs(EXPECTED_KEYS) do
+ if keys[index] ~= expected then
+ fail(
+ "Migemo " .. encoding
+ .. " asset keys must be ordered a through z, then A through Z",
+ 2
+ )
+ end
+ end
+end
+
+local function nvim_runtime()
+ local runtime = rawget(_G, "vim")
+ if type(runtime) ~= "table"
+ or runtime.cmd == nil
+ or type(runtime.fn) ~= "table"
+ or type(runtime.fn.fnameescape) ~= "function"
+ then
+ fail("Migemo dictionary loading requires Nvim", 2)
+ end
+ return runtime
+end
+
+local function default_asset_loader(encoding, asset)
+ local runtime = nvim_runtime()
+ local path = asset_path(asset)
+ local keys = read_asset_key_order(path)
+ assert_key_order(keys, encoding)
+
+ runtime.cmd("silent source " .. runtime.fn.fnameescape(path))
+ local loader = runtime.fn[asset.function_name]
+ if type(loader) ~= "function" then
+ fail("Migemo " .. encoding .. " asset did not define its dictionary loader", 2)
+ end
+ local dictionary = loader()
+ return dictionary, keys, path
+end
+
+local function explicit_pattern(pattern, case_mode)
+ local case_flag = case_mode == domain.CaseMode.INSENSITIVE and "\\c" or "\\C"
+ return "\\m" .. case_flag .. "^" .. pattern
+end
+
+local function default_pattern_compiler(pattern, key, encoding)
+ local runtime = nvim_runtime()
+ if type(runtime.regex) ~= "function" or type(runtime.fn.match) ~= "function" then
+ fail("Migemo pattern evaluation requires Nvim regular expressions", 2)
+ end
+
+ local sensitive = explicit_pattern(pattern, domain.CaseMode.SENSITIVE)
+ local insensitive = explicit_pattern(pattern, domain.CaseMode.INSENSITIVE)
+ local ok, compile_error = pcall(runtime.regex, sensitive)
+ if ok then
+ ok, compile_error = pcall(runtime.regex, insensitive)
+ end
+ if not ok then
+ fail(
+ "Migemo " .. encoding .. " pattern for '" .. key
+ .. "' could not be compiled: " .. tostring(compile_error),
+ 2
+ )
+ end
+
+ return function(text, case_mode)
+ if type(text) ~= "string" then
+ fail("Migemo assertion text must be a string", 2)
+ end
+ case_mode = domain.CaseMode.from_string(case_mode)
+ local selected = case_mode == domain.CaseMode.INSENSITIVE
+ and insensitive
+ or sensitive
+ local matched, start_or_error = pcall(runtime.fn.match, text, selected)
+ if not matched then
+ fail(
+ "Migemo " .. encoding .. " pattern for '" .. key
+ .. "' could not be evaluated: " .. tostring(start_or_error),
+ 2
+ )
+ end
+ return start_or_error == 0
+ end
+end
+
+local dictionary_metatable = {
+ __index = function(dictionary, key)
+ local method = MigemoDictionary[key]
+ if method ~= nil then
+ return method
+ end
+
+ local record = dictionary_records[dictionary]
+ if key == "encoding" or key == "effective_encoding" then
+ return record.encoding
+ end
+ if key == "entry_count" then
+ return #record.keys
+ end
+ if key == "asset_path" then
+ return record.asset_path
+ end
+ if EXPECTED_KEY_SET[key] then
+ return record.predicates[key]
+ end
+ return nil
+ end,
+ __newindex = function()
+ fail("MigemoDictionary values are immutable", 2)
+ end,
+ __tostring = function(dictionary)
+ return "migemo-dictionary:" .. dictionary_records[dictionary].encoding
+ end,
+ __metatable = "clever_tee.migemo_catalog.MigemoDictionary",
+}
+
+local function validate_dictionary_data(data, ordered_keys, encoding)
+ if type(data) ~= "table" then
+ fail("Migemo " .. encoding .. " asset must return a dictionary", 3)
+ end
+ assert_key_order(ordered_keys, encoding)
+
+ local count = 0
+ for key, pattern in pairs(data) do
+ count = count + 1
+ if EXPECTED_KEY_SET[key] ~= true then
+ fail("Migemo " .. encoding .. " asset contains an unexpected key", 3)
+ end
+ if type(pattern) ~= "string" or pattern == "" then
+ fail("Migemo " .. encoding .. " patterns must be nonempty strings", 3)
+ end
+ end
+ if count ~= #EXPECTED_KEYS then
+ fail("Migemo " .. encoding .. " asset must contain exactly 52 keys", 3)
+ end
+ for _, key in ipairs(EXPECTED_KEYS) do
+ if data[key] == nil then
+ fail("Migemo " .. encoding .. " asset is missing key '" .. key .. "'", 3)
+ end
+ end
+end
+
+local function new_dictionary(encoding, data, ordered_keys, path, compiler)
+ validate_dictionary_data(data, ordered_keys, encoding)
+
+ local patterns = {}
+ local predicates = {}
+ for _, key in ipairs(EXPECTED_KEYS) do
+ local pattern = data[key]
+ patterns[key] = pattern
+ local predicate = compiler(pattern, key, encoding)
+ if type(predicate) ~= "function" then
+ fail("Migemo pattern compiler must return a predicate", 3)
+ end
+ predicates[key] = predicate
+ end
+
+ local dictionary = setmetatable({}, dictionary_metatable)
+ dictionary_records[dictionary] = {
+ encoding = encoding,
+ keys = copy_list(ordered_keys),
+ patterns = patterns,
+ predicates = predicates,
+ asset_path = path,
+ }
+ return dictionary
+end
+
+function MigemoDictionary.is(value)
+ return type(value) == "table" and dictionary_records[value] ~= nil
+end
+
+local function dictionary_record(dictionary)
+ if not MigemoDictionary.is(dictionary) then
+ fail("value must be a MigemoDictionary", 3)
+ end
+ return dictionary_records[dictionary]
+end
+
+function MigemoDictionary:keys()
+ return copy_list(dictionary_record(self).keys)
+end
+
+function MigemoDictionary:has(key)
+ return type(key) == "string"
+ and dictionary_record(self).predicates[key] ~= nil
+end
+
+function MigemoDictionary:pattern(key)
+ require_nonempty_string(key, "Migemo dictionary key")
+ local pattern = dictionary_record(self).patterns[key]
+ if pattern == nil then
+ fail("Migemo dictionary key must be one ASCII alphabetic character", 2)
+ end
+ return pattern
+end
+
+function MigemoDictionary:predicate(key, case_mode)
+ require_nonempty_string(key, "Migemo dictionary key")
+ local predicate = dictionary_record(self).predicates[key]
+ if predicate == nil then
+ fail("Migemo dictionary key must be one ASCII alphabetic character", 2)
+ end
+ if case_mode == nil then
+ return predicate
+ end
+
+ case_mode = domain.CaseMode.from_string(case_mode)
+ return function(text)
+ return predicate(text, case_mode)
+ end
+end
+
+function MigemoDictionary:matches(key, text, case_mode)
+ return self:predicate(key)(text, case_mode)
+end
+
+function MigemoDictionary:to_table()
+ local record = dictionary_record(self)
+ return {
+ encoding = record.encoding,
+ entry_count = #record.keys,
+ keys = copy_list(record.keys),
+ asset_path = record.asset_path,
+ }
+end
+
+local function normalize_catalog_options(options)
+ if options == nil then
+ return {}
+ end
+ if type(options) ~= "table" then
+ fail("MigemoCatalog options must be a table", 3)
+ end
+ if type(options.disable_migemo_for_unsupported_encoding) == "function"
+ and options.policy == nil
+ and options.policy_service == nil
+ and options.transitions == nil
+ and options.state == nil
+ and options.asset_loader == nil
+ and options.pattern_compiler == nil
+ then
+ return { policy = options }
+ end
+ return options
+end
+
+local function require_policy(service)
+ if service ~= nil and (type(service) ~= "table"
+ or type(service.disable_migemo_for_unsupported_encoding) ~= "function")
+ then
+ fail(
+ "MigemoCatalog policy must provide disable_migemo_for_unsupported_encoding",
+ 3
+ )
+ end
+ return service
+end
+
+local function require_transitions(transitions, state)
+ transitions = transitions or state_transitions.new(state)
+ if type(transitions) ~= "table"
+ or type(transitions.CacheMigemo) ~= "function"
+ or type(transitions.state) ~= "function"
+ or transitions:state() ~= state
+ then
+ fail("MigemoCatalog transitions must mutate its SequenceState", 3)
+ end
+ return transitions
+end
+
+local function selected_function(value, fallback, name)
+ value = value or fallback
+ if type(value) ~= "function" then
+ fail("MigemoCatalog " .. name .. " must be a function", 3)
+ end
+ return value
+end
+
+local catalog_metatable = {
+ __index = MigemoCatalog,
+ __newindex = function()
+ fail("MigemoCatalog values are immutable", 2)
+ end,
+ __tostring = function()
+ return "migemo-catalog"
+ end,
+ __metatable = "clever_tee.migemo_catalog.MigemoCatalog",
+}
+
+function MigemoCatalog.new(options)
+ if MigemoCatalog.is(options) then
+ return options
+ end
+ options = normalize_catalog_options(options)
+ local state = options.state or sequence_state.get()
+ if not sequence_state.is(state) then
+ fail("MigemoCatalog requires the plugin-global SequenceState", 2)
+ end
+
+ local catalog = setmetatable({}, catalog_metatable)
+ catalog_records[catalog] = {
+ state = state,
+ transitions = require_transitions(options.transitions, state),
+ policy = require_policy(options.policy or options.policy_service),
+ disable_migemo = options.disable_migemo,
+ asset_loader = selected_function(
+ options.asset_loader,
+ default_asset_loader,
+ "asset_loader"
+ ),
+ pattern_compiler = selected_function(
+ options.pattern_compiler,
+ default_pattern_compiler,
+ "pattern_compiler"
+ ),
+ load_counts = {},
+ }
+ if catalog_records[catalog].disable_migemo ~= nil
+ and type(catalog_records[catalog].disable_migemo) ~= "function"
+ then
+ fail("MigemoCatalog disable_migemo must be a function", 2)
+ end
+ return catalog
+end
+
+function MigemoCatalog.is(value)
+ return type(value) == "table" and catalog_records[value] ~= nil
+end
+
+function M.new(options)
+ return MigemoCatalog.new(options)
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return MigemoCatalog.new(options)
+ end,
+})
+
+local function catalog_record(catalog)
+ if not MigemoCatalog.is(catalog) then
+ fail("value must be a MigemoCatalog", 3)
+ end
+ return catalog_records[catalog]
+end
+
+local function unsupported(catalog, requested_encoding, policy_override)
+ local record = catalog_record(catalog)
+ local active_policy = policy_override or record.policy
+ if active_policy ~= nil then
+ require_policy(active_policy):disable_migemo_for_unsupported_encoding()
+ elseif record.disable_migemo ~= nil then
+ record.disable_migemo()
+ end
+ error(
+ "clever-tee: Encoding '" .. requested_encoding
+ .. "' is not supported. Migemo is disabled",
+ 0
+ )
+end
+
+function MigemoCatalog:get(effective_encoding, policy_override)
+ local requested = require_nonempty_string(effective_encoding, "effective encoding")
+ local encoding = text_topology.normalize_encoding(requested)
+ local asset = ASSETS[encoding]
+ if asset == nil then
+ return unsupported(self, requested, policy_override)
+ end
+
+ local record = catalog_record(self)
+ local cached = record.state:get_migemo(encoding)
+ if cached ~= nil then
+ if not MigemoDictionary.is(cached) then
+ fail("Migemo cache contains an invalid dictionary", 2)
+ end
+ return cached
+ end
+
+ local data, ordered_keys, path = record.asset_loader(encoding, asset)
+ local dictionary = new_dictionary(
+ encoding,
+ data,
+ ordered_keys,
+ path,
+ record.pattern_compiler
+ )
+ record.transitions:CacheMigemo(encoding, dictionary)
+ record.load_counts[encoding] = (record.load_counts[encoding] or 0) + 1
+ return dictionary
+end
+
+function MigemoCatalog:load_count(effective_encoding)
+ local encoding = text_topology.normalize_encoding(effective_encoding)
+ return catalog_record(self).load_counts[encoding] or 0
+end
+
+function MigemoCatalog:cached(effective_encoding)
+ local encoding = text_topology.normalize_encoding(effective_encoding)
+ local value = catalog_record(self).state:get_migemo(encoding)
+ if value ~= nil and not MigemoDictionary.is(value) then
+ fail("Migemo cache contains an invalid dictionary", 2)
+ end
+ return value
+end
+
+MigemoCatalog.load = MigemoCatalog.get
+MigemoCatalog.select = MigemoCatalog.get
+MigemoCatalog.dictionary = MigemoCatalog.get
+
+function M.expected_keys()
+ return copy_list(EXPECTED_KEYS)
+end
+
+function M.supported_encodings()
+ return { "utf-8", "cp932", "euc-jp" }
+end
+
+function M.bundled_asset_path(effective_encoding)
+ local encoding = text_topology.normalize_encoding(effective_encoding)
+ local asset = ASSETS[encoding]
+ if asset == nil then
+ return nil
+ end
+ return asset_path(asset)
+end
+
+M.load = function(effective_encoding, options)
+ return MigemoCatalog.new(options):get(effective_encoding)
+end
+M.EXPECTED_ENTRY_COUNT = #EXPECTED_KEYS
+
+return M
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
diff --git a/lua/clever_tee/motion_plan.lua b/lua/clever_tee/motion_plan.lua
new file mode 100644
index 0000000..17e06eb
--- /dev/null
+++ b/lua/clever_tee/motion_plan.lua
@@ -0,0 +1,188 @@
+local domain = require("clever_tee.domain")
+
+local M = {}
+local MotionPlanFactory = {}
+M.MotionPlanFactory = MotionPlanFactory
+
+local factory_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function normalize_options(options)
+ if options == nil then
+ return {}
+ end
+ if type(options) == "table" and type(options.sample_search) == "function" then
+ return { policy = options }
+ end
+ if type(options) ~= "table" then
+ fail("MotionPlanFactory options must be a table", 3)
+ end
+ return options
+end
+
+local function require_policy(service)
+ if service ~= nil and (type(service) ~= "table"
+ or type(service.sample_search) ~= "function")
+ then
+ fail("MotionPlanFactory policy must provide sample_search", 3)
+ end
+ return service
+end
+
+local factory_metatable = {
+ __index = MotionPlanFactory,
+ __newindex = function()
+ fail("MotionPlanFactory values are immutable", 2)
+ end,
+ __tostring = function()
+ return "motion-plan-factory"
+ end,
+ __metatable = "clever_tee.motion_plan.MotionPlanFactory",
+}
+
+function MotionPlanFactory.new(options)
+ if MotionPlanFactory.is(options) then
+ return options
+ end
+ options = normalize_options(options)
+ local factory = setmetatable({}, factory_metatable)
+ factory_records[factory] = {
+ policy = require_policy(options.policy or options.policy_service),
+ }
+ return factory
+end
+
+function MotionPlanFactory.is(value)
+ return type(value) == "table" and factory_records[value] ~= nil
+end
+
+local function resolved_scope(factory, search_scope)
+ if search_scope == nil then
+ local policy = factory_records[factory].policy
+ if policy ~= nil then
+ search_scope = policy:sample_search().search_scope
+ else
+ search_scope = domain.SearchScope.BUFFER
+ end
+ elseif search_scope == "line" then
+ search_scope = domain.SearchScope.CURRENT_LINE
+ end
+ return domain.SearchScope.from_string(search_scope)
+end
+
+function MotionPlanFactory:build(
+ target_plan,
+ effective_descriptor,
+ search_scope,
+ endpoint_policy
+)
+ if not domain.TargetPlan.is(target_plan) then
+ fail("motion target plan must be a TargetPlan", 2)
+ end
+
+ return domain.ResolvedMotionPlan.new({
+ target_plan = target_plan,
+ descriptor = effective_descriptor,
+ search_scope = resolved_scope(self, search_scope),
+ endpoint_policy = endpoint_policy or domain.EndpointPolicy.REGULAR,
+ })
+end
+
+local function selection_option(selection)
+ if selection == nil then
+ return domain.SelectionOption.INCLUSIVE
+ end
+ if domain.Selection.is(selection) then
+ return selection.option
+ end
+ if domain.SelectionOption.is(selection) then
+ return selection
+ end
+ if type(selection) == "table" and selection.option ~= nil then
+ return domain.SelectionOption.from_string(selection.option)
+ end
+ return domain.SelectionOption.from_string(selection)
+end
+
+function M.endpoint_policy(context, selection)
+ context = domain.ModeContext.from_full_mode(context)
+ local option = selection_option(selection)
+ local visual_kind = context.visual_kind
+ if option == domain.SelectionOption.EXCLUSIVE
+ and (visual_kind == domain.SelectionKind.CHARACTER
+ or visual_kind == domain.SelectionKind.LINE)
+ then
+ return domain.EndpointPolicy.VISUAL_EXCLUSIVE
+ end
+ return domain.EndpointPolicy.REGULAR
+end
+
+function MotionPlanFactory:endpoint_policy(context, selection)
+ return M.endpoint_policy(context, selection)
+end
+
+function MotionPlanFactory:build_for_context(
+ target_plan,
+ effective_descriptor,
+ context,
+ selection,
+ search_scope
+)
+ return self:build(
+ target_plan,
+ effective_descriptor,
+ search_scope,
+ self:endpoint_policy(context, selection)
+ )
+end
+
+function M.new(options)
+ return MotionPlanFactory.new(options)
+end
+
+function M.build(
+ target_plan,
+ effective_descriptor,
+ search_scope,
+ endpoint_policy,
+ options
+)
+ return MotionPlanFactory.new(options):build(
+ target_plan,
+ effective_descriptor,
+ search_scope,
+ endpoint_policy
+ )
+end
+
+function M.build_for_context(
+ target_plan,
+ effective_descriptor,
+ context,
+ selection,
+ search_scope,
+ options
+)
+ return MotionPlanFactory.new(options):build_for_context(
+ target_plan,
+ effective_descriptor,
+ context,
+ selection,
+ search_scope
+ )
+end
+
+M.create = M.build
+M.resolve = M.build
+M.for_context = M.build_for_context
+
+setmetatable(M, {
+ __call = function(_, options)
+ return MotionPlanFactory.new(options)
+ end,
+})
+
+return M
diff --git a/lua/clever_tee/policy.lua b/lua/clever_tee/policy.lua
new file mode 100644
index 0000000..94bf6df
--- /dev/null
+++ b/lua/clever_tee/policy.lua
@@ -0,0 +1,488 @@
+local case_policy = require("clever_tee.case_policy")
+local domain = require("clever_tee.domain")
+
+local M = {}
+local PolicyService = {}
+PolicyService.__index = PolicyService
+M.PolicyService = PolicyService
+
+M.ValueType = {
+ BOOLEAN = "boolean",
+ STRING = "string",
+ STRING_LIST = "string_list",
+ OPTIONAL_GROUP_NAME = "optional_group_name",
+ NONNEGATIVE_INTEGER = "nonnegative_integer",
+ PRESENCE = "presence",
+}
+
+M.Sampling = {
+ LIVE = "live",
+ ACTIVATION = "activation",
+ LINK_EVALUATION = "link_evaluation",
+}
+
+M.DEFAULT_MAP_SUPPRESSION_SENTINEL = "suppress_default_mappings"
+
+local NO_VALUE = {}
+local SCHEMA = {
+ search_current_line_only = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ ignore_case = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ smart_case = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ use_migemo = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ fix_key_direction = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ show_prompt = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ chars_match_any_signs = {
+ value_type = M.ValueType.STRING,
+ default = "",
+ sampling = M.Sampling.LIVE,
+ },
+ mark_cursor = {
+ value_type = M.ValueType.BOOLEAN,
+ default = true,
+ sampling = M.Sampling.LIVE,
+ },
+ mark_cursor_color = {
+ value_type = M.ValueType.OPTIONAL_GROUP_NAME,
+ default = NO_VALUE,
+ default_target = "Cursor",
+ feature_setting = "mark_cursor",
+ highlight_group = "CleverTeeCursor",
+ sampling = M.Sampling.LINK_EVALUATION,
+ },
+ hide_cursor_on_cmdline = {
+ value_type = M.ValueType.BOOLEAN,
+ default = true,
+ sampling = M.Sampling.LIVE,
+ },
+ repeat_timeout_ms = {
+ value_type = M.ValueType.NONNEGATIVE_INTEGER,
+ default = 0,
+ sampling = M.Sampling.LIVE,
+ },
+ mark_char = {
+ value_type = M.ValueType.BOOLEAN,
+ default = true,
+ sampling = M.Sampling.LIVE,
+ },
+ mark_char_color = {
+ value_type = M.ValueType.OPTIONAL_GROUP_NAME,
+ default = NO_VALUE,
+ default_target = "CleverTeeDefaultLabel",
+ feature_setting = "mark_char",
+ highlight_group = "CleverTeeChar",
+ sampling = M.Sampling.LINK_EVALUATION,
+ },
+ highlight_timeout_ms = {
+ value_type = M.ValueType.NONNEGATIVE_INTEGER,
+ default = 0,
+ sampling = M.Sampling.LIVE,
+ },
+ repeat_last_char_inputs = {
+ value_type = M.ValueType.STRING_LIST,
+ default = { "\r" },
+ sampling = M.Sampling.LIVE,
+ },
+ mark_direct = {
+ value_type = M.ValueType.BOOLEAN,
+ default = false,
+ sampling = M.Sampling.LIVE,
+ },
+ mark_direct_color = {
+ value_type = M.ValueType.OPTIONAL_GROUP_NAME,
+ default = NO_VALUE,
+ default_target = "CleverTeeDefaultLabel",
+ feature_setting = "mark_direct",
+ highlight_group = "CleverTeeDirect",
+ sampling = M.Sampling.LINK_EVALUATION,
+ },
+ clean_labels_eagerly = {
+ value_type = M.ValueType.BOOLEAN,
+ default = true,
+ sampling = M.Sampling.ACTIVATION,
+ },
+ [M.DEFAULT_MAP_SUPPRESSION_SENTINEL] = {
+ value_type = M.ValueType.PRESENCE,
+ default = false,
+ sampling = M.Sampling.ACTIVATION,
+ },
+}
+
+local COLOR_SETTINGS = {
+ "mark_cursor_color",
+ "mark_char_color",
+ "mark_direct_color",
+}
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function is_integer(value)
+ return type(value) == "number"
+ and value > -math.huge
+ and value < math.huge
+ and value == math.floor(value)
+end
+
+local function copy_list(values)
+ local result = {}
+ for index = 1, #values do
+ result[index] = values[index]
+ end
+ return result
+end
+
+local function copy_table(value)
+ local result = {}
+ for key, item in pairs(value) do
+ if type(item) == "table" and domain.type_of(item) == nil then
+ result[key] = copy_table(item)
+ else
+ result[key] = item
+ end
+ end
+ return result
+end
+
+local function schema_entry(name)
+ local entry = SCHEMA[name]
+ if entry == nil then
+ fail("unknown policy setting '" .. tostring(name) .. "'", 2)
+ end
+ return entry
+end
+
+local function default_value(entry)
+ if entry.default == NO_VALUE then
+ return nil
+ end
+ if type(entry.default) == "table" then
+ return copy_table(entry.default)
+ end
+ return entry.default
+end
+
+local function validate_boolean(value, name)
+ if type(value) ~= "boolean" then
+ fail("policy setting '" .. name .. "' must be a Boolean", 3)
+ end
+ return value
+end
+
+local function validate_string(value, name)
+ if type(value) ~= "string" then
+ fail("policy setting '" .. name .. "' must be a string", 3)
+ end
+ return value
+end
+
+local function validate_string_list(value, name)
+ if type(value) ~= "table" then
+ fail("policy setting '" .. name .. "' must be a list of strings", 3)
+ end
+
+ local length = #value
+ local item_count = 0
+ for key, item in pairs(value) do
+ if not is_integer(key) or key < 1 or key > length then
+ fail("policy setting '" .. name .. "' must be a list of strings", 3)
+ end
+ if type(item) ~= "string" then
+ fail("policy setting '" .. name .. "' must be a list of strings", 3)
+ end
+ item_count = item_count + 1
+ end
+ if item_count ~= length then
+ fail("policy setting '" .. name .. "' must be a list of strings", 3)
+ end
+ return copy_list(value)
+end
+
+local function validate_optional_group_name(value, name)
+ if value ~= nil and (type(value) ~= "string" or value == "") then
+ fail("policy setting '" .. name .. "' must be an optional group name", 3)
+ end
+ return value
+end
+
+local function validate_nonnegative_integer(value, name)
+ if not is_integer(value) or value < 0 then
+ fail("policy setting '" .. name .. "' must be a nonnegative integer", 3)
+ end
+ return value
+end
+
+local VALIDATORS = {
+ [M.ValueType.BOOLEAN] = validate_boolean,
+ [M.ValueType.STRING] = validate_string,
+ [M.ValueType.STRING_LIST] = validate_string_list,
+ [M.ValueType.OPTIONAL_GROUP_NAME] = validate_optional_group_name,
+ [M.ValueType.NONNEGATIVE_INTEGER] = validate_nonnegative_integer,
+}
+
+local function require_provider(provider)
+ if type(provider) ~= "table" then
+ fail("policy configuration provider must be a table", 2)
+ end
+ local required = {
+ "configuration_present",
+ "read_configuration",
+ "write_configuration",
+ }
+ for _, method_name in ipairs(required) do
+ if type(provider[method_name]) ~= "function" then
+ fail("policy configuration provider is missing " .. method_name, 2)
+ end
+ end
+ return provider
+end
+
+function M.setting_names()
+ local names = {}
+ for name in pairs(SCHEMA) do
+ names[#names + 1] = name
+ end
+ table.sort(names)
+ return names
+end
+
+function M.schema()
+ local result = {}
+ for name, entry in pairs(SCHEMA) do
+ local public_entry = {
+ value_type = entry.value_type,
+ sampling = entry.sampling,
+ has_default = true,
+ }
+ local value = default_value(entry)
+ if value ~= nil then
+ public_entry.default = value
+ end
+ if entry.default_target ~= nil then
+ public_entry.default_target = entry.default_target
+ public_entry.feature_setting = entry.feature_setting
+ public_entry.highlight_group = entry.highlight_group
+ end
+ result[name] = public_entry
+ end
+ return result
+end
+
+function M.default(name)
+ return default_value(schema_entry(name))
+end
+
+function M.defaults()
+ local result = {}
+ for name, entry in pairs(SCHEMA) do
+ local value = default_value(entry)
+ if value ~= nil then
+ result[name] = value
+ end
+ end
+ return result
+end
+
+function PolicyService.new(provider)
+ return setmetatable({
+ _provider = require_provider(provider),
+ _activation = nil,
+ }, PolicyService)
+end
+
+function M.new(provider)
+ return PolicyService.new(provider)
+end
+
+setmetatable(M, {
+ __call = function(_, provider)
+ return PolicyService.new(provider)
+ end,
+})
+
+function PolicyService:get(name)
+ local entry = schema_entry(name)
+ if entry.value_type == M.ValueType.PRESENCE then
+ return self._provider:configuration_present(name)
+ end
+
+ local value
+ if self._provider:configuration_present(name) then
+ value = self._provider:read_configuration(name)
+ else
+ value = default_value(entry)
+ end
+ return VALIDATORS[entry.value_type](value, name)
+end
+
+function PolicyService:_get_typed(name, expected_type)
+ local entry = schema_entry(name)
+ if entry.value_type ~= expected_type then
+ fail(
+ "policy setting '" .. name .. "' does not have type " .. expected_type,
+ 2
+ )
+ end
+ return self:get(name)
+end
+
+function PolicyService:get_boolean(name)
+ return self:_get_typed(name, M.ValueType.BOOLEAN)
+end
+
+function PolicyService:get_string(name)
+ return self:_get_typed(name, M.ValueType.STRING)
+end
+
+function PolicyService:get_string_list(name)
+ return self:_get_typed(name, M.ValueType.STRING_LIST)
+end
+
+function PolicyService:get_optional_group_name(name)
+ return self:_get_typed(name, M.ValueType.OPTIONAL_GROUP_NAME)
+end
+
+function PolicyService:get_nonnegative_integer(name)
+ return self:_get_typed(name, M.ValueType.NONNEGATIVE_INTEGER)
+end
+
+function PolicyService:get_presence(name)
+ return self:_get_typed(name, M.ValueType.PRESENCE)
+end
+
+function PolicyService:default_maps_suppressed()
+ return self:get_presence(M.DEFAULT_MAP_SUPPRESSION_SENTINEL)
+end
+
+function PolicyService:capture_activation()
+ if self._activation == nil then
+ self._activation = {
+ install_default_mappings = not self:default_maps_suppressed(),
+ clean_labels_eagerly = self:get_boolean("clean_labels_eagerly"),
+ }
+ end
+ return copy_table(self._activation)
+end
+
+function PolicyService:evaluate_highlight_links()
+ local result = {}
+ for _, color_setting in ipairs(COLOR_SETTINGS) do
+ local entry = SCHEMA[color_setting]
+ local configured_target = self:get_optional_group_name(color_setting)
+ result[entry.highlight_group] = {
+ enabled = self:get_boolean(entry.feature_setting),
+ feature_setting = entry.feature_setting,
+ color_setting = color_setting,
+ configured_target = configured_target,
+ target = configured_target or entry.default_target,
+ }
+ end
+ return result
+end
+
+function M.resolve_case_mode(target, ignore_case, smart_case)
+ return case_policy.resolve_case_mode(target, ignore_case, smart_case)
+end
+
+function PolicyService:case_mode(target)
+ return M.resolve_case_mode(
+ target,
+ self:get_boolean("ignore_case"),
+ self:get_boolean("smart_case")
+ )
+end
+
+function PolicyService:sample_search()
+ local current_line_only = self:get_boolean("search_current_line_only")
+ return {
+ search_current_line_only = current_line_only,
+ search_scope = current_line_only
+ and domain.SearchScope.CURRENT_LINE
+ or domain.SearchScope.BUFFER,
+ }
+end
+
+function PolicyService:sample_match(target)
+ local ignore_case = self:get_boolean("ignore_case")
+ local smart_case = self:get_boolean("smart_case")
+ return {
+ ignore_case = ignore_case,
+ smart_case = smart_case,
+ use_migemo = self:get_boolean("use_migemo"),
+ chars_match_any_signs = self:get_string("chars_match_any_signs"),
+ case_mode = M.resolve_case_mode(target, ignore_case, smart_case),
+ }
+end
+
+function PolicyService:sample_direction()
+ return {
+ fix_key_direction = self:get_boolean("fix_key_direction"),
+ }
+end
+
+function PolicyService:sample_acquisition()
+ return {
+ show_prompt = self:get_boolean("show_prompt"),
+ mark_cursor = self:get_boolean("mark_cursor"),
+ hide_cursor_on_cmdline = self:get_boolean("hide_cursor_on_cmdline"),
+ mark_direct = self:get_boolean("mark_direct"),
+ }
+end
+
+function PolicyService:sample_direct_preview()
+ return {
+ ignore_case = self:get_boolean("ignore_case"),
+ smart_case = self:get_boolean("smart_case"),
+ }
+end
+
+function PolicyService:sample_markers()
+ return {
+ mark_cursor = self:get_boolean("mark_cursor"),
+ mark_char = self:get_boolean("mark_char"),
+ mark_direct = self:get_boolean("mark_direct"),
+ }
+end
+
+function PolicyService:sample_timeouts()
+ return {
+ repeat_timeout_ms = self:get_nonnegative_integer("repeat_timeout_ms"),
+ highlight_timeout_ms = self:get_nonnegative_integer("highlight_timeout_ms"),
+ }
+end
+
+function PolicyService:sample_previous_input()
+ return {
+ repeat_last_char_inputs = self:get_string_list("repeat_last_char_inputs"),
+ }
+end
+
+function PolicyService:disable_migemo_for_unsupported_encoding()
+ self._provider:write_configuration("use_migemo", false)
+end
+
+return M
diff --git a/lua/clever_tee/repeat_resolver.lua b/lua/clever_tee/repeat_resolver.lua
new file mode 100644
index 0000000..67d6617
--- /dev/null
+++ b/lua/clever_tee/repeat_resolver.lua
@@ -0,0 +1,333 @@
+local domain = require("clever_tee.domain")
+local sequence_state = require("clever_tee.sequence_state")
+local state_transitions = require("clever_tee.state_transitions")
+
+local M = {}
+local RepeatResolver = {}
+M.RepeatResolver = RepeatResolver
+
+M.Decision = {
+ ACQUIRE = "acquire",
+ REPEAT = "repeat",
+}
+M.ACQUIRE = M.Decision.ACQUIRE
+M.REPEAT = M.Decision.REPEAT
+
+local resolver_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function is_nonnegative_integer(value)
+ return type(value) == "number"
+ and value >= 0
+ and value < math.huge
+ and value == math.floor(value)
+end
+
+local function require_policy(service)
+ if service ~= nil and (type(service) ~= "table"
+ or type(service.sample_timeouts) ~= "function"
+ or type(service.sample_direction) ~= "function")
+ then
+ fail("RepeatResolver policy must sample timeouts and direction", 3)
+ end
+ return service
+end
+
+local function require_clock(clock)
+ if clock ~= nil and (type(clock) ~= "table"
+ or type(clock.read_time_ms) ~= "function")
+ then
+ fail("RepeatResolver clock must provide read_time_ms", 3)
+ end
+ return clock
+end
+
+local function require_transitions(transitions, state)
+ transitions = transitions or state_transitions.new(state)
+ if type(transitions) ~= "table"
+ or type(transitions.SetRepeatTimestamp) ~= "function"
+ or type(transitions.PublicReset) ~= "function"
+ then
+ fail("RepeatResolver transitions must set repeat time and apply Public Reset", 3)
+ end
+ return transitions
+end
+
+local function finite_time(value)
+ return type(value) == "number"
+ and value == value
+ and value > -math.huge
+ and value < math.huge
+end
+
+function M.truncate_elapsed_ms(elapsed_ms)
+ if not finite_time(elapsed_ms) then
+ fail("elapsed milliseconds must be finite", 2)
+ end
+ local integer_part = math.modf(elapsed_ms)
+ return integer_part
+end
+
+M.truncate_milliseconds = M.truncate_elapsed_ms
+
+local resolver_metatable = {
+ __index = RepeatResolver,
+ __newindex = function()
+ fail("RepeatResolver values are immutable", 2)
+ end,
+ __tostring = function()
+ return "repeat-resolver"
+ end,
+ __metatable = "clever_tee.repeat_resolver.RepeatResolver",
+}
+
+function RepeatResolver.new(options)
+ if RepeatResolver.is(options) then
+ return options
+ end
+ if options == nil then
+ options = {}
+ elseif sequence_state.is(options) then
+ options = { state = options }
+ elseif type(options) ~= "table" then
+ fail("RepeatResolver options must be a table", 2)
+ end
+
+ local state = options.state or sequence_state.get()
+ if not sequence_state.is(state) then
+ fail("RepeatResolver state must be the plugin-global SequenceState", 2)
+ end
+
+ local resolver = setmetatable({}, resolver_metatable)
+ resolver_records[resolver] = {
+ clock = require_clock(options.clock or options.time_provider or options.host),
+ policy = require_policy(options.policy or options.policy_service),
+ state = state,
+ transitions = require_transitions(
+ options.transitions or options.state_transitions,
+ state
+ ),
+ }
+ return resolver
+end
+
+function RepeatResolver.is(value)
+ return type(value) == "table" and resolver_records[value] ~= nil
+end
+
+function RepeatResolver:previous_landing(context)
+ context = domain.ModeContext.from_full_mode(context)
+ return resolver_records[self].state:get_previous_landing(context)
+end
+
+function RepeatResolver:decide(context, current_position, macro_state)
+ current_position = domain.Position.coerce(current_position)
+ local landing = self:previous_landing(context)
+ if landing == nil or not domain.Position.equal(landing, current_position) then
+ return M.Decision.ACQUIRE
+ end
+ if domain.MacroState.new(macro_state).executing then
+ return M.Decision.ACQUIRE
+ end
+ return M.Decision.REPEAT
+end
+
+RepeatResolver.eligibility = RepeatResolver.decide
+RepeatResolver.resolve_eligibility = RepeatResolver.decide
+
+function RepeatResolver:sample_repeat_timeout_ms()
+ local service = resolver_records[self].policy
+ if service == nil then
+ fail("RepeatResolver requires a policy to sample repeat timeout", 2)
+ end
+ local sampled = service:sample_timeouts()
+ local timeout = type(sampled) == "table" and sampled.repeat_timeout_ms or nil
+ if not is_nonnegative_integer(timeout) then
+ fail("repeat_timeout_ms sample must be a nonnegative integer", 2)
+ end
+ return timeout
+end
+
+RepeatResolver.sample_repeat_timeout = RepeatResolver.sample_repeat_timeout_ms
+
+function RepeatResolver:evaluate_timeout(current_window)
+ local timeout = self:sample_repeat_timeout_ms()
+ if timeout == 0 then
+ return M.Decision.REPEAT, nil
+ end
+
+ local clock = resolver_records[self].clock
+ if clock == nil then
+ fail("RepeatResolver requires a clock for positive repeat timeout", 2)
+ end
+ local current_time = clock:read_time_ms()
+ if not finite_time(current_time) then
+ fail("repeat clock must return finite milliseconds", 2)
+ end
+ local record = resolver_records[self]
+ local elapsed_ms = M.truncate_elapsed_ms(
+ current_time - record.state.repeat_timestamp_ms
+ )
+ record.transitions:SetRepeatTimestamp(current_time)
+ if elapsed_ms <= timeout then
+ return M.Decision.REPEAT, elapsed_ms
+ end
+ local cleanup = record.transitions:PublicReset(current_window)
+ return M.Decision.ACQUIRE, elapsed_ms, cleanup
+end
+
+RepeatResolver.check_timeout = RepeatResolver.evaluate_timeout
+RepeatResolver.resolve_timeout = RepeatResolver.evaluate_timeout
+
+local function sampled_fixed_direction(resolver)
+ local service = resolver_records[resolver].policy
+ if service == nil then
+ fail("RepeatResolver requires a policy to resolve primary direction", 3)
+ end
+ local sampled = service:sample_direction()
+ local fixed
+ if type(sampled) == "table" then
+ fixed = sampled.fix_key_direction
+ end
+ if type(fixed) ~= "boolean" then
+ fail("fix_key_direction sample must be a Boolean", 3)
+ end
+ return fixed
+end
+
+function M.reverse_request(stored_descriptor, pressed_key, fix_key_direction)
+ local stored = domain.Descriptor.from_string(stored_descriptor)
+ local pressed = domain.Descriptor.from_string(pressed_key)
+ if type(fix_key_direction) ~= "boolean" then
+ fail("fix_key_direction must be a Boolean", 2)
+ end
+
+ local reverse = domain.Descriptor.is_uppercase(pressed)
+ if fix_key_direction and domain.Descriptor.is_uppercase(stored) then
+ reverse = not reverse
+ end
+ return reverse
+end
+
+function M.primary_direction(stored_descriptor, pressed_key, fix_key_direction)
+ local stored = domain.Descriptor.from_string(stored_descriptor)
+ if M.reverse_request(stored, pressed_key, fix_key_direction) then
+ return domain.Descriptor.swap(stored)
+ end
+ return stored
+end
+
+M.resolve_primary_direction = M.primary_direction
+M.effective_primary_descriptor = M.primary_direction
+
+function RepeatResolver:resolve_primary_direction(stored_descriptor, pressed_key)
+ return M.primary_direction(
+ stored_descriptor,
+ pressed_key,
+ sampled_fixed_direction(self)
+ )
+end
+
+RepeatResolver.primary_direction = RepeatResolver.resolve_primary_direction
+RepeatResolver.effective_primary_descriptor =
+ RepeatResolver.resolve_primary_direction
+
+function M.explicit_target(stored_target)
+ if stored_target == nil then
+ return domain.TargetValue.code_fallback(0)
+ end
+ return stored_target
+end
+
+local function build_explicit_request(descriptor, stored_target)
+ if descriptor == nil then
+ return domain.ExplicitRepeatRequest.neutral()
+ end
+ local target = M.explicit_target(stored_target)
+ if target.first_code == 0x80 then
+ return domain.ExplicitRepeatRequest.neutral()
+ end
+ return domain.ExplicitRepeatRequest.new(descriptor, target)
+end
+
+function M.build_same_direction_request(stored_descriptor, stored_target)
+ return build_explicit_request(stored_descriptor, stored_target)
+end
+
+M.explicit_same_direction = M.build_same_direction_request
+M.same_direction_request = M.build_same_direction_request
+
+function RepeatResolver:same_direction_request(context)
+ context = domain.ModeContext.from_full_mode(context)
+ local state = resolver_records[self].state
+ return M.build_same_direction_request(
+ state:get_previous_descriptor(context),
+ state:get_previous_target(context)
+ )
+end
+
+RepeatResolver.resolve_explicit_same = RepeatResolver.same_direction_request
+RepeatResolver.explicit_same = RepeatResolver.same_direction_request
+
+function M.build_opposite_direction_request(stored_descriptor, stored_target)
+ if stored_descriptor == nil then
+ return domain.ExplicitRepeatRequest.neutral()
+ end
+ return build_explicit_request(
+ domain.Descriptor.swap(stored_descriptor),
+ stored_target
+ )
+end
+
+M.explicit_opposite_direction = M.build_opposite_direction_request
+M.opposite_direction_request = M.build_opposite_direction_request
+
+function RepeatResolver:opposite_direction_request(context)
+ context = domain.ModeContext.from_full_mode(context)
+ local state = resolver_records[self].state
+ return M.build_opposite_direction_request(
+ state:get_previous_descriptor(context),
+ state:get_previous_target(context)
+ )
+end
+
+RepeatResolver.resolve_explicit_opposite =
+ RepeatResolver.opposite_direction_request
+RepeatResolver.explicit_opposite = RepeatResolver.opposite_direction_request
+
+function M.new(options)
+ return RepeatResolver.new(options)
+end
+
+M.landing = function(context, options)
+ return RepeatResolver.new(options):previous_landing(context)
+end
+
+function M.decide(context, current_position, macro_state, options)
+ return RepeatResolver.new(options):decide(
+ context,
+ current_position,
+ macro_state
+ )
+end
+
+M.eligibility = M.decide
+
+function M.sample_repeat_timeout_ms(options)
+ return RepeatResolver.new(options):sample_repeat_timeout_ms()
+end
+
+function M.evaluate_timeout(options, current_window)
+ return RepeatResolver.new(options):evaluate_timeout(current_window)
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return RepeatResolver.new(options)
+ end,
+})
+
+return M
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
diff --git a/lua/clever_tee/sequence_state.lua b/lua/clever_tee/sequence_state.lua
new file mode 100644
index 0000000..5c66a42
--- /dev/null
+++ b/lua/clever_tee/sequence_state.lua
@@ -0,0 +1,302 @@
+local domain = require("clever_tee.domain")
+
+local M = {}
+local State = {}
+M.State = State
+
+local MAP_FIELDS = {
+ "previous_descriptor",
+ "previous_landing",
+ "first_move",
+ "previous_target",
+}
+
+local MAP_FIELD_SET = {}
+for _, field in ipairs(MAP_FIELDS) do
+ MAP_FIELD_SET[field] = true
+end
+
+local data = {
+ previous_descriptor = {},
+ previous_landing = {},
+ first_move = {},
+ previous_target = {},
+ known_contexts = {},
+ last_input_context = nil,
+ moved_forward = false,
+ moved_forward_initialized = false,
+ migemo_cache = {},
+ repeat_timestamp_ms = 0,
+ highlight_timer = nil,
+ target_overlays = {},
+ temporary_overlays = {},
+ finalizers = {},
+}
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function normalize_context(context, name)
+ if domain.ModeContext.is(context) then
+ return domain.ModeContext.from_full_mode(context.full_mode)
+ end
+ if type(context) ~= "string" or context == "" then
+ fail((name or "context") .. " must be a ModeContext or full mode string", 2)
+ end
+ return domain.ModeContext.from_full_mode(context)
+end
+
+local function copy_map(source)
+ local result = {}
+ for key, value in pairs(source) do
+ result[key] = value
+ end
+ return result
+end
+
+local function copy_resource(resource)
+ local result = {}
+ for key, value in pairs(resource) do
+ result[key] = value
+ end
+ return result
+end
+
+local function copy_resources(resources)
+ local result = {}
+ for index, resource in ipairs(resources) do
+ result[index] = copy_resource(resource)
+ end
+ return result
+end
+
+local function sorted_contexts()
+ local result = {}
+ for context in pairs(data.known_contexts) do
+ result[#result + 1] = context
+ end
+ table.sort(result, function(left, right)
+ return left.key < right.key
+ end)
+ return result
+end
+
+local function context_record(context)
+ return {
+ context = context,
+ previous_descriptor = data.previous_descriptor[context],
+ previous_landing = data.previous_landing[context],
+ first_move = data.first_move[context],
+ previous_target = data.previous_target[context],
+ }
+end
+
+local function context_records()
+ local result = {}
+ for _, context in ipairs(sorted_contexts()) do
+ result[context.key] = context_record(context)
+ end
+ return result
+end
+
+function State:get_previous_descriptor(context)
+ context = normalize_context(context)
+ return data.previous_descriptor[context]
+end
+
+function State:get_previous_landing(context)
+ context = normalize_context(context)
+ return data.previous_landing[context]
+end
+
+function State:get_first_move(context)
+ context = normalize_context(context)
+ return data.first_move[context]
+end
+
+function State:get_previous_target(context)
+ context = normalize_context(context)
+ return data.previous_target[context]
+end
+
+function State:get_context(context)
+ context = normalize_context(context)
+ return context_record(context)
+end
+
+State.context = State.get_context
+
+function State:has_previous_landing(context)
+ return self:get_previous_landing(context) ~= nil
+end
+
+function State:get_migemo(encoding)
+ if type(encoding) ~= "string" or encoding == "" then
+ fail("encoding must be a nonempty string", 2)
+ end
+ return data.migemo_cache[encoding]
+end
+
+function State:target_overlay_identities()
+ local result = {}
+ for index, resource in ipairs(data.target_overlays) do
+ result[index] = resource.identity
+ end
+ return result
+end
+
+function State:temporary_overlay_identities()
+ local result = {}
+ for index, resource in ipairs(data.temporary_overlays) do
+ result[index] = resource.identity
+ end
+ return result
+end
+
+function State:finalizer_identities()
+ local result = {}
+ for index, resource in ipairs(data.finalizers) do
+ result[index] = resource.identity
+ end
+ return result
+end
+
+function State:resources()
+ return {
+ highlight_timer = data.highlight_timer,
+ target_overlays = copy_resources(data.target_overlays),
+ temporary_overlays = copy_resources(data.temporary_overlays),
+ finalizers = copy_resources(data.finalizers),
+ }
+end
+
+function State:snapshot()
+ local snapshot = {
+ contexts = context_records(),
+ last_input_context = data.last_input_context,
+ moved_forward = data.moved_forward,
+ moved_forward_initialized = data.moved_forward_initialized,
+ migemo_cache = copy_map(data.migemo_cache),
+ repeat_timestamp_ms = data.repeat_timestamp_ms,
+ highlight_timer = data.highlight_timer,
+ target_overlays = copy_resources(data.target_overlays),
+ temporary_overlays = copy_resources(data.temporary_overlays),
+ finalizers = copy_resources(data.finalizers),
+ }
+ for _, field in ipairs(MAP_FIELDS) do
+ snapshot[field] = copy_map(data[field])
+ end
+ return snapshot
+end
+
+local function target_to_table(target)
+ return target and target:to_table() or nil
+end
+
+function State:to_table()
+ local contexts = {}
+ for _, context in ipairs(sorted_contexts()) do
+ local record = context_record(context)
+ contexts[context.key] = {
+ previous_descriptor = record.previous_descriptor
+ and record.previous_descriptor.value
+ or nil,
+ previous_landing = record.previous_landing
+ and record.previous_landing:to_table()
+ or nil,
+ first_move = record.first_move,
+ previous_target = target_to_table(record.previous_target),
+ }
+ end
+
+ local cache_keys = {}
+ for encoding in pairs(data.migemo_cache) do
+ cache_keys[#cache_keys + 1] = encoding
+ end
+ table.sort(cache_keys)
+
+ return {
+ contexts = contexts,
+ last_input_context = data.last_input_context and data.last_input_context.key or nil,
+ moved_forward = data.moved_forward,
+ moved_forward_initialized = data.moved_forward_initialized,
+ migemo_cache = cache_keys,
+ repeat_timestamp_ms = data.repeat_timestamp_ms,
+ highlight_timer = data.highlight_timer,
+ target_overlays = self:target_overlay_identities(),
+ temporary_overlays = self:temporary_overlay_identities(),
+ finalizers = self:finalizer_identities(),
+ }
+end
+
+local state
+local state_metatable = {
+ __index = function(_, key)
+ local method = State[key]
+ if method ~= nil then
+ return method
+ end
+ if MAP_FIELD_SET[key] then
+ return copy_map(data[key])
+ end
+ if key == "contexts" then
+ return context_records()
+ end
+ if key == "last_input_context"
+ or key == "moved_forward"
+ or key == "moved_forward_initialized"
+ or key == "highlight_timer"
+ then
+ return data[key]
+ end
+ if key == "repeat_timestamp" or key == "repeat_timestamp_ms" then
+ return data.repeat_timestamp_ms
+ end
+ if key == "migemo_cache" then
+ return copy_map(data.migemo_cache)
+ end
+ if key == "target_overlays"
+ or key == "temporary_overlays"
+ or key == "finalizers"
+ then
+ return copy_resources(data[key])
+ end
+ return nil
+ end,
+ __newindex = function()
+ fail("SequenceState is read-only; use StateTransitions", 2)
+ end,
+ __metatable = "clever_tee.sequence_state.State",
+}
+state = setmetatable({}, state_metatable)
+
+function M.get()
+ return state
+end
+
+function M.new()
+ return state
+end
+
+function M.is(value)
+ return value == state
+end
+
+M.global = state
+
+function M._mutate(target, mutation)
+ if target ~= state then
+ fail("StateTransitions must use the plugin-global SequenceState", 2)
+ end
+ if type(mutation) ~= "function" then
+ fail("state mutation must be a function", 2)
+ end
+ return mutation(data)
+end
+
+function M._normalize_context(context)
+ return normalize_context(context)
+end
+
+return M
diff --git a/lua/clever_tee/state_transitions.lua b/lua/clever_tee/state_transitions.lua
new file mode 100644
index 0000000..377f04f
--- /dev/null
+++ b/lua/clever_tee/state_transitions.lua
@@ -0,0 +1,493 @@
+local domain = require("clever_tee.domain")
+local sequence_state = require("clever_tee.sequence_state")
+
+local M = {}
+local StateTransitions = {}
+StateTransitions.__index = StateTransitions
+M.StateTransitions = StateTransitions
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function is_integer(value)
+ return type(value) == "number"
+ and value > -math.huge
+ and value < math.huge
+ and value == math.floor(value)
+end
+
+local function require_time(value, name)
+ if type(value) ~= "number"
+ or value ~= value
+ or value <= -math.huge
+ or value >= math.huge
+ then
+ fail((name or "time") .. " must be a finite number", 2)
+ end
+ return value
+end
+
+local function require_identity(identity, name)
+ if identity == nil then
+ fail((name or "resource identity") .. " must be active", 2)
+ end
+ return identity
+end
+
+local function require_location(location, name)
+ if location == nil then
+ fail((name or "resource location") .. " must identify its host location", 2)
+ end
+ return location
+end
+
+local function require_target(target)
+ if not domain.TargetValue.is(target) then
+ fail("acquired target must be a TargetValue", 2)
+ end
+ return target
+end
+
+local function require_position(position)
+ return domain.Position.coerce(position)
+end
+
+local function moved_forward_value(direction)
+ if type(direction) == "boolean" then
+ return direction
+ end
+ if domain.Direction.is(direction) then
+ return direction == domain.Direction.FORWARD
+ end
+ if domain.Descriptor.is(direction) then
+ return direction.direction == domain.Direction.FORWARD
+ end
+ if direction == "forward" or direction == "backward" then
+ return direction == "forward"
+ end
+ fail("movement direction must be a Boolean, Direction, or Descriptor", 2)
+end
+
+local function copy_resource(resource)
+ local result = {}
+ for key, value in pairs(resource) do
+ result[key] = value
+ end
+ return result
+end
+
+local function resource_matches(resource, identity, location_field, location)
+ return resource.identity == identity
+ and (location == nil or resource[location_field] == location)
+end
+
+local function add_unique_resource(resources, resource, location_field, field_name)
+ for _, active in ipairs(resources) do
+ if resource_matches(
+ active,
+ resource.identity,
+ location_field,
+ resource[location_field]
+ ) then
+ fail(field_name .. " resource is already active at this host location", 3)
+ end
+ end
+ resources[#resources + 1] = resource
+ return copy_resource(resource)
+end
+
+local function remove_resources(data, field, predicate)
+ local removed = {}
+ local retained = {}
+ for _, resource in ipairs(data[field]) do
+ if predicate(resource) then
+ removed[#removed + 1] = copy_resource(resource)
+ else
+ retained[#retained + 1] = resource
+ end
+ end
+ data[field] = retained
+ return removed
+end
+
+local function clear_target_overlays(data, window)
+ return remove_resources(data, "target_overlays", function(resource)
+ return window == nil or resource.window == window
+ end)
+end
+
+local function clear_temporary_overlays(data, window)
+ return remove_resources(data, "temporary_overlays", function(resource)
+ return window == nil or resource.window == window
+ end)
+end
+
+local function clear_finalizers(data, buffer)
+ return remove_resources(data, "finalizers", function(resource)
+ return buffer == nil or resource.buffer == buffer
+ end)
+end
+
+local function clear_highlight_timer(data)
+ local identity = data.highlight_timer
+ data.highlight_timer = nil
+ return identity
+end
+
+local function clear_all_landings_and_direction(data)
+ data.previous_landing = {}
+ data.moved_forward = false
+end
+
+local function public_reset(data, current_window)
+ local cleanup = {
+ highlight_timer = clear_highlight_timer(data),
+ target_overlays = clear_target_overlays(data, current_window),
+ finalizers = {},
+ temporary_overlays = {},
+ }
+ data.previous_descriptor = {}
+ data.previous_landing = {}
+ data.first_move = {}
+ data.migemo_cache = {}
+ data.repeat_timestamp_ms = 0
+ return cleanup
+end
+
+function StateTransitions.new(state)
+ state = state or sequence_state.get()
+ if not sequence_state.is(state) then
+ fail("StateTransitions requires the plugin-global SequenceState", 2)
+ end
+ return setmetatable({ _state = state }, StateTransitions)
+end
+
+function M.new(state)
+ return StateTransitions.new(state)
+end
+
+setmetatable(M, {
+ __call = function(_, state)
+ return StateTransitions.new(state)
+ end,
+})
+
+function StateTransitions:state()
+ return self._state
+end
+
+function StateTransitions:_mutate(mutation)
+ return sequence_state._mutate(self._state, mutation)
+end
+
+function StateTransitions:BeginAcquisition(context, descriptor)
+ context = sequence_state._normalize_context(context)
+ descriptor = domain.Descriptor.from_string(descriptor)
+ return self:_mutate(function(state)
+ state.known_contexts[context] = true
+ state.previous_descriptor[context] = descriptor
+ state.first_move[context] = true
+ return self._state:get_context(context)
+ end)
+end
+
+function StateTransitions:CommitAcquiredTarget(context, target, time_ms)
+ context = sequence_state._normalize_context(context)
+ target = require_target(target)
+ if time_ms ~= nil then
+ time_ms = require_time(time_ms, "acquisition time")
+ end
+ return self:_mutate(function(state)
+ state.known_contexts[context] = true
+ state.previous_target[context] = target
+ state.last_input_context = context
+ if time_ms ~= nil then
+ state.repeat_timestamp_ms = time_ms
+ end
+ return self._state:get_context(context)
+ end)
+end
+
+function StateTransitions:CommitCommandSuccess(context, destination, direction)
+ context = sequence_state._normalize_context(context)
+ destination = require_position(destination)
+ local forward = moved_forward_value(direction)
+ return self:_mutate(function(state)
+ state.known_contexts[context] = true
+ state.moved_forward = forward
+ state.moved_forward_initialized = true
+ state.previous_landing[context] = destination
+ state.first_move[context] = false
+ return self._state:get_context(context)
+ end)
+end
+
+function StateTransitions:CommitVisualSuccess(context, destination)
+ context = sequence_state._normalize_context(context)
+ destination = require_position(destination)
+ return self:_mutate(function(state)
+ state.known_contexts[context] = true
+ state.previous_landing[context] = destination
+ state.first_move[context] = false
+ return self._state:get_context(context)
+ end)
+end
+
+function StateTransitions:ClearAllLandingsAndDirection()
+ return self:_mutate(function(state)
+ clear_all_landings_and_direction(state)
+ end)
+end
+
+function StateTransitions:SetRepeatTimestamp(time_ms)
+ time_ms = require_time(time_ms, "repeat timestamp")
+ return self:_mutate(function(state)
+ local previous = state.repeat_timestamp_ms
+ state.repeat_timestamp_ms = time_ms
+ return previous
+ end)
+end
+
+function StateTransitions:CacheMigemo(encoding, dictionary)
+ if type(encoding) ~= "string" or encoding == "" then
+ fail("Migemo cache encoding must be a nonempty string", 2)
+ end
+ if dictionary == nil then
+ fail("Migemo cache dictionary must be present", 2)
+ end
+ return self:_mutate(function(state)
+ local previous = state.migemo_cache[encoding]
+ state.migemo_cache[encoding] = dictionary
+ return previous
+ end)
+end
+
+function StateTransitions:RemoveMigemo(encoding)
+ if type(encoding) ~= "string" or encoding == "" then
+ fail("Migemo cache encoding must be a nonempty string", 2)
+ end
+ return self:_mutate(function(state)
+ local previous = state.migemo_cache[encoding]
+ state.migemo_cache[encoding] = nil
+ return previous
+ end)
+end
+
+function StateTransitions:ClearMigemoCache()
+ return self:_mutate(function(state)
+ local previous = state.migemo_cache
+ state.migemo_cache = {}
+ return previous
+ end)
+end
+
+function StateTransitions:SetHighlightTimer(identity)
+ return self:_mutate(function(state)
+ local previous = state.highlight_timer
+ state.highlight_timer = identity
+ return previous
+ end)
+end
+
+function StateTransitions:ClearHighlightTimer(expected_identity)
+ return self:_mutate(function(state)
+ local current = state.highlight_timer
+ if current == nil then
+ return nil, false
+ end
+ if expected_identity ~= nil and current ~= expected_identity then
+ return nil, false
+ end
+ state.highlight_timer = nil
+ return current, true
+ end)
+end
+
+function StateTransitions:AddTargetOverlay(identity, window, anchor_line)
+ if type(identity) == "table" and window == nil and identity.identity ~= nil then
+ local resource = identity
+ identity = resource.identity
+ window = resource.window
+ anchor_line = resource.anchor_line
+ end
+ require_identity(identity, "target overlay identity")
+ require_location(window, "target overlay window")
+ if anchor_line ~= nil and (not is_integer(anchor_line) or anchor_line < 1) then
+ fail("target overlay anchor_line must be a positive integer", 2)
+ end
+ local resource = {
+ identity = identity,
+ window = window,
+ group = "CleverTeeChar",
+ anchor_line = anchor_line,
+ }
+ return self:_mutate(function(state)
+ return add_unique_resource(
+ state.target_overlays,
+ resource,
+ "window",
+ "target overlay"
+ )
+ end)
+end
+
+function StateTransitions:RemoveTargetOverlay(identity, window)
+ require_identity(identity, "target overlay identity")
+ return self:_mutate(function(state)
+ return remove_resources(state, "target_overlays", function(resource)
+ return resource_matches(resource, identity, "window", window)
+ end)
+ end)
+end
+
+function StateTransitions:ClearTargetOverlays(window)
+ return self:_mutate(function(state)
+ return clear_target_overlays(state, window)
+ end)
+end
+
+function StateTransitions:AddTemporaryOverlay(identity, window, group)
+ if type(identity) == "table" and window == nil and identity.identity ~= nil then
+ local resource = identity
+ identity = resource.identity
+ window = resource.window
+ group = resource.group
+ end
+ require_identity(identity, "temporary overlay identity")
+ require_location(window, "temporary overlay window")
+ group = group or "CleverTeeCursor"
+ if group ~= "CleverTeeCursor" and group ~= "CleverTeeDirect" then
+ fail("temporary overlay group must be CleverTeeCursor or CleverTeeDirect", 2)
+ end
+ local resource = {
+ identity = identity,
+ window = window,
+ group = group,
+ }
+ return self:_mutate(function(state)
+ return add_unique_resource(
+ state.temporary_overlays,
+ resource,
+ "window",
+ "temporary overlay"
+ )
+ end)
+end
+
+function StateTransitions:RemoveTemporaryOverlay(identity, window)
+ require_identity(identity, "temporary overlay identity")
+ return self:_mutate(function(state)
+ return remove_resources(state, "temporary_overlays", function(resource)
+ return resource_matches(resource, identity, "window", window)
+ end)
+ end)
+end
+
+function StateTransitions:ClearTemporaryOverlays(window)
+ return self:_mutate(function(state)
+ return clear_temporary_overlays(state, window)
+ end)
+end
+
+function StateTransitions:AddFinalizer(identity, buffer)
+ if type(identity) == "table" and buffer == nil and identity.identity ~= nil then
+ local resource = identity
+ identity = resource.identity
+ buffer = resource.buffer
+ end
+ require_identity(identity, "finalizer identity")
+ require_location(buffer, "finalizer buffer")
+ local resource = {
+ identity = identity,
+ buffer = buffer,
+ }
+ return self:_mutate(function(state)
+ return add_unique_resource(state.finalizers, resource, "buffer", "finalizer")
+ end)
+end
+
+function StateTransitions:RemoveFinalizer(identity, buffer)
+ require_identity(identity, "finalizer identity")
+ return self:_mutate(function(state)
+ return remove_resources(state, "finalizers", function(resource)
+ return resource_matches(resource, identity, "buffer", buffer)
+ end)
+ end)
+end
+
+function StateTransitions:ClearFinalizers(buffer)
+ return self:_mutate(function(state)
+ return clear_finalizers(state, buffer)
+ end)
+end
+
+function StateTransitions:ClearTargetFeedback(current_window)
+ return self:_mutate(function(state)
+ return {
+ highlight_timer = clear_highlight_timer(state),
+ target_overlays = clear_target_overlays(state, current_window),
+ finalizers = {},
+ temporary_overlays = {},
+ }
+ end)
+end
+
+function StateTransitions:FullFinalization(current_window)
+ return self:_mutate(function(state)
+ local cleanup = {
+ highlight_timer = clear_highlight_timer(state),
+ target_overlays = clear_target_overlays(state, current_window),
+ finalizers = clear_finalizers(state),
+ temporary_overlays = {},
+ }
+ clear_all_landings_and_direction(state)
+ return cleanup
+ end)
+end
+
+function StateTransitions:PublicReset(current_window)
+ return self:_mutate(function(state)
+ return public_reset(state, current_window)
+ end)
+end
+
+function StateTransitions:DiagnosticFullReset(current_window)
+ return self:_mutate(function(state)
+ local cleanup = public_reset(state, current_window)
+ cleanup.finalizers = clear_finalizers(state)
+ state.previous_target = {}
+ state.last_input_context = nil
+ state.moved_forward = false
+ state.moved_forward_initialized = false
+ return cleanup
+ end)
+end
+
+StateTransitions.begin_acquisition = StateTransitions.BeginAcquisition
+StateTransitions.commit_acquired_target = StateTransitions.CommitAcquiredTarget
+StateTransitions.commit_command_success = StateTransitions.CommitCommandSuccess
+StateTransitions.commit_visual_success = StateTransitions.CommitVisualSuccess
+StateTransitions.clear_all_landings_and_direction =
+ StateTransitions.ClearAllLandingsAndDirection
+StateTransitions.set_repeat_timestamp = StateTransitions.SetRepeatTimestamp
+StateTransitions.cache_migemo = StateTransitions.CacheMigemo
+StateTransitions.remove_migemo = StateTransitions.RemoveMigemo
+StateTransitions.clear_migemo_cache = StateTransitions.ClearMigemoCache
+StateTransitions.set_highlight_timer = StateTransitions.SetHighlightTimer
+StateTransitions.clear_highlight_timer = StateTransitions.ClearHighlightTimer
+StateTransitions.add_target_overlay = StateTransitions.AddTargetOverlay
+StateTransitions.remove_target_overlay = StateTransitions.RemoveTargetOverlay
+StateTransitions.clear_target_overlays = StateTransitions.ClearTargetOverlays
+StateTransitions.add_temporary_overlay = StateTransitions.AddTemporaryOverlay
+StateTransitions.remove_temporary_overlay = StateTransitions.RemoveTemporaryOverlay
+StateTransitions.clear_temporary_overlays = StateTransitions.ClearTemporaryOverlays
+StateTransitions.add_finalizer = StateTransitions.AddFinalizer
+StateTransitions.remove_finalizer = StateTransitions.RemoveFinalizer
+StateTransitions.clear_finalizers = StateTransitions.ClearFinalizers
+StateTransitions.clear_target_feedback = StateTransitions.ClearTargetFeedback
+StateTransitions.full_finalization = StateTransitions.FullFinalization
+StateTransitions.public_reset = StateTransitions.PublicReset
+StateTransitions.diagnostic_full_reset = StateTransitions.DiagnosticFullReset
+
+return M
diff --git a/lua/clever_tee/target_plan.lua b/lua/clever_tee/target_plan.lua
new file mode 100644
index 0000000..a6c2d68
--- /dev/null
+++ b/lua/clever_tee/target_plan.lua
@@ -0,0 +1,541 @@
+local case_policy = require("clever_tee.case_policy")
+local domain = require("clever_tee.domain")
+local migemo_catalog = require("clever_tee.migemo_catalog")
+local text_topology = require("clever_tee.text_topology")
+
+local M = {}
+local TargetPlanFactory = {}
+M.TargetPlanFactory = TargetPlanFactory
+
+M.SYMBOLS = "!\"#$%&'()=~|\\-^@`[]{};:+*<>,.?_/"
+
+local SYMBOL_CHARACTERS = {}
+local SYMBOL_SET = {}
+for index = 1, #M.SYMBOLS do
+ local character = M.SYMBOLS:sub(index, index)
+ SYMBOL_CHARACTERS[index] = character
+ SYMBOL_SET[character] = true
+end
+
+local factory_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function require_target(target)
+ if not domain.TargetValue.is(target) then
+ fail("target plan target must be a TargetValue", 2)
+ end
+ return target
+end
+
+local function require_boolean(value, name)
+ if type(value) ~= "boolean" then
+ fail("match policy " .. name .. " must be a Boolean", 2)
+ end
+ return value
+end
+
+local function require_string(value, name)
+ if type(value) ~= "string" then
+ fail("match policy " .. name .. " must be a string", 2)
+ end
+ return value
+end
+
+local function copy_list(values)
+ local result = {}
+ for index = 1, #values do
+ result[index] = values[index]
+ end
+ return result
+end
+
+function M.symbol_characters()
+ return copy_list(SYMBOL_CHARACTERS)
+end
+
+function M.is_symbol(character)
+ return type(character) == "string" and SYMBOL_SET[character] == true
+end
+
+local function require_character_list(characters, source)
+ if type(characters) ~= "table" then
+ fail("editor character splitter must return a list", 3)
+ end
+
+ local result = {}
+ local item_count = 0
+ for key, character in pairs(characters) do
+ if type(key) ~= "number"
+ or key ~= math.floor(key)
+ or key < 1
+ or key > #characters
+ or type(character) ~= "string"
+ or character == ""
+ then
+ fail("editor character splitter must return a list of nonempty strings", 3)
+ end
+ result[key] = character
+ item_count = item_count + 1
+ end
+ if item_count ~= #characters or table.concat(result) ~= source then
+ fail("editor character splitter must preserve the configured trigger string", 3)
+ end
+ return result
+end
+
+function M.parse_trigger_characters(value, splitter)
+ if type(value) ~= "string" then
+ fail("chars_match_any_signs must be a string", 2)
+ end
+ splitter = splitter or text_topology.split_editor_characters
+ if type(splitter) ~= "function" then
+ fail("editor character splitter must be a function", 2)
+ end
+ return require_character_list(splitter(value), value)
+end
+
+local function trigger_set(value, splitter)
+ local result = {}
+ for _, character in ipairs(M.parse_trigger_characters(value, splitter)) do
+ result[character] = true
+ end
+ return result
+end
+
+local function false_matcher()
+ return false
+end
+
+local function symbol_matcher(candidate_character)
+ return M.is_symbol(candidate_character)
+end
+
+local function normalize_factory_options(options)
+ if options == nil then
+ return {}
+ end
+ if type(options) == "function" then
+ return { lowercase = options }
+ end
+ if type(options) ~= "table" then
+ fail("TargetPlanFactory options must be a table", 3)
+ end
+ if type(options.sample_match) == "function"
+ and options.policy == nil
+ and options.policy_service == nil
+ and options.case_resolver == nil
+ and options.lowercase == nil
+ and options.splitter == nil
+ and options.split_editor_characters == nil
+ then
+ return { policy = options }
+ end
+ return options
+end
+
+local function require_policy_service(service)
+ if service ~= nil and (type(service) ~= "table"
+ or type(service.sample_match) ~= "function")
+ then
+ fail("TargetPlanFactory policy must provide sample_match", 3)
+ end
+ return service
+end
+
+local function require_case_resolver(resolver, options)
+ if resolver == nil then
+ return case_policy.new({
+ lowercase = options.lowercase,
+ })
+ end
+ if type(resolver) ~= "table"
+ or type(resolver.resolve) ~= "function"
+ or type(resolver.comparator) ~= "function"
+ then
+ fail("TargetPlanFactory case resolver is invalid", 3)
+ end
+ return resolver
+end
+
+local function require_splitter(splitter)
+ splitter = splitter or text_topology.split_editor_characters
+ if type(splitter) ~= "function" then
+ fail("TargetPlanFactory editor character splitter must be a function", 3)
+ end
+ return splitter
+end
+
+local function require_migemo_catalog(catalog)
+ if catalog ~= nil and not migemo_catalog.MigemoCatalog.is(catalog) then
+ fail("TargetPlanFactory migemo_catalog must be a MigemoCatalog", 3)
+ end
+ return catalog
+end
+
+local factory_metatable = {
+ __index = TargetPlanFactory,
+ __newindex = function()
+ fail("TargetPlanFactory values are immutable", 2)
+ end,
+ __tostring = function()
+ return "target-plan-factory"
+ end,
+ __metatable = "clever_tee.target_plan.TargetPlanFactory",
+}
+
+function TargetPlanFactory.new(options)
+ if TargetPlanFactory.is(options) then
+ return options
+ end
+ options = normalize_factory_options(options)
+ local factory = setmetatable({}, factory_metatable)
+ factory_records[factory] = {
+ policy = require_policy_service(options.policy or options.policy_service),
+ case_resolver = require_case_resolver(options.case_resolver, options),
+ splitter = require_splitter(
+ options.split_editor_characters or options.splitter
+ ),
+ migemo_catalog = require_migemo_catalog(
+ options.migemo_catalog or options.catalog
+ ),
+ }
+ return factory
+end
+
+function TargetPlanFactory.is(value)
+ return type(value) == "table" and factory_records[value] ~= nil
+end
+
+function M.new(options)
+ return TargetPlanFactory.new(options)
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return TargetPlanFactory.new(options)
+ end,
+})
+
+local function default_match_policy()
+ return {
+ ignore_case = false,
+ smart_case = false,
+ use_migemo = false,
+ chars_match_any_signs = "",
+ }
+end
+
+local function sampled_policy(factory, target, match_policy)
+ local service
+ if match_policy == nil then
+ service = factory_records[factory].policy
+ if service == nil then
+ return default_match_policy()
+ end
+ elseif type(match_policy) == "table"
+ and type(match_policy.sample_match) == "function"
+ then
+ service = match_policy
+ end
+
+ if service ~= nil then
+ match_policy = service:sample_match(target)
+ end
+ if type(match_policy) ~= "table" then
+ fail("target match policy must be a table", 3)
+ end
+
+ local use_migemo = match_policy.use_migemo
+ if use_migemo == nil then
+ use_migemo = false
+ end
+
+ return {
+ ignore_case = require_boolean(match_policy.ignore_case, "ignore_case"),
+ smart_case = require_boolean(match_policy.smart_case, "smart_case"),
+ use_migemo = require_boolean(use_migemo, "use_migemo"),
+ chars_match_any_signs = require_string(
+ match_policy.chars_match_any_signs,
+ "chars_match_any_signs"
+ ),
+ }
+end
+
+local function new_plan(target, kind, case_mode, matcher)
+ return domain.TargetPlan.new({
+ target = target,
+ kind = kind,
+ case_mode = case_mode,
+ matcher = matcher,
+ })
+end
+
+local function is_ascii_alphabetic(character)
+ if type(character) ~= "string" or #character ~= 1 then
+ return false
+ end
+ local code = character:byte(1)
+ return (code >= string.byte("a") and code <= string.byte("z"))
+ or (code >= string.byte("A") and code <= string.byte("Z"))
+end
+
+M.is_ascii_alphabetic = is_ascii_alphabetic
+
+local function context_field(context, primary, alternate)
+ local value = context[primary]
+ if value == nil and alternate ~= nil then
+ value = context[alternate]
+ end
+ return value
+end
+
+local function context_table(match_policy, search_context)
+ if text_topology.TextView.is(search_context) then
+ return { text_view = search_context }
+ end
+ if search_context ~= nil and type(search_context) ~= "table" then
+ fail("target search context must be a table or TextView", 3)
+ end
+
+ local context = search_context or {}
+ if search_context == nil and type(match_policy) == "table" then
+ if match_policy.text_view ~= nil
+ or match_policy.view ~= nil
+ or match_policy.search_scope ~= nil
+ or match_policy.scope ~= nil
+ or match_policy.origin ~= nil
+ or match_policy.current_line ~= nil
+ or match_policy.effective_encoding ~= nil
+ or match_policy.encoding ~= nil
+ then
+ context = match_policy
+ end
+ end
+ return context
+end
+
+local function active_policy_service(factory, match_policy)
+ if type(match_policy) == "table"
+ and type(match_policy.sample_match) == "function"
+ then
+ return match_policy
+ end
+ return factory_records[factory].policy
+end
+
+local function search_scope(factory, match_policy, context)
+ local value = context_field(context, "search_scope", "scope")
+ if value == nil and type(match_policy) == "table" then
+ value = match_policy.search_scope
+ if value == nil and match_policy.search_current_line_only ~= nil then
+ value = match_policy.search_current_line_only
+ and domain.SearchScope.CURRENT_LINE
+ or domain.SearchScope.BUFFER
+ end
+ end
+ if value == nil then
+ local service = active_policy_service(factory, match_policy)
+ if service ~= nil and type(service.sample_search) == "function" then
+ value = service:sample_search().search_scope
+ end
+ end
+ if value == nil then
+ return domain.SearchScope.BUFFER
+ end
+ if value == "line" then
+ value = domain.SearchScope.CURRENT_LINE
+ end
+ return domain.SearchScope.from_string(value)
+end
+
+local function migemo_search_context(factory, match_policy, search_context)
+ local context = context_table(match_policy, search_context)
+ local view = context_field(context, "text_view", "view")
+ if not text_topology.TextView.is(view) then
+ fail("Migemo target planning requires a TextView", 3)
+ end
+
+ local scope = search_scope(factory, match_policy, context)
+ local origin = context.origin
+ if origin == nil then
+ origin = context.current_line
+ end
+ if scope == domain.SearchScope.CURRENT_LINE and origin == nil then
+ fail("current-line Migemo planning requires an origin line", 3)
+ end
+
+ local line_number
+ if scope == domain.SearchScope.CURRENT_LINE then
+ line_number = type(origin) == "number"
+ and origin
+ or domain.Position.coerce(origin).line
+ end
+
+ local encoding = context_field(context, "effective_encoding", "encoding")
+ or view.requested_encoding
+ or view.effective_encoding
+ return {
+ view = view,
+ scope = scope,
+ origin = origin,
+ line_number = line_number,
+ encoding = encoding,
+ bounds = view:match_start_bounds(scope, origin),
+ }
+end
+
+local function selected_migemo_catalog(factory, match_policy)
+ local record = factory_records[factory]
+ if record.migemo_catalog == nil then
+ record.migemo_catalog = migemo_catalog.new({
+ policy = active_policy_service(factory, match_policy),
+ })
+ end
+ return record.migemo_catalog
+end
+
+local function migemo_matcher(
+ target_character,
+ case_mode,
+ resolver,
+ dictionary,
+ context
+)
+ local target_equal = resolver:comparator(target_character, case_mode)
+ local assertion = dictionary:predicate(target_character, case_mode)
+
+ return function(candidate_character, candidate_position, candidate_view)
+ if candidate_position == nil then
+ fail("Migemo matching requires a candidate Position", 2)
+ end
+ local position = domain.Position.coerce(candidate_position)
+ local view = candidate_view or context.view
+ if not text_topology.TextView.is(view) then
+ fail("Migemo matching requires a TextView", 2)
+ end
+ if not context.bounds:contains(position)
+ or not view:is_character_start(position)
+ then
+ return false
+ end
+
+ local actual_character = view:character_at(position)
+ if candidate_character ~= actual_character then
+ return false
+ end
+ if is_ascii_alphabetic(actual_character)
+ and not target_equal(actual_character)
+ then
+ return false
+ end
+ return assertion(view:text_suffix(position))
+ end
+end
+
+function TargetPlanFactory:build(target, match_policy, search_context)
+ target = require_target(target)
+ local record = factory_records[self]
+ local sampled = sampled_policy(self, target, match_policy)
+ local case_mode = record.case_resolver:resolve(
+ target,
+ sampled.ignore_case,
+ sampled.smart_case
+ )
+
+ if target.first_code == 0x80 then
+ return new_plan(
+ target,
+ domain.TargetPlanKind.EMPTY,
+ case_mode,
+ false_matcher
+ )
+ end
+
+ if sampled.use_migemo and is_ascii_alphabetic(target.value) then
+ local context = migemo_search_context(self, match_policy, search_context)
+ local active = context.scope == domain.SearchScope.BUFFER
+ or context.view:line_byte_length(context.line_number)
+ > context.view:line_character_count(context.line_number)
+ if active then
+ local dictionary = selected_migemo_catalog(self, match_policy):get(
+ context.encoding,
+ active_policy_service(self, match_policy)
+ )
+ return new_plan(
+ target,
+ domain.TargetPlanKind.MIGEMO,
+ case_mode,
+ migemo_matcher(
+ target.value,
+ case_mode,
+ record.case_resolver,
+ dictionary,
+ context
+ )
+ )
+ end
+ end
+
+ local triggers = trigger_set(sampled.chars_match_any_signs, record.splitter)
+ if triggers[target.value] then
+ return new_plan(
+ target,
+ domain.TargetPlanKind.SYMBOL,
+ case_mode,
+ symbol_matcher
+ )
+ end
+
+ local kind = target.value == "\\"
+ and domain.TargetPlanKind.BACKSLASH
+ or domain.TargetPlanKind.LITERAL
+ return new_plan(
+ target,
+ kind,
+ case_mode,
+ record.case_resolver:comparator(target.value, case_mode)
+ )
+end
+
+local function is_search_context(value)
+ return text_topology.TextView.is(value)
+ or (type(value) == "table" and (
+ value.text_view ~= nil
+ or value.view ~= nil
+ or value.search_scope ~= nil
+ or value.scope ~= nil
+ or value.origin ~= nil
+ or value.current_line ~= nil
+ or value.effective_encoding ~= nil
+ or value.encoding ~= nil
+ ))
+end
+
+function M.build(target, match_policy, options, search_context)
+ if search_context == nil and is_search_context(options) then
+ search_context = options
+ options = nil
+ end
+ return TargetPlanFactory.new(options):build(
+ target,
+ match_policy,
+ search_context
+ )
+end
+
+function M.build_for_view(target, view, origin, scope, match_policy, options)
+ return TargetPlanFactory.new(options):build(target, match_policy, {
+ text_view = view,
+ origin = origin,
+ search_scope = scope,
+ })
+end
+
+M.create = M.build
+M.create_plan = M.build
+M.SYMBOL_SET_STRING = M.SYMBOLS
+
+return M
diff --git a/lua/clever_tee/testing/memory_host.lua b/lua/clever_tee/testing/memory_host.lua
new file mode 100644
index 0000000..4479da7
--- /dev/null
+++ b/lua/clever_tee/testing/memory_host.lua
@@ -0,0 +1,1075 @@
+local capabilities = require("clever_tee.capabilities")
+local domain = require("clever_tee.domain")
+local text_topology = require("clever_tee.text_topology")
+
+local M = {}
+local MemoryHost = {}
+MemoryHost.__index = MemoryHost
+M.MemoryHost = MemoryHost
+local unpack_values = table.unpack or unpack
+
+local function is_integer(value)
+ return type(value) == "number"
+ and value > -math.huge
+ and value < math.huge
+ and value == math.floor(value)
+end
+
+local function copy(value, seen)
+ if type(value) ~= "table" or domain.type_of(value) ~= nil then
+ return value
+ end
+ seen = seen or {}
+ if seen[value] ~= nil then
+ return seen[value]
+ end
+ local result = {}
+ seen[value] = result
+ for key, item in pairs(value) do
+ result[copy(key, seen)] = copy(item, seen)
+ end
+ return result
+end
+
+local function list_copy(values)
+ local result = {}
+ for index = 1, #values do
+ result[index] = values[index]
+ end
+ return result
+end
+
+local function text_snapshot(value)
+ if domain.TextSnapshot.is(value) then
+ return value
+ end
+ if type(value) == "table" and value.lines ~= nil then
+ value = value.lines
+ end
+ return domain.TextSnapshot.new(value)
+end
+
+local function selection_value(value)
+ if value == nil then
+ return domain.Selection.inactive()
+ end
+ return domain.Selection.new(value)
+end
+
+local function macro_state(value)
+ if type(value) == "table" and not domain.MacroState.is(value) then
+ value = value.register
+ end
+ return domain.MacroState.new(value)
+end
+
+local function fold_state(options)
+ if domain.FoldState.is(options.fold_state) then
+ return options.fold_state
+ end
+ return domain.FoldState.new(
+ options.fold_open_policy or {},
+ options.closed_fold_levels or 0
+ )
+end
+
+local function input_packet(value)
+ return domain.InputPacket.from_table(value)
+end
+
+local function normalize_event_names(event_names)
+ if type(event_names) == "string" then
+ event_names = { event_names }
+ end
+ if type(event_names) ~= "table" or #event_names < 1 then
+ error("event names must be a nonempty list", 3)
+ end
+ local result = {}
+ local seen = {}
+ for index = 1, #event_names do
+ local name = event_names[index]
+ if type(name) ~= "string" or name == "" then
+ error("event name must be a nonempty string", 3)
+ end
+ if not seen[name] then
+ seen[name] = true
+ result[#result + 1] = name
+ end
+ end
+ return result, seen
+end
+
+local function normalize_modes(modes)
+ if type(modes) == "string" then
+ modes = { modes }
+ end
+ if type(modes) ~= "table" or #modes < 1 then
+ error("mapping modes must be a nonempty list", 3)
+ end
+ local result = {}
+ for index = 1, #modes do
+ if type(modes[index]) ~= "string" or modes[index] == "" then
+ error("mapping mode must be a nonempty string", 3)
+ end
+ result[index] = modes[index]
+ end
+ return result
+end
+
+function MemoryHost.new(options)
+ options = options or {}
+ if type(options) ~= "table" then
+ error("memory host options must be a table", 2)
+ end
+
+ local cursor_presentation_support = options.cursor_presentation_support
+ if cursor_presentation_support == nil then
+ cursor_presentation_support = options.cmdline_cursor_support
+ end
+ if cursor_presentation_support == nil then
+ cursor_presentation_support = true
+ end
+
+ local raw_mode = options.mode or "n"
+ if domain.ModeContext.is(raw_mode) then
+ raw_mode = raw_mode.full_mode
+ end
+ domain.ModeContext.from_full_mode(raw_mode)
+
+ local self = setmetatable({
+ _text = text_snapshot(options.text or options.buffer_lines or { "" }),
+ _buffer = options.buffer or "buffer-1",
+ _cursor = domain.Position.coerce(options.cursor or { line = 1, byte_column = 1 }),
+ _window = options.window or "window-1",
+ _mode = raw_mode,
+ _selection = selection_value(options.selection),
+ _count = domain.Count.new(options.count),
+ _configuration = copy(options.configuration or {}),
+ _encoding = options.encoding or options.effective_encoding or "utf-8",
+ _lowercase = options.lowercase or vim.fn.tolower,
+ _macro_state = macro_state(options.macro_state or options.macro_register),
+ _fold_state = fold_state(options),
+ _pending_operator = options.pending_operator,
+ _time_values = list_copy(options.time_values_ms or {}),
+ _time_index = 1,
+ _current_time = options.time_ms or 0,
+ _input_packets = {},
+ _input_index = 1,
+ _timer_support = options.timer_support ~= false,
+ _cursor_presentation_support = cursor_presentation_support,
+ _cursor_presentation = copy(options.cursor_presentation or {
+ hidden = false,
+ }),
+ _emit_movement_events = options.emit_movement_events ~= false,
+ _operator_inclusive = false,
+ _operations = {},
+ _prompts = {},
+ _redraws = {},
+ _diagnostics = {},
+ _highlight_groups = copy(options.highlight_groups or {}),
+ _highlights = {},
+ _timers = {},
+ _event_registrations = {},
+ _event_registration_order = {},
+ _actions = {},
+ _mappings = {},
+ _cursor_leases = {},
+ _dot_repeat = nil,
+ _identity_counters = {},
+ }, MemoryHost)
+
+ for index, packet in ipairs(options.input_packets or {}) do
+ self._input_packets[index] = input_packet(packet)
+ end
+
+ self._event_queue = capabilities.EventQueue.new(function(name, payload)
+ self:_deliver_event_now(name, payload)
+ end)
+
+ return capabilities.assert_implements(self)
+end
+
+function M.new(options)
+ return MemoryHost.new(options)
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return MemoryHost.new(options)
+ end,
+})
+
+function MemoryHost:_next_identity(prefix)
+ local next_value = (self._identity_counters[prefix] or 0) + 1
+ self._identity_counters[prefix] = next_value
+ return prefix .. "-" .. tostring(next_value)
+end
+
+function MemoryHost:_record(operation, details)
+ local entry = { operation = operation }
+ for key, value in pairs(details or {}) do
+ entry[key] = copy(value)
+ end
+ self._operations[#self._operations + 1] = entry
+end
+
+function MemoryHost:operations()
+ return copy(self._operations)
+end
+
+function MemoryHost:clear_operations()
+ self._operations = {}
+end
+
+function MemoryHost:read_text()
+ self:_record("read_text")
+ return self._text
+end
+
+function MemoryHost:read_cursor()
+ self:_record("read_cursor")
+ return self._cursor
+end
+
+function MemoryHost:read_buffer()
+ self:_record("read_buffer", { buffer = self._buffer })
+ return self._buffer
+end
+
+function MemoryHost:read_window()
+ self:_record("read_window", { window = self._window })
+ return self._window
+end
+
+function MemoryHost:read_mode()
+ self:_record("read_mode", { mode = self._mode })
+ return self._mode
+end
+
+function MemoryHost:read_mode_context()
+ return domain.ModeContext.from_full_mode(self:read_mode())
+end
+
+function MemoryHost:read_pending_operator()
+ self:_record("read_pending_operator", { operator = self._pending_operator })
+ return self._pending_operator
+end
+
+function MemoryHost:read_selection()
+ self:_record("read_selection")
+ return self._selection
+end
+
+function MemoryHost:read_count()
+ self:_record("read_count", { count = self._count.value })
+ return self._count
+end
+
+function MemoryHost:configuration_present(name)
+ if type(name) ~= "string" or name == "" then
+ error("configuration name must be a nonempty string", 2)
+ end
+ local present = self._configuration[name] ~= nil
+ self:_record("configuration_present", { name = name, present = present })
+ return present
+end
+
+function MemoryHost:read_configuration(name)
+ if type(name) ~= "string" or name == "" then
+ error("configuration name must be a nonempty string", 2)
+ end
+ local value = copy(self._configuration[name])
+ self:_record("read_configuration", { name = name, value = value })
+ return value
+end
+
+function MemoryHost:write_configuration(name, value)
+ if type(name) ~= "string" or name == "" then
+ error("configuration name must be a nonempty string", 2)
+ end
+ self._configuration[name] = copy(value)
+ self:_record("write_configuration", { name = name, value = value })
+end
+
+function MemoryHost:read_encoding()
+ self:_record("read_encoding", { encoding = self._encoding })
+ return self._encoding
+end
+
+function MemoryHost:lowercase(value)
+ if type(value) ~= "string" then
+ error("case conversion value must be a string", 2)
+ end
+ local result = self._lowercase(value)
+ if type(result) ~= "string" then
+ error("case converter must return a string", 2)
+ end
+ self:_record("lowercase", { value = value, result = result })
+ return result
+end
+
+function MemoryHost:read_macro_state()
+ self:_record("read_macro_state", { executing = self._macro_state.executing })
+ return self._macro_state
+end
+
+function MemoryHost:read_fold_state()
+ self:_record("read_fold_state", { closed_levels = self._fold_state.closed_levels })
+ return self._fold_state
+end
+
+function MemoryHost:read_time_ms()
+ local value = self._time_values[self._time_index]
+ if value ~= nil then
+ self._time_index = self._time_index + 1
+ self._current_time = value
+ else
+ value = self._current_time
+ end
+ if type(value) ~= "number" then
+ error("time value must be a number", 2)
+ end
+ self:_record("read_time_ms", { value = value })
+ return value
+end
+
+function MemoryHost:set_text(value)
+ self._text = text_snapshot(value)
+end
+
+function MemoryHost:set_cursor(position)
+ self._cursor = domain.Position.coerce(position)
+end
+
+function MemoryHost:set_buffer(buffer)
+ if buffer == nil then
+ error("buffer identity must be present", 2)
+ end
+ self._buffer = buffer
+end
+
+function MemoryHost:set_window(window)
+ if window == nil then
+ error("window identity must be present", 2)
+ end
+ self._window = window
+end
+
+function MemoryHost:set_mode(full_mode)
+ if domain.ModeContext.is(full_mode) then
+ full_mode = full_mode.full_mode
+ end
+ domain.ModeContext.from_full_mode(full_mode)
+ self._mode = full_mode
+end
+
+function MemoryHost:set_selection(selection)
+ self._selection = selection_value(selection)
+end
+
+function MemoryHost:set_count(count)
+ self._count = domain.Count.new(count)
+end
+
+function MemoryHost:set_configuration(name, value)
+ if type(name) ~= "string" or name == "" then
+ error("configuration name must be a nonempty string", 2)
+ end
+ self._configuration[name] = copy(value)
+end
+
+function MemoryHost:unset_configuration(name)
+ self._configuration[name] = nil
+end
+
+function MemoryHost:set_encoding(encoding)
+ if type(encoding) ~= "string" or encoding == "" then
+ error("encoding must be a nonempty string", 2)
+ end
+ self._encoding = encoding
+end
+
+function MemoryHost:set_macro_state(state)
+ self._macro_state = macro_state(state)
+end
+
+function MemoryHost:set_fold_state(state, closed_levels)
+ if domain.FoldState.is(state) then
+ self._fold_state = state
+ else
+ self._fold_state = domain.FoldState.new(state, closed_levels)
+ end
+end
+
+function MemoryHost:set_pending_operator(operator)
+ self._pending_operator = operator
+end
+
+function MemoryHost:push_time_ms(value)
+ if type(value) ~= "number" then
+ error("time value must be a number", 2)
+ end
+ self._time_values[#self._time_values + 1] = value
+end
+
+function MemoryHost:push_input(packet)
+ self._input_packets[#self._input_packets + 1] = input_packet(packet)
+end
+
+function MemoryHost:_emit_movement_event(previous)
+ if self._emit_movement_events and not domain.Position.equal(previous, self._cursor) then
+ self:deliver_event("CursorMoved", {
+ cursor = self._cursor,
+ })
+ end
+end
+
+local function motion_descriptor(motion)
+ if domain.Descriptor.is(motion) then
+ return motion
+ end
+ if type(motion) == "table" and motion.descriptor ~= nil then
+ return domain.Descriptor.from_string(motion.descriptor)
+ end
+ return nil
+end
+
+local function character_boundary(view, position)
+ if view:line_is_empty(position.line) then
+ return 1
+ end
+ return view:character_index_for_position(position)
+end
+
+local function character_lines(snapshot)
+ local result = {}
+ for line_number, line in ipairs(snapshot:lines()) do
+ result[line_number] = text_topology.split_editor_characters(line)
+ end
+ return result
+end
+
+local function joined_range(characters, first, last)
+ local result = {}
+ for index = first, last do
+ result[#result + 1] = characters[index]
+ end
+ return table.concat(result)
+end
+
+local function delete_character_range(
+ snapshot,
+ start_line,
+ start_index,
+ finish_line,
+ finish_index
+)
+ local source = character_lines(snapshot)
+ local lines = snapshot:lines()
+ local result = {}
+
+ for line_number = 1, start_line - 1 do
+ result[#result + 1] = lines[line_number]
+ end
+
+ local prefix = joined_range(source[start_line], 1, start_index - 1)
+ if start_line == finish_line then
+ result[#result + 1] = prefix
+ .. joined_range(
+ source[start_line],
+ finish_index,
+ #source[start_line]
+ )
+ else
+ result[#result + 1] = prefix
+ .. joined_range(
+ source[finish_line],
+ finish_index,
+ #source[finish_line]
+ )
+ end
+
+ for line_number = finish_line + 1, #lines do
+ result[#result + 1] = lines[line_number]
+ end
+ return domain.TextSnapshot.new(result)
+end
+
+local function normalized_cursor(snapshot, encoding, position)
+ local line_number = math.min(position.line, snapshot.line_count)
+ local view = text_topology.new(snapshot, encoding)
+ return view:normalize_endpoint(line_number, position.byte_column)
+end
+
+function MemoryHost:_apply_pending_delete(origin, destination, descriptor)
+ if self._pending_operator ~= "delete" and self._pending_operator ~= "d" then
+ return false
+ end
+
+ local view = text_topology.new(self._text, self._encoding)
+ local origin_index = character_boundary(view, origin)
+ local destination_index = character_boundary(view, destination)
+ local start_line
+ local start_index
+ local finish_line
+ local finish_index
+ local final_cursor
+
+ if descriptor.direction == domain.Direction.FORWARD then
+ start_line = origin.line
+ start_index = origin_index
+ finish_line = destination.line
+ finish_index = destination_index + (self._operator_inclusive and 1 or 0)
+ final_cursor = origin
+ elseif descriptor.family == domain.Family.FIND then
+ start_line = destination.line
+ start_index = destination_index + 1
+ finish_line = origin.line
+ finish_index = origin_index + 1
+ final_cursor = destination
+ else
+ start_line = destination.line
+ start_index = destination_index
+ finish_line = origin.line
+ finish_index = origin_index
+ final_cursor = view:predecessor(destination) or destination
+ end
+
+ self._text = delete_character_range(
+ self._text,
+ start_line,
+ start_index,
+ finish_line,
+ finish_index
+ )
+ self._cursor = normalized_cursor(self._text, self._encoding, final_cursor)
+ self:_record("apply_operator", {
+ operator = self._pending_operator,
+ descriptor = descriptor.value,
+ origin = origin,
+ endpoint = destination,
+ position = self._cursor,
+ })
+ return true
+end
+
+function MemoryHost:apply_cursor(position, motion)
+ position = domain.Position.coerce(position)
+ local previous = self._cursor
+ local descriptor = motion_descriptor(motion)
+ self._cursor = position
+ self:_record("apply_cursor", {
+ position = position,
+ descriptor = descriptor and descriptor.value or nil,
+ })
+ if descriptor ~= nil then
+ local origin = type(motion) == "table" and motion.origin or previous
+ self:_apply_pending_delete(domain.Position.coerce(origin), position, descriptor)
+ end
+ self:_emit_movement_event(previous)
+end
+
+function MemoryHost:apply_selection(position, kind)
+ local previous = self._cursor
+ local next_selection
+ if domain.Selection.is(position) then
+ next_selection = position
+ position = next_selection.focus
+ else
+ position = domain.Position.coerce(position)
+ if kind == nil then
+ kind = self._selection.kind
+ end
+ kind = domain.SelectionKind.from_string(kind)
+ if kind == domain.SelectionKind.NONE then
+ error("selection movement requires a Visual selection kind", 2)
+ end
+ local anchor = self._selection.active and self._selection.anchor or previous
+ next_selection = domain.Selection.active(
+ kind,
+ anchor,
+ position,
+ self._selection.option
+ )
+ end
+
+ self._selection = next_selection
+ self._cursor = position
+ self:_record("apply_selection", {
+ position = position,
+ kind = next_selection.kind.value,
+ })
+ self:_emit_movement_event(previous)
+end
+
+function MemoryHost:set_operator_inclusive(enabled)
+ if type(enabled) ~= "boolean" then
+ error("operator inclusivity must be a Boolean", 2)
+ end
+ self._operator_inclusive = enabled
+ self:_record("set_operator_inclusive", { enabled = enabled })
+end
+
+function MemoryHost:operator_inclusive()
+ return self._operator_inclusive
+end
+
+function MemoryHost:read_input()
+ local packet = self._input_packets[self._input_index]
+ if packet == nil then
+ error("in-memory input queue is empty", 2)
+ end
+ self._input_index = self._input_index + 1
+ self:_record("read_input", { packet = packet:to_table() })
+ if packet.kind == domain.InputPacketKind.ERROR then
+ error(packet.message, 0)
+ end
+ return packet
+end
+
+function MemoryHost:open_fold(position)
+ position = position and domain.Position.coerce(position) or self._cursor
+ local closed_levels = self._fold_state.closed_levels
+ if closed_levels == 0 then
+ self:_record("open_fold", { position = position, opened = false })
+ return false
+ end
+ self:_record("open_fold", {
+ position = position,
+ fold_level = closed_levels,
+ opened = true,
+ })
+ self._fold_state = domain.FoldState.new(
+ self._fold_state:policies(),
+ closed_levels - 1
+ )
+ return true
+end
+
+function MemoryHost:show_prompt(text)
+ if type(text) ~= "string" then
+ error("prompt must be a string", 2)
+ end
+ self._prompts[#self._prompts + 1] = text
+ self:_record("show_prompt", { text = text })
+end
+
+function MemoryHost:prompts()
+ return list_copy(self._prompts)
+end
+
+function MemoryHost:redraw(kind)
+ if kind ~= "screen" and kind ~= "full" and kind ~= "suppressed" then
+ error("redraw kind must be screen, full, or suppressed", 2)
+ end
+ self._redraws[#self._redraws + 1] = kind
+ self:_record("redraw", { kind = kind })
+end
+
+function MemoryHost:redraws()
+ return list_copy(self._redraws)
+end
+
+function MemoryHost:emit_diagnostic(level, text)
+ if level ~= "error" and level ~= "warning" and level ~= "info" then
+ error("diagnostic level must be error, warning, or info", 2)
+ end
+ if type(text) ~= "string" or text == "" then
+ error("diagnostic text must be a nonempty string", 2)
+ end
+ local diagnostic = { level = level, text = text }
+ self._diagnostics[#self._diagnostics + 1] = diagnostic
+ self:_record("emit_diagnostic", diagnostic)
+end
+
+function MemoryHost:diagnostics()
+ return copy(self._diagnostics)
+end
+
+function MemoryHost:read_highlight_group(name)
+ if type(name) ~= "string" or name == "" then
+ error("highlight group name must be a nonempty string", 2)
+ end
+ local definition = self._highlight_groups[name]
+ self:_record("read_highlight_group", {
+ name = name,
+ defined = definition ~= nil,
+ })
+ return copy(definition)
+end
+
+function MemoryHost:highlight_groups()
+ return copy(self._highlight_groups)
+end
+
+function MemoryHost:define_highlight_group(name, definition, options)
+ if type(name) ~= "string" or name == "" then
+ error("highlight group name must be a nonempty string", 2)
+ end
+ if type(definition) ~= "table" then
+ error("highlight group definition must be a table", 2)
+ end
+ options = options or {}
+ if type(options) ~= "table" then
+ error("highlight group options must be a table", 2)
+ end
+ if options.default ~= nil and type(options.default) ~= "boolean" then
+ error("highlight group default option must be a Boolean", 2)
+ end
+ if options.force ~= nil and type(options.force) ~= "boolean" then
+ error("highlight group force option must be a Boolean", 2)
+ end
+
+ local exists = self._highlight_groups[name] ~= nil
+ local applied = not (exists and options.default)
+ if applied then
+ self._highlight_groups[name] = copy(definition)
+ end
+ self:_record("define_highlight_group", {
+ name = name,
+ definition = definition,
+ options = options,
+ applied = applied,
+ })
+ return applied
+end
+
+function MemoryHost:create_highlight(specification)
+ if type(specification) ~= "table" then
+ error("highlight specification must be a table", 2)
+ end
+ if type(specification.group) ~= "string" or specification.group == "" then
+ error("highlight group must be a nonempty string", 2)
+ end
+ local identity = specification.identity or self:_next_identity("highlight")
+ if self._highlights[identity] ~= nil then
+ error("highlight identity is already active", 2)
+ end
+ local stored = copy(specification)
+ stored.identity = identity
+ self._highlights[identity] = stored
+ self:_record("create_highlight", stored)
+ return identity
+end
+
+function MemoryHost:remove_highlight(identity)
+ if type(identity) ~= "string" or identity == "" then
+ error("highlight identity must be a nonempty string", 2)
+ end
+ local removed = self._highlights[identity] ~= nil
+ self._highlights[identity] = nil
+ self:_record("remove_highlight", { identity = identity, removed = removed })
+ return removed
+end
+
+function MemoryHost:highlights()
+ return copy(self._highlights)
+end
+
+function MemoryHost:supports_cursor_presentation()
+ self:_record("supports_cursor_presentation", {
+ supported = self._cursor_presentation_support,
+ })
+ return self._cursor_presentation_support
+end
+
+function MemoryHost:suppress_cursor_presentation()
+ if not self._cursor_presentation_support then
+ self:_record("suppress_cursor_presentation", { supported = false })
+ return nil
+ end
+ local identity = self:_next_identity("cursor-presentation")
+ self._cursor_leases[identity] = copy(self._cursor_presentation)
+ local suppressed = copy(self._cursor_presentation)
+ suppressed.hidden = true
+ self._cursor_presentation = suppressed
+ self:_record("suppress_cursor_presentation", {
+ identity = identity,
+ supported = true,
+ })
+ return identity
+end
+
+function MemoryHost:restore_cursor_presentation(identity)
+ if identity == nil then
+ self:_record("restore_cursor_presentation", { restored = false })
+ return false
+ end
+ local saved = self._cursor_leases[identity]
+ if saved == nil then
+ error("cursor presentation lease is inactive", 2)
+ end
+ self._cursor_presentation = saved
+ self._cursor_leases[identity] = nil
+ self:_record("restore_cursor_presentation", {
+ identity = identity,
+ restored = true,
+ })
+ return true
+end
+
+function MemoryHost:cursor_presentation()
+ return copy(self._cursor_presentation)
+end
+
+function MemoryHost:supports_timers()
+ self:_record("supports_timers", { supported = self._timer_support })
+ return self._timer_support
+end
+
+function MemoryHost:start_timer(delay_ms, callback)
+ if not is_integer(delay_ms) or delay_ms < 0 then
+ error("timer delay must be a nonnegative integer", 2)
+ end
+ if type(callback) ~= "function" then
+ error("timer callback must be a function", 2)
+ end
+ if not self._timer_support then
+ self:_record("start_timer", { delay_ms = delay_ms, supported = false })
+ return nil
+ end
+ local identity = self:_next_identity("timer")
+ self._timers[identity] = {
+ identity = identity,
+ delay_ms = delay_ms,
+ callback = callback,
+ active = true,
+ }
+ self:_record("start_timer", {
+ identity = identity,
+ delay_ms = delay_ms,
+ supported = true,
+ })
+ return identity
+end
+
+function MemoryHost:stop_timer(identity)
+ if type(identity) ~= "string" or identity == "" then
+ error("timer identity must be a nonempty string", 2)
+ end
+ local timer = self._timers[identity]
+ local stopped = timer ~= nil and timer.active
+ if timer ~= nil then
+ timer.active = false
+ end
+ self:_record("stop_timer", { identity = identity, stopped = stopped })
+ return stopped
+end
+
+function MemoryHost:fire_timer(identity)
+ local timer = self._timers[identity]
+ if timer == nil then
+ error("timer identity is unknown", 2)
+ end
+ if not timer.active then
+ self:_record("ignore_timer", { identity = identity })
+ return false
+ end
+ timer.active = false
+ self:_record("fire_timer", { identity = identity })
+ timer.callback(identity)
+ return true
+end
+
+function MemoryHost:timers()
+ local result = {}
+ for identity, timer in pairs(self._timers) do
+ result[identity] = {
+ identity = identity,
+ delay_ms = timer.delay_ms,
+ active = timer.active,
+ }
+ end
+ return result
+end
+
+function MemoryHost:register_events(event_names, callback, options)
+ local names, name_set = normalize_event_names(event_names)
+ if type(callback) ~= "function" then
+ error("event callback must be a function", 2)
+ end
+ local identity = self:_next_identity("event-registration")
+ self._event_registrations[identity] = {
+ identity = identity,
+ names = names,
+ name_set = name_set,
+ callback = callback,
+ options = copy(options or {}),
+ active = true,
+ }
+ self._event_registration_order[#self._event_registration_order + 1] = identity
+ self:_record("register_events", {
+ identity = identity,
+ names = names,
+ options = options or {},
+ })
+ return identity
+end
+
+function MemoryHost:remove_event_registration(identity)
+ local registration = self._event_registrations[identity]
+ local removed = registration ~= nil and registration.active
+ if registration ~= nil then
+ registration.active = false
+ end
+ self:_record("remove_event_registration", {
+ identity = identity,
+ removed = removed,
+ })
+ return removed
+end
+
+function MemoryHost:_deliver_event_now(name, payload)
+ self:_record("event", { name = name, payload = payload })
+ local order = list_copy(self._event_registration_order)
+ local event_buffer = payload.buffer or self._buffer
+ for _, identity in ipairs(order) do
+ local registration = self._event_registrations[identity]
+ local registration_buffer = registration.options.buffer
+ if registration.active
+ and registration.name_set[name]
+ and (registration_buffer == nil or registration_buffer == event_buffer)
+ then
+ registration.callback(name, payload)
+ end
+ end
+end
+
+function MemoryHost:deliver_event(name, payload)
+ if type(name) ~= "string" or name == "" then
+ error("event name must be a nonempty string", 2)
+ end
+ payload = copy(payload or {})
+ local queued = self._event_queue:is_transition_active()
+ self:_record(queued and "queue_event" or "deliver_event", {
+ name = name,
+ payload = payload,
+ })
+ return self._event_queue:emit(name, payload)
+end
+
+function MemoryHost:begin_action_transition()
+ local token = self._event_queue:begin_transition()
+ self:_record("begin_action_transition", { identity = token })
+ return token
+end
+
+function MemoryHost:commit_action_transition(token)
+ self:_record("commit_action_transition", { identity = token })
+ self._event_queue:commit_transition(token)
+end
+
+function MemoryHost:pending_event_count()
+ return self._event_queue:pending_count()
+end
+
+function MemoryHost:event_registrations()
+ local result = {}
+ for identity, registration in pairs(self._event_registrations) do
+ result[identity] = {
+ identity = identity,
+ names = list_copy(registration.names),
+ options = copy(registration.options),
+ active = registration.active,
+ }
+ end
+ return result
+end
+
+function MemoryHost:register_action(name, callback)
+ if type(name) ~= "string" or name == "" then
+ error("action name must be a nonempty string", 2)
+ end
+ if type(callback) ~= "function" then
+ error("action callback must be a function", 2)
+ end
+ if self._actions[name] ~= nil then
+ error("action is already registered", 2)
+ end
+ self._actions[name] = callback
+ self:_record("register_action", { name = name })
+ return name
+end
+
+function MemoryHost:invoke_action(name, ...)
+ local callback = self._actions[name]
+ if callback == nil then
+ error("action is not registered", 2)
+ end
+ local arguments = { ... }
+ local argument_count = select("#", ...)
+ local token = self:begin_action_transition()
+ local results = {
+ pcall(function()
+ return callback(unpack_values(arguments, 1, argument_count))
+ end),
+ }
+ self:commit_action_transition(token)
+ local succeeded = table.remove(results, 1)
+ if not succeeded then
+ error(results[1], 0)
+ end
+ return unpack_values(results)
+end
+
+function MemoryHost:register_mapping(modes, lhs, action, options)
+ modes = normalize_modes(modes)
+ if type(lhs) ~= "string" or lhs == "" then
+ error("mapping lhs must be a nonempty string", 2)
+ end
+ if type(action) ~= "string" and type(action) ~= "function" then
+ error("mapping action must be an action name or function", 2)
+ end
+ local identity = self:_next_identity("mapping")
+ self._mappings[identity] = {
+ identity = identity,
+ modes = modes,
+ lhs = lhs,
+ action = action,
+ options = copy(options or {}),
+ }
+ self:_record("register_mapping", {
+ identity = identity,
+ modes = modes,
+ lhs = lhs,
+ action = type(action) == "string" and action or "<function>",
+ options = options or {},
+ })
+ return identity
+end
+
+function MemoryHost:mappings()
+ return copy(self._mappings)
+end
+
+function MemoryHost:register_dot_repeat(payload, callback)
+ if not domain.DotPayload.is(payload) then
+ error("dot-repeat payload must be a DotPayload", 2)
+ end
+ if callback ~= nil and type(callback) ~= "function" then
+ error("dot-repeat callback must be a function", 2)
+ end
+ self._dot_repeat = {
+ payload = payload,
+ callback = callback,
+ operator = self._pending_operator,
+ mode = self._mode,
+ }
+ self:_record("register_dot_repeat", { payload = payload:to_table() })
+ return payload
+end
+
+function MemoryHost:dot_repeat_payload()
+ return self._dot_repeat and self._dot_repeat.payload or nil
+end
+
+function MemoryHost:replay_dot(count)
+ if self._dot_repeat == nil or self._dot_repeat.callback == nil then
+ error("dot repeat is not executable", 2)
+ end
+ self._pending_operator = self._dot_repeat.operator
+ self._mode = self._dot_repeat.mode
+ return self._dot_repeat.callback(self._dot_repeat.payload, domain.Count.new(count))
+end
+
+return M
diff --git a/lua/clever_tee/text_topology.lua b/lua/clever_tee/text_topology.lua
new file mode 100644
index 0000000..348af62
--- /dev/null
+++ b/lua/clever_tee/text_topology.lua
@@ -0,0 +1,1031 @@
+local domain = require("clever_tee.domain")
+
+local M = {}
+local TextView = {}
+local MatchStartBounds = {}
+M.TextView = TextView
+M.MatchStartBounds = MatchStartBounds
+
+local view_records = setmetatable({}, { __mode = "k" })
+local bounds_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function is_integer(value)
+ return type(value) == "number"
+ and value > -math.huge
+ and value < math.huge
+ and value == math.floor(value)
+end
+
+local function require_nonempty_string(value, name)
+ if type(value) ~= "string" or value == "" then
+ fail((name or "value") .. " must be a nonempty string", 2)
+ end
+ return value
+end
+
+local function canonical_encoding(encoding)
+ encoding = require_nonempty_string(encoding, "effective encoding"):lower()
+ encoding = encoding:gsub("_", "-")
+
+ local aliases = {
+ ["utf8"] = "utf-8",
+ ["cp-932"] = "cp932",
+ ["932"] = "cp932",
+ ["windows-31j"] = "cp932",
+ ["eucjp"] = "euc-jp",
+ ["ujis"] = "euc-jp",
+ ["unix-jis"] = "euc-jp",
+ }
+ return aliases[encoding] or encoding
+end
+
+M.normalize_encoding = canonical_encoding
+
+local function utf8_character_length(text, offset)
+ local first = text:byte(offset)
+ if first == nil then
+ return nil
+ end
+ if first < 0x80 then
+ return 1
+ end
+
+ local length
+ local minimum
+ if first >= 0xc2 and first <= 0xdf then
+ length = 2
+ minimum = 0x80
+ elseif first >= 0xe0 and first <= 0xef then
+ length = 3
+ minimum = 0x800
+ elseif first >= 0xf0 and first <= 0xf4 then
+ length = 4
+ minimum = 0x10000
+ else
+ fail("text contains an invalid UTF-8 character", 3)
+ end
+
+ if offset + length - 1 > #text then
+ fail("text contains an incomplete UTF-8 character", 3)
+ end
+
+ local codepoint = first % (2 ^ (8 - length - 1))
+ for index = offset + 1, offset + length - 1 do
+ local byte = text:byte(index)
+ if byte < 0x80 or byte > 0xbf then
+ fail("text contains an invalid UTF-8 character", 3)
+ end
+ codepoint = codepoint * 0x40 + (byte - 0x80)
+ end
+
+ if codepoint < minimum
+ or codepoint > 0x10ffff
+ or (codepoint >= 0xd800 and codepoint <= 0xdfff)
+ then
+ fail("text contains an invalid UTF-8 character", 3)
+ end
+ return length
+end
+
+local function split_utf8_codepoints(text)
+ local characters = {}
+ local offset = 1
+ while offset <= #text do
+ local length = utf8_character_length(text, offset)
+ characters[#characters + 1] = text:sub(offset, offset + length - 1)
+ offset = offset + length
+ end
+ return characters
+end
+
+local function nvim_split_segment(segment, result)
+ if segment == "" then
+ return
+ end
+ if segment:find("[\128-\255]") == nil then
+ for index = 1, #segment do
+ result[#result + 1] = segment:sub(index, index)
+ end
+ return
+ end
+
+ local offset = 0
+ while offset < #segment do
+ local match = vim.fn.matchstrpos(segment, "\\m.", offset)
+ local character = match[1]
+ local first = match[2]
+ local following = match[3]
+ if first ~= offset or following <= first or character == "" then
+ fail("Nvim could not index an editor character", 3)
+ end
+ result[#result + 1] = character
+ offset = following
+ end
+end
+
+local function default_split_editor_characters(text)
+ local runtime = rawget(_G, "vim")
+ if type(runtime) ~= "table"
+ or type(runtime.fn) ~= "table"
+ or type(runtime.fn.strchars) ~= "function"
+ or type(runtime.fn.byteidx) ~= "function"
+ then
+ return split_utf8_codepoints(text)
+ end
+
+ local result = {}
+ local offset = 1
+ while offset <= #text do
+ local nul = text:find("\0", offset, true)
+ local last = nul and (nul - 1) or #text
+ nvim_split_segment(text:sub(offset, last), result)
+ if nul == nil then
+ break
+ end
+ result[#result + 1] = "\0"
+ offset = nul + 1
+ end
+ return result
+end
+
+local function default_encode(text, encoding)
+ if encoding == "utf-8" then
+ return text
+ end
+
+ local runtime = rawget(_G, "vim")
+ if type(runtime) ~= "table" or type(runtime.iconv) ~= "function" then
+ fail("text encoding conversion requires Nvim or an encoder", 3)
+ end
+
+ local ok, encoded = pcall(runtime.iconv, text, "utf-8", encoding)
+ if not ok or encoded == nil then
+ fail("text could not be converted to " .. encoding, 3)
+ end
+ return encoded
+end
+
+local function require_character_list(characters)
+ if type(characters) ~= "table" then
+ fail("editor character splitter must return a list", 3)
+ end
+
+ local result = {}
+ local item_count = 0
+ for key, character in pairs(characters) do
+ if not is_integer(key) or key < 1 or key > #characters then
+ fail("editor character splitter must return a list", 3)
+ end
+ if type(character) ~= "string" or character == "" then
+ fail("editor character splitter must return nonempty strings", 3)
+ end
+ result[key] = character
+ item_count = item_count + 1
+ end
+ if item_count ~= #characters then
+ fail("editor character splitter must return a list", 3)
+ end
+ return result
+end
+
+function M.split_editor_characters(text)
+ if type(text) ~= "string" then
+ fail("text to split must be a string", 2)
+ end
+ return require_character_list(default_split_editor_characters(text))
+end
+
+local function snapshot_value(text)
+ if domain.TextSnapshot.is(text) then
+ return text
+ end
+ if type(text) == "table" and text.lines ~= nil then
+ text = text.lines
+ end
+ return domain.TextSnapshot.new(text)
+end
+
+local function require_options(options)
+ if options == nil then
+ return {}
+ end
+ if type(options) == "function" then
+ return { encoder = options }
+ end
+ if type(options) ~= "table" then
+ fail("TextView options must be a table", 2)
+ end
+ return options
+end
+
+local function selected_function(options, primary, alternate, fallback)
+ local value = options[primary]
+ if value == nil and alternate ~= nil then
+ value = options[alternate]
+ end
+ if value == nil then
+ return fallback
+ end
+ if type(value) ~= "function" then
+ fail("TextView " .. primary .. " must be a function", 3)
+ end
+ return value
+end
+
+local function index_line(text, encoding, splitter, encoder)
+ if text:find("\n", 1, true) ~= nil then
+ fail("a text snapshot line must not contain a newline", 3)
+ end
+
+ local characters = require_character_list(splitter(text))
+ if table.concat(characters) ~= text then
+ fail("editor character splitter must preserve the complete line", 3)
+ end
+
+ local entries = {}
+ local starts = {}
+ local by_start = {}
+ local encoded_parts = {}
+ local next_column = 1
+
+ for index, character in ipairs(characters) do
+ local encoded = encoder(character, encoding)
+ if type(encoded) ~= "string" or encoded == "" then
+ fail("TextView encoder must return a nonempty byte string", 3)
+ end
+
+ local byte_length = #encoded
+ local entry = {
+ character = character,
+ encoded = encoded,
+ byte_start = next_column,
+ byte_end = next_column + byte_length - 1,
+ byte_length = byte_length,
+ }
+ entries[index] = entry
+ starts[index] = next_column
+ by_start[next_column] = index
+ encoded_parts[index] = encoded
+ next_column = next_column + byte_length
+ end
+
+ return {
+ text = text,
+ encoded = table.concat(encoded_parts),
+ entries = entries,
+ starts = starts,
+ by_start = by_start,
+ byte_length = next_column - 1,
+ character_count = #entries,
+ }
+end
+
+local text_view_metatable = {
+ __index = function(view, key)
+ local method = TextView[key]
+ if method ~= nil then
+ return method
+ end
+
+ local record = view_records[view]
+ if key == "encoding" or key == "effective_encoding" then
+ return record.encoding
+ end
+ if key == "requested_encoding" then
+ return record.requested_encoding
+ end
+ if key == "line_count" then
+ return record.snapshot.line_count
+ end
+ return nil
+ end,
+ __newindex = function()
+ fail("TextView values are immutable", 2)
+ end,
+ __tostring = function(view)
+ local record = view_records[view]
+ return "text-view:" .. record.encoding .. ":" .. tostring(record.snapshot.line_count)
+ end,
+ __metatable = "clever_tee.text_topology.TextView",
+}
+
+function TextView.new(text, effective_encoding, options)
+ if TextView.is(text) and effective_encoding == nil and options == nil then
+ return text
+ end
+
+ local snapshot = snapshot_value(text)
+ local requested_encoding = require_nonempty_string(
+ effective_encoding,
+ "effective encoding"
+ )
+ local encoding = canonical_encoding(requested_encoding)
+ options = require_options(options)
+ local splitter = selected_function(
+ options,
+ "splitter",
+ "split_editor_characters",
+ default_split_editor_characters
+ )
+ local encoder = selected_function(options, "encoder", "encode", default_encode)
+
+ local view = setmetatable({}, text_view_metatable)
+ view_records[view] = {
+ snapshot = snapshot,
+ requested_encoding = requested_encoding,
+ encoding = encoding,
+ splitter = splitter,
+ encoder = encoder,
+ lines = {},
+ }
+ return view
+end
+
+function TextView.from_host(host, options)
+ if type(host) ~= "table"
+ or type(host.read_text) ~= "function"
+ or type(host.read_encoding) ~= "function"
+ then
+ fail("TextView host must provide read_text and read_encoding", 2)
+ end
+ local text = host:read_text()
+ local encoding = host:read_encoding()
+ return TextView.new(text, encoding, options)
+end
+
+function TextView.is(value)
+ return type(value) == "table" and view_records[value] ~= nil
+end
+
+local function view_record(view)
+ if not TextView.is(view) then
+ fail("value must be a TextView", 3)
+ end
+ return view_records[view]
+end
+
+local function line_record(view, line_number)
+ local record = view_record(view)
+ if not is_integer(line_number)
+ or line_number < 1
+ or line_number > record.snapshot.line_count
+ then
+ fail("line_number must identify a line in the TextView", 3)
+ end
+ local line = record.lines[line_number]
+ if line == nil then
+ line = index_line(
+ record.snapshot:line(line_number),
+ record.encoding,
+ record.splitter,
+ record.encoder
+ )
+ record.lines[line_number] = line
+ end
+ return line
+end
+
+local function require_character_index(line, character_index)
+ if not is_integer(character_index)
+ or character_index < 1
+ or character_index > line.character_count
+ then
+ fail("character_index must identify an editor character", 3)
+ end
+ return character_index
+end
+
+local function require_byte_column(byte_column)
+ if not is_integer(byte_column) or byte_column < 1 then
+ fail("byte_column must be a positive one-based integer", 3)
+ end
+ return byte_column
+end
+
+local function position_arguments(position_or_line, byte_column, name)
+ if byte_column == nil then
+ local position = domain.Position.coerce(position_or_line)
+ return position.line, position.byte_column
+ end
+ if not is_integer(position_or_line) or position_or_line < 1 then
+ fail((name or "line_number") .. " must be a positive integer", 3)
+ end
+ return position_or_line, require_byte_column(byte_column)
+end
+
+function TextView:text_snapshot()
+ return view_record(self).snapshot
+end
+
+function TextView:line_text(line_number)
+ return line_record(self, line_number).text
+end
+
+function TextView:line_encoded_text(line_number)
+ return line_record(self, line_number).encoded
+end
+
+function TextView:text_suffix(position)
+ position = domain.Position.coerce(position)
+ local record = view_record(self)
+ local line = line_record(self, position.line)
+ local character_index = self:character_index_for_byte_column(
+ position.line,
+ position.byte_column
+ )
+ local parts = {}
+
+ for index = character_index, line.character_count do
+ parts[#parts + 1] = line.entries[index].character
+ end
+ for line_number = position.line + 1, record.snapshot.line_count do
+ parts[#parts + 1] = "\n"
+ parts[#parts + 1] = record.snapshot:line(line_number)
+ end
+ return table.concat(parts)
+end
+
+function TextView:line_byte_length(line_number)
+ return line_record(self, line_number).byte_length
+end
+
+function TextView:line_character_count(line_number)
+ return line_record(self, line_number).character_count
+end
+
+function TextView:line_is_empty(line_number)
+ return self:line_character_count(line_number) == 0
+end
+
+function TextView:character_at_index(line_number, character_index)
+ local line = line_record(self, line_number)
+ require_character_index(line, character_index)
+ return line.entries[character_index].character
+end
+
+function TextView:encoded_character_at_index(line_number, character_index)
+ local line = line_record(self, line_number)
+ require_character_index(line, character_index)
+ return line.entries[character_index].encoded
+end
+
+function TextView:byte_column_for_character_index(line_number, character_index)
+ local line = line_record(self, line_number)
+ require_character_index(line, character_index)
+ return line.starts[character_index]
+end
+
+function TextView:position_for_character_index(line_number, character_index)
+ return domain.Position.new(
+ line_number,
+ self:byte_column_for_character_index(line_number, character_index)
+ )
+end
+
+function TextView:try_character_index_for_byte_column(line_number, byte_column)
+ local line = line_record(self, line_number)
+ require_byte_column(byte_column)
+ return line.by_start[byte_column]
+end
+
+function TextView:character_index_for_byte_column(line_number, byte_column)
+ local line = line_record(self, line_number)
+ require_byte_column(byte_column)
+ local character_index = line.by_start[byte_column]
+ if character_index == nil then
+ if byte_column <= line.byte_length then
+ fail("byte_column points inside an editor character", 2)
+ end
+ fail("byte_column does not identify an editor character", 2)
+ end
+ return character_index
+end
+
+function TextView:character_index_for_position(position)
+ position = domain.Position.coerce(position)
+ return self:character_index_for_byte_column(position.line, position.byte_column)
+end
+
+local function copy_span(line_number, character_index, entry)
+ local position = domain.Position.new(line_number, entry.byte_start)
+ return {
+ line = line_number,
+ character_index = character_index,
+ character = entry.character,
+ encoded = entry.encoded,
+ position = position,
+ byte_column = entry.byte_start,
+ byte_start = entry.byte_start,
+ byte_end = entry.byte_end,
+ start_byte_column = entry.byte_start,
+ end_byte_column = entry.byte_end,
+ byte_length = entry.byte_length,
+ }
+end
+
+function TextView:byte_span_for_character_index(line_number, character_index)
+ local line = line_record(self, line_number)
+ require_character_index(line, character_index)
+ return copy_span(line_number, character_index, line.entries[character_index])
+end
+
+function TextView:byte_span_at(position_or_line, byte_column)
+ local line_number, column = position_arguments(position_or_line, byte_column)
+ local character_index = self:character_index_for_byte_column(line_number, column)
+ return self:byte_span_for_character_index(line_number, character_index)
+end
+
+function TextView:character_at(position_or_line, byte_column)
+ local line_number, column = position_arguments(position_or_line, byte_column)
+ local character_index = self:character_index_for_byte_column(line_number, column)
+ return self:character_at_index(line_number, character_index)
+end
+
+function TextView:is_character_start(position_or_line, byte_column)
+ local line_number, column = position_arguments(position_or_line, byte_column)
+ local record = view_record(self)
+ if line_number > record.snapshot.line_count then
+ return false
+ end
+ return line_record(self, line_number).by_start[column] ~= nil
+end
+
+function TextView:is_valid_cursor_position(position_or_line, byte_column)
+ local line_number, column = position_arguments(position_or_line, byte_column)
+ local record = view_record(self)
+ if line_number > record.snapshot.line_count then
+ return false
+ end
+ local line = line_record(self, line_number)
+ if line.character_count == 0 then
+ return column == 1
+ end
+ return line.by_start[column] ~= nil
+end
+
+local function containing_character_index(line, byte_column)
+ for index = 1, line.character_count do
+ local entry = line.entries[index]
+ if byte_column >= entry.byte_start and byte_column <= entry.byte_end then
+ return index
+ end
+ end
+ return nil
+end
+
+function TextView:normalize_endpoint(position_or_line, byte_column)
+ local line_number, column = position_arguments(position_or_line, byte_column)
+ local line = line_record(self, line_number)
+ if line.character_count == 0 then
+ return domain.Position.new(line_number, 1)
+ end
+
+ if column > line.byte_length then
+ return self:position_for_character_index(line_number, line.character_count)
+ end
+
+ local character_index = line.by_start[column]
+ or containing_character_index(line, column)
+ return self:position_for_character_index(line_number, character_index)
+end
+
+local function first_cursor_position(view, line_number)
+ local line = line_record(view, line_number)
+ if line.character_count == 0 then
+ return domain.Position.new(line_number, 1)
+ end
+ return view:position_for_character_index(line_number, 1)
+end
+
+local function last_cursor_position(view, line_number)
+ local line = line_record(view, line_number)
+ if line.character_count == 0 then
+ return domain.Position.new(line_number, 1)
+ end
+ return view:position_for_character_index(line_number, line.character_count)
+end
+
+function TextView:first_cursor_position(line_number)
+ return first_cursor_position(self, line_number)
+end
+
+function TextView:last_cursor_position(line_number)
+ return last_cursor_position(self, line_number)
+end
+
+function TextView:predecessor(position)
+ position = domain.Position.coerce(position)
+ local line = line_record(self, position.line)
+
+ if line.character_count > 0 then
+ local character_index = self:character_index_for_byte_column(
+ position.line,
+ position.byte_column
+ )
+ if character_index > 1 then
+ return self:position_for_character_index(position.line, character_index - 1)
+ end
+ elseif position.byte_column ~= 1 then
+ fail("an empty line cursor position must use byte column one", 2)
+ end
+
+ if position.line == 1 then
+ return nil
+ end
+ return last_cursor_position(self, position.line - 1)
+end
+
+function TextView:successor(position)
+ position = domain.Position.coerce(position)
+ local record = view_record(self)
+ local line = line_record(self, position.line)
+
+ if line.character_count > 0 then
+ local character_index = self:character_index_for_byte_column(
+ position.line,
+ position.byte_column
+ )
+ if character_index < line.character_count then
+ return self:position_for_character_index(position.line, character_index + 1)
+ end
+ elseif position.byte_column ~= 1 then
+ fail("an empty line cursor position must use byte column one", 2)
+ end
+
+ if position.line == record.snapshot.line_count then
+ return nil
+ end
+ return first_cursor_position(self, position.line + 1)
+end
+
+local bounds_metatable = {
+ __index = function(bounds, key)
+ local method = MatchStartBounds[key]
+ if method ~= nil then
+ return method
+ end
+ return bounds_records[bounds][key]
+ end,
+ __newindex = function()
+ fail("MatchStartBounds values are immutable", 2)
+ end,
+ __tostring = function(bounds)
+ local record = bounds_records[bounds]
+ if record.empty then
+ return "match-start-bounds:empty"
+ end
+ return "match-start-bounds:" .. tostring(record.first) .. ":" .. tostring(record.last)
+ end,
+ __metatable = "clever_tee.text_topology.MatchStartBounds",
+}
+
+local function new_bounds(scope, first_line, last_line, first, last)
+ local bounds = setmetatable({}, bounds_metatable)
+ bounds_records[bounds] = {
+ scope = scope,
+ first_line = first_line,
+ last_line = last_line,
+ first = first,
+ last = last,
+ start = first,
+ finish = last,
+ empty = first == nil,
+ }
+ return bounds
+end
+
+function MatchStartBounds.is(value)
+ return type(value) == "table" and bounds_records[value] ~= nil
+end
+
+function MatchStartBounds:is_empty()
+ return bounds_records[self].empty
+end
+
+function MatchStartBounds:contains(position)
+ position = domain.Position.coerce(position)
+ local record = bounds_records[self]
+ if record.empty then
+ return false
+ end
+ return domain.Position.compare(position, record.first) >= 0
+ and domain.Position.compare(position, record.last) <= 0
+end
+
+function MatchStartBounds:to_table()
+ local record = bounds_records[self]
+ return {
+ scope = record.scope.value,
+ first_line = record.first_line,
+ last_line = record.last_line,
+ first = record.first and record.first:to_table() or nil,
+ last = record.last and record.last:to_table() or nil,
+ empty = record.empty,
+ }
+end
+
+function TextView:line_match_start_bounds(line_number)
+ local line = line_record(self, line_number)
+ local first
+ local last
+ if line.character_count > 0 then
+ first = self:position_for_character_index(line_number, 1)
+ last = self:position_for_character_index(line_number, line.character_count)
+ end
+ return new_bounds(
+ domain.SearchScope.CURRENT_LINE,
+ line_number,
+ line_number,
+ first,
+ last
+ )
+end
+
+function TextView:buffer_match_start_bounds()
+ local record = view_record(self)
+ local first
+ local last
+
+ for line_number = 1, record.snapshot.line_count do
+ local line = line_record(self, line_number)
+ if line.character_count > 0 then
+ first = self:position_for_character_index(line_number, 1)
+ break
+ end
+ end
+
+ for line_number = record.snapshot.line_count, 1, -1 do
+ local line = line_record(self, line_number)
+ if line.character_count > 0 then
+ last = self:position_for_character_index(line_number, line.character_count)
+ break
+ end
+ end
+
+ return new_bounds(
+ domain.SearchScope.BUFFER,
+ 1,
+ record.snapshot.line_count,
+ first,
+ last
+ )
+end
+
+local function scope_value(scope)
+ if scope == nil then
+ return domain.SearchScope.BUFFER
+ end
+ if scope == "line" then
+ return domain.SearchScope.CURRENT_LINE
+ end
+ return domain.SearchScope.from_string(scope)
+end
+
+function TextView:match_start_bounds(scope, origin)
+ if domain.Position.is(scope)
+ or type(scope) == "number"
+ or (type(scope) == "table" and scope.line ~= nil)
+ then
+ scope, origin = origin, scope
+ end
+
+ scope = scope_value(scope)
+ if scope == domain.SearchScope.BUFFER then
+ return self:buffer_match_start_bounds()
+ end
+
+ if origin == nil then
+ fail("current-line match bounds require an origin line", 2)
+ end
+ local line_number = type(origin) == "number"
+ and origin
+ or domain.Position.coerce(origin).line
+ return self:line_match_start_bounds(line_number)
+end
+
+local function empty_iterator()
+ return nil
+end
+
+local function iteration_endpoint(view, position, name)
+ position = domain.Position.coerce(position)
+ if not view:is_character_start(position) then
+ fail((name or "iterator endpoint") .. " must start an editor character", 3)
+ end
+ return position
+end
+
+local function step_character(view, position, direction)
+ local record = view_record(view)
+ local line = line_record(view, position.line)
+ local character_index = line.by_start[position.byte_column]
+
+ if direction == domain.Direction.FORWARD then
+ if character_index < line.character_count then
+ return view:position_for_character_index(position.line, character_index + 1)
+ end
+ for line_number = position.line + 1, record.snapshot.line_count do
+ if line_record(view, line_number).character_count > 0 then
+ return view:position_for_character_index(line_number, 1)
+ end
+ end
+ return nil
+ end
+
+ if character_index > 1 then
+ return view:position_for_character_index(position.line, character_index - 1)
+ end
+ for line_number = position.line - 1, 1, -1 do
+ local previous_line = line_record(view, line_number)
+ if previous_line.character_count > 0 then
+ return view:position_for_character_index(
+ line_number,
+ previous_line.character_count
+ )
+ end
+ end
+ return nil
+end
+
+local function position_iterator(view, direction, start_position, boundary)
+ if start_position == nil then
+ return empty_iterator
+ end
+
+ start_position = iteration_endpoint(view, start_position, "iterator start")
+ boundary = iteration_endpoint(view, boundary, "iterator boundary")
+ local comparison = domain.Position.compare(start_position, boundary)
+ if direction == domain.Direction.FORWARD and comparison > 0 then
+ fail("a forward iterator start must not follow its boundary", 3)
+ end
+ if direction == domain.Direction.BACKWARD and comparison < 0 then
+ fail("a backward iterator start must not precede its boundary", 3)
+ end
+
+ local current = start_position
+ local finished = false
+ return function()
+ if finished then
+ return nil
+ end
+
+ local position = current
+ local character_index = view:character_index_for_position(position)
+ local character = view:character_at_index(position.line, character_index)
+ local span = view:byte_span_for_character_index(position.line, character_index)
+
+ if position == boundary then
+ finished = true
+ else
+ current = step_character(view, position, direction)
+ if current == nil then
+ fail("iterator reached the text boundary before its selected boundary", 2)
+ end
+ end
+ return position, character, span
+ end
+end
+
+local function iteration_arguments(view, direction, first, second)
+ if MatchStartBounds.is(first) then
+ local record = bounds_records[first]
+ if record.empty then
+ return nil, nil
+ end
+ if direction == domain.Direction.FORWARD then
+ return record.first, record.last
+ end
+ return record.last, record.first
+ end
+
+ if type(first) == "number" and second == nil then
+ local bounds = view:line_match_start_bounds(first)
+ return iteration_arguments(view, direction, bounds)
+ end
+
+ if first == nil then
+ local bounds = view:buffer_match_start_bounds()
+ return iteration_arguments(view, direction, bounds)
+ end
+
+ first = domain.Position.coerce(first)
+ if second ~= nil then
+ return first, domain.Position.coerce(second)
+ end
+
+ local bounds = view:buffer_match_start_bounds()
+ if bounds.empty then
+ return nil, nil
+ end
+ return first, direction == domain.Direction.FORWARD and bounds.last or bounds.first
+end
+
+function TextView:iterate(direction, first, second)
+ direction = domain.Direction.from_string(direction)
+ local start_position, boundary = iteration_arguments(self, direction, first, second)
+ return position_iterator(self, direction, start_position, boundary)
+end
+
+function TextView:iter_forward(first, boundary)
+ return self:iterate(domain.Direction.FORWARD, first, boundary)
+end
+
+function TextView:iter_backward(first, boundary)
+ return self:iterate(domain.Direction.BACKWARD, first, boundary)
+end
+
+function TextView:iter_line_forward(line_number)
+ return self:iter_forward(self:line_match_start_bounds(line_number))
+end
+
+function TextView:iter_line_backward(line_number)
+ return self:iter_backward(self:line_match_start_bounds(line_number))
+end
+
+function TextView:iter_buffer_forward()
+ return self:iter_forward(self:buffer_match_start_bounds())
+end
+
+function TextView:iter_buffer_backward()
+ return self:iter_backward(self:buffer_match_start_bounds())
+end
+
+local function strict_scope_bounds(view, origin, scope_or_bounds)
+ if MatchStartBounds.is(scope_or_bounds) then
+ return scope_or_bounds
+ end
+ local scope = scope_value(scope_or_bounds)
+ return view:match_start_bounds(scope, origin)
+end
+
+function TextView:iter_strict(origin, direction, scope_or_bounds)
+ origin = domain.Position.coerce(origin)
+ line_record(self, origin.line)
+ if not self:is_valid_cursor_position(origin) then
+ fail("strict iterator origin must be a valid editor cursor position", 2)
+ end
+
+ direction = domain.Direction.from_string(direction)
+ local bounds = strict_scope_bounds(self, origin, scope_or_bounds)
+ local candidates = self:iterate(direction, bounds)
+
+ return function()
+ while true do
+ local position, character, span = candidates()
+ if position == nil then
+ return nil
+ end
+ local comparison = domain.Position.compare(position, origin)
+ if (direction == domain.Direction.FORWARD and comparison > 0)
+ or (direction == domain.Direction.BACKWARD and comparison < 0)
+ then
+ return position, character, span
+ end
+ end
+ end
+end
+
+function TextView:iter_strict_forward(origin, scope_or_bounds)
+ return self:iter_strict(origin, domain.Direction.FORWARD, scope_or_bounds)
+end
+
+function TextView:iter_strict_backward(origin, scope_or_bounds)
+ return self:iter_strict(origin, domain.Direction.BACKWARD, scope_or_bounds)
+end
+
+TextView.character_index_to_byte_column = TextView.byte_column_for_character_index
+TextView.byte_column_to_character_index = TextView.character_index_for_byte_column
+TextView.character_count = TextView.line_character_count
+TextView.byte_length = TextView.line_byte_length
+TextView.text_from = TextView.text_suffix
+TextView.suffix_from = TextView.text_suffix
+TextView.character_span = TextView.byte_span_for_character_index
+TextView.predecessor_endpoint = TextView.predecessor
+TextView.successor_endpoint = TextView.successor
+TextView.normalize_boundary_endpoint = TextView.normalize_endpoint
+TextView.bounds_for_line = TextView.line_match_start_bounds
+TextView.bounds_for_buffer = TextView.buffer_match_start_bounds
+TextView.iterate_forward = TextView.iter_forward
+TextView.iterate_backward = TextView.iter_backward
+TextView.forward = TextView.iter_forward
+TextView.backward = TextView.iter_backward
+TextView.strict_forward = TextView.iter_strict_forward
+TextView.strict_backward = TextView.iter_strict_backward
+
+function M.new(text, effective_encoding, options)
+ return TextView.new(text, effective_encoding, options)
+end
+
+function M.from_host(host, options)
+ return TextView.from_host(host, options)
+end
+
+M.build = M.new
+M.build_from_host = M.from_host
+M.is = TextView.is
+
+return M