From c7b14ffb6d14969c9c41864e827f33f8e80fc24e Mon Sep 17 00:00:00 2001 From: Jackson Moore Date: Fri, 4 Sep 2026 18:44:32 +0200 Subject: Rename plugin to clever-tee --- lua/clever_f/acquisition_service.lua | 927 ------------------- lua/clever_f/action_facade.lua | 134 --- lua/clever_f/capabilities.lua | 163 ---- lua/clever_f/case_policy.lua | 190 ---- lua/clever_f/composition_root.lua | 232 ----- lua/clever_f/destination_engine.lua | 201 ----- lua/clever_f/direct_preview_planner.lua | 250 ------ lua/clever_f/domain.lua | 1390 ----------------------------- lua/clever_f/feedback_service.lua | 1003 --------------------- lua/clever_f/host_adapter.lua | 1165 ------------------------ lua/clever_f/init.lua | 118 --- lua/clever_f/migemo_catalog.lua | 527 ----------- lua/clever_f/motion_executor.lua | 436 --------- lua/clever_f/motion_plan.lua | 188 ---- lua/clever_f/policy.lua | 488 ---------- lua/clever_f/repeat_resolver.lua | 333 ------- lua/clever_f/sequence_coordinator.lua | 675 -------------- lua/clever_f/sequence_state.lua | 302 ------- lua/clever_f/state_transitions.lua | 493 ---------- lua/clever_f/target_plan.lua | 541 ----------- lua/clever_f/testing/memory_host.lua | 1075 ---------------------- lua/clever_f/text_topology.lua | 1031 --------------------- lua/clever_tee/acquisition_service.lua | 927 +++++++++++++++++++ lua/clever_tee/action_facade.lua | 134 +++ lua/clever_tee/capabilities.lua | 163 ++++ lua/clever_tee/case_policy.lua | 190 ++++ lua/clever_tee/composition_root.lua | 232 +++++ lua/clever_tee/destination_engine.lua | 201 +++++ lua/clever_tee/direct_preview_planner.lua | 250 ++++++ lua/clever_tee/domain.lua | 1390 +++++++++++++++++++++++++++++ lua/clever_tee/feedback_service.lua | 1003 +++++++++++++++++++++ lua/clever_tee/host_adapter.lua | 1165 ++++++++++++++++++++++++ lua/clever_tee/init.lua | 118 +++ lua/clever_tee/migemo_catalog.lua | 527 +++++++++++ lua/clever_tee/motion_executor.lua | 436 +++++++++ lua/clever_tee/motion_plan.lua | 188 ++++ lua/clever_tee/policy.lua | 488 ++++++++++ lua/clever_tee/repeat_resolver.lua | 333 +++++++ lua/clever_tee/sequence_coordinator.lua | 675 ++++++++++++++ lua/clever_tee/sequence_state.lua | 302 +++++++ lua/clever_tee/state_transitions.lua | 493 ++++++++++ lua/clever_tee/target_plan.lua | 541 +++++++++++ lua/clever_tee/testing/memory_host.lua | 1075 ++++++++++++++++++++++ lua/clever_tee/text_topology.lua | 1031 +++++++++++++++++++++ 44 files changed, 11862 insertions(+), 11862 deletions(-) delete mode 100644 lua/clever_f/acquisition_service.lua delete mode 100644 lua/clever_f/action_facade.lua delete mode 100644 lua/clever_f/capabilities.lua delete mode 100644 lua/clever_f/case_policy.lua delete mode 100644 lua/clever_f/composition_root.lua delete mode 100644 lua/clever_f/destination_engine.lua delete mode 100644 lua/clever_f/direct_preview_planner.lua delete mode 100644 lua/clever_f/domain.lua delete mode 100644 lua/clever_f/feedback_service.lua delete mode 100644 lua/clever_f/host_adapter.lua delete mode 100644 lua/clever_f/init.lua delete mode 100644 lua/clever_f/migemo_catalog.lua delete mode 100644 lua/clever_f/motion_executor.lua delete mode 100644 lua/clever_f/motion_plan.lua delete mode 100644 lua/clever_f/policy.lua delete mode 100644 lua/clever_f/repeat_resolver.lua delete mode 100644 lua/clever_f/sequence_coordinator.lua delete mode 100644 lua/clever_f/sequence_state.lua delete mode 100644 lua/clever_f/state_transitions.lua delete mode 100644 lua/clever_f/target_plan.lua delete mode 100644 lua/clever_f/testing/memory_host.lua delete mode 100644 lua/clever_f/text_topology.lua create mode 100644 lua/clever_tee/acquisition_service.lua create mode 100644 lua/clever_tee/action_facade.lua create mode 100644 lua/clever_tee/capabilities.lua create mode 100644 lua/clever_tee/case_policy.lua create mode 100644 lua/clever_tee/composition_root.lua create mode 100644 lua/clever_tee/destination_engine.lua create mode 100644 lua/clever_tee/direct_preview_planner.lua create mode 100644 lua/clever_tee/domain.lua create mode 100644 lua/clever_tee/feedback_service.lua create mode 100644 lua/clever_tee/host_adapter.lua create mode 100644 lua/clever_tee/init.lua create mode 100644 lua/clever_tee/migemo_catalog.lua create mode 100644 lua/clever_tee/motion_executor.lua create mode 100644 lua/clever_tee/motion_plan.lua create mode 100644 lua/clever_tee/policy.lua create mode 100644 lua/clever_tee/repeat_resolver.lua create mode 100644 lua/clever_tee/sequence_coordinator.lua create mode 100644 lua/clever_tee/sequence_state.lua create mode 100644 lua/clever_tee/state_transitions.lua create mode 100644 lua/clever_tee/target_plan.lua create mode 100644 lua/clever_tee/testing/memory_host.lua create mode 100644 lua/clever_tee/text_topology.lua (limited to 'lua') diff --git a/lua/clever_f/acquisition_service.lua b/lua/clever_f/acquisition_service.lua deleted file mode 100644 index e0a6291..0000000 --- a/lua/clever_f/acquisition_service.lua +++ /dev/null @@ -1,927 +0,0 @@ -local domain = require("clever_f.domain") -local direct_preview_planner = require("clever_f.direct_preview_planner") -local feedback_service = require("clever_f.feedback_service") -local motion_plan_factory = require("clever_f.motion_plan") -local policy = require("clever_f.policy") -local sequence_state = require("clever_f.sequence_state") -local state_transitions = require("clever_f.state_transitions") -local target_plan_factory = require("clever_f.target_plan") -local text_topology = require("clever_f.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-f: " -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_f.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_f.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_f.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-f: 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_f/action_facade.lua b/lua/clever_f/action_facade.lua deleted file mode 100644 index a3fe173..0000000 --- a/lua/clever_f/action_facade.lua +++ /dev/null @@ -1,134 +0,0 @@ -local domain = require("clever_f.domain") -local sequence_coordinator = require("clever_f.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_f/capabilities.lua b/lua/clever_f/capabilities.lua deleted file mode 100644 index 325d015..0000000 --- a/lua/clever_f/capabilities.lua +++ /dev/null @@ -1,163 +0,0 @@ -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_f/case_policy.lua b/lua/clever_f/case_policy.lua deleted file mode 100644 index 2631d5b..0000000 --- a/lua/clever_f/case_policy.lua +++ /dev/null @@ -1,190 +0,0 @@ -local domain = require("clever_f.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_f.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_f/composition_root.lua b/lua/clever_f/composition_root.lua deleted file mode 100644 index 35aaf1f..0000000 --- a/lua/clever_f/composition_root.lua +++ /dev/null @@ -1,232 +0,0 @@ -local action_facade = require("clever_f.action_facade") -local capabilities = require("clever_f.capabilities") -local feedback_service = require("clever_f.feedback_service") -local policy = require("clever_f.policy") -local sequence_coordinator = require("clever_f.sequence_coordinator") -local sequence_state = require("clever_f.sequence_state") -local state_transitions = require("clever_f.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_f", 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_f/destination_engine.lua b/lua/clever_f/destination_engine.lua deleted file mode 100644 index 53cb654..0000000 --- a/lua/clever_f/destination_engine.lua +++ /dev/null @@ -1,201 +0,0 @@ -local domain = require("clever_f.domain") -local text_topology = require("clever_f.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_f.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_f/direct_preview_planner.lua b/lua/clever_f/direct_preview_planner.lua deleted file mode 100644 index 6735924..0000000 --- a/lua/clever_f/direct_preview_planner.lua +++ /dev/null @@ -1,250 +0,0 @@ -local case_policy = require("clever_f.case_policy") -local domain = require("clever_f.domain") -local text_topology = require("clever_f.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_f/domain.lua b/lua/clever_f/domain.lua deleted file mode 100644 index 0d802b3..0000000 --- a/lua/clever_f/domain.lua +++ /dev/null @@ -1,1390 +0,0 @@ -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_f.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_f/feedback_service.lua b/lua/clever_f/feedback_service.lua deleted file mode 100644 index ca26746..0000000 --- a/lua/clever_f/feedback_service.lua +++ /dev/null @@ -1,1003 +0,0 @@ -local domain = require("clever_f.domain") -local policy = require("clever_f.policy") -local sequence_state = require("clever_f.sequence_state") -local state_transitions = require("clever_f.state_transitions") -local text_topology = require("clever_f.text_topology") - -local M = {} -local FeedbackService = {} -FeedbackService.__index = FeedbackService -M.FeedbackService = FeedbackService -local CursorPresentationLease = {} -M.CursorPresentationLease = CursorPresentationLease - -M.DEFAULT_LABEL_GROUP = "CleverFDefaultLabel" -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 = { - CleverFCursor = M.Priority.HIGH, - CleverFChar = M.Priority.HIGH, - CleverFDirect = 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 = { - "CleverFCursor", - "CleverFChar", - "CleverFDirect", -} - -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_f.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 = "CleverFDirect", - window = window, - positions = positions, - priority = M.overlay_priority("CleverFDirect"), - }) - record.transitions:AddTemporaryOverlay(identity, window, "CleverFDirect") - local resource = { - identity = identity, - window = window, - group = "CleverFDirect", - 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 = "CleverFCursor", - window = window, - position = position, - priority = M.overlay_priority("CleverFCursor"), - }) - record.transitions:AddTemporaryOverlay(identity, window, "CleverFCursor") - local resource = { - identity = identity, - window = window, - group = "CleverFCursor", - 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 = "CleverFChar", - window = request.window, - positions = request.positions, - priority = M.overlay_priority("CleverFChar"), - 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 = "CleverFChar" - request.priority = M.overlay_priority("CleverFChar") - 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_f", 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_f/host_adapter.lua b/lua/clever_f/host_adapter.lua deleted file mode 100644 index a4ec77d..0000000 --- a/lua/clever_f/host_adapter.lua +++ /dev/null @@ -1,1165 +0,0 @@ -local capabilities = require("clever_f.capabilities") -local domain = require("clever_f.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_f_" -M.CONFIGURATION_GLOBALS = { - suppress_default_mappings = "clever_f_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-f" }) - 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_f", - { 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-f " .. 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 = "(clever-f-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-f 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-f 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-f 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("") - end - if type(runtime.api.nvim_replace_termcodes) == "function" then - return runtime.api.nvim_replace_termcodes("", 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-f " .. (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_f/init.lua b/lua/clever_f/init.lua deleted file mode 100644 index a1272fc..0000000 --- a/lua/clever_f/init.lua +++ /dev/null @@ -1,118 +0,0 @@ -local composition_root = require("clever_f.composition_root") -local host_adapter = require("clever_f.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-f 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-f 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_f/migemo_catalog.lua b/lua/clever_f/migemo_catalog.lua deleted file mode 100644 index 18d9541..0000000 --- a/lua/clever_f/migemo_catalog.lua +++ /dev/null @@ -1,527 +0,0 @@ -local domain = require("clever_f.domain") -local sequence_state = require("clever_f.sequence_state") -local state_transitions = require("clever_f.state_transitions") -local text_topology = require("clever_f.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_f#migemo#utf8#load_dict", - }, - cp932 = { - file = "cp932.vim", - function_name = "clever_f#migemo#cp932#load_dict", - }, - ["euc-jp"] = { - file = "eucjp.vim", - function_name = "clever_f#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_f/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_f/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_f.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_f.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-f: 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_f/motion_executor.lua b/lua/clever_f/motion_executor.lua deleted file mode 100644 index 79cc2ab..0000000 --- a/lua/clever_f/motion_executor.lua +++ /dev/null @@ -1,436 +0,0 @@ -local destination_engine = require("clever_f.destination_engine") -local domain = require("clever_f.domain") -local sequence_state = require("clever_f.sequence_state") -local state_transitions = require("clever_f.state_transitions") -local text_topology = require("clever_f.text_topology") - -local M = {} -local MotionExecutor = {} -M.MotionExecutor = MotionExecutor - -M.ExecutionPath = { - VISUAL = "visual", - COMMAND = "command", -} - -local executor_records = setmetatable({}, { __mode = "k" }) - -local function fail(message, level) - error(message, (level or 1) + 1) -end - -function M.execution_path(context) - context = domain.ModeContext.from_full_mode(context) - if context.visual_kind ~= nil then - 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_f.motion_executor.MotionExecutor", -} - -function MotionExecutor.new(options, dependencies) - options = normalize_options(options, dependencies) - if MotionExecutor.is(options) then - return options - end - - local executor = setmetatable({}, executor_metatable) - 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_f/motion_plan.lua b/lua/clever_f/motion_plan.lua deleted file mode 100644 index 1d0b4e3..0000000 --- a/lua/clever_f/motion_plan.lua +++ /dev/null @@ -1,188 +0,0 @@ -local domain = require("clever_f.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_f.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_f/policy.lua b/lua/clever_f/policy.lua deleted file mode 100644 index df9fb7a..0000000 --- a/lua/clever_f/policy.lua +++ /dev/null @@ -1,488 +0,0 @@ -local case_policy = require("clever_f.case_policy") -local domain = require("clever_f.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 = "CleverFCursor", - 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 = "CleverFDefaultLabel", - feature_setting = "mark_char", - highlight_group = "CleverFChar", - 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 = "CleverFDefaultLabel", - feature_setting = "mark_direct", - highlight_group = "CleverFDirect", - 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_f/repeat_resolver.lua b/lua/clever_f/repeat_resolver.lua deleted file mode 100644 index a5374d6..0000000 --- a/lua/clever_f/repeat_resolver.lua +++ /dev/null @@ -1,333 +0,0 @@ -local domain = require("clever_f.domain") -local sequence_state = require("clever_f.sequence_state") -local state_transitions = require("clever_f.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_f.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_f/sequence_coordinator.lua b/lua/clever_f/sequence_coordinator.lua deleted file mode 100644 index d8cf7f0..0000000 --- a/lua/clever_f/sequence_coordinator.lua +++ /dev/null @@ -1,675 +0,0 @@ -local acquisition_service_factory = require("clever_f.acquisition_service") -local case_policy = require("clever_f.case_policy") -local direct_preview_planner = require("clever_f.direct_preview_planner") -local domain = require("clever_f.domain") -local feedback_service_factory = require("clever_f.feedback_service") -local motion_executor_factory = require("clever_f.motion_executor") -local motion_plan_factory = require("clever_f.motion_plan") -local policy = require("clever_f.policy") -local repeat_resolver_factory = require("clever_f.repeat_resolver") -local sequence_state = require("clever_f.sequence_state") -local state_transitions = require("clever_f.state_transitions") -local target_plan_factory = require("clever_f.target_plan") -local text_topology = require("clever_f.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-f: 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_f/sequence_state.lua b/lua/clever_f/sequence_state.lua deleted file mode 100644 index e31752d..0000000 --- a/lua/clever_f/sequence_state.lua +++ /dev/null @@ -1,302 +0,0 @@ -local domain = require("clever_f.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_f.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_f/state_transitions.lua b/lua/clever_f/state_transitions.lua deleted file mode 100644 index 426bddb..0000000 --- a/lua/clever_f/state_transitions.lua +++ /dev/null @@ -1,493 +0,0 @@ -local domain = require("clever_f.domain") -local sequence_state = require("clever_f.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 = "CleverFChar", - 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 "CleverFCursor" - if group ~= "CleverFCursor" and group ~= "CleverFDirect" then - fail("temporary overlay group must be CleverFCursor or CleverFDirect", 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_f/target_plan.lua b/lua/clever_f/target_plan.lua deleted file mode 100644 index 78d0b41..0000000 --- a/lua/clever_f/target_plan.lua +++ /dev/null @@ -1,541 +0,0 @@ -local case_policy = require("clever_f.case_policy") -local domain = require("clever_f.domain") -local migemo_catalog = require("clever_f.migemo_catalog") -local text_topology = require("clever_f.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_f.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_f/testing/memory_host.lua b/lua/clever_f/testing/memory_host.lua deleted file mode 100644 index 6147d2c..0000000 --- a/lua/clever_f/testing/memory_host.lua +++ /dev/null @@ -1,1075 +0,0 @@ -local capabilities = require("clever_f.capabilities") -local domain = require("clever_f.domain") -local text_topology = require("clever_f.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 "", - 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_f/text_topology.lua b/lua/clever_f/text_topology.lua deleted file mode 100644 index db4ea3b..0000000 --- a/lua/clever_f/text_topology.lua +++ /dev/null @@ -1,1031 +0,0 @@ -local domain = require("clever_f.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_f.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_f.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 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 = "(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("") + end + if type(runtime.api.nvim_replace_termcodes) == "function" then + return runtime.api.nvim_replace_termcodes("", 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 "", + 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 -- cgit v1.2.3