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