local domain = require("clever_f.domain") local direct_preview_planner = require("clever_f.direct_preview_planner") local feedback_service = require("clever_f.feedback_service") local motion_plan_factory = require("clever_f.motion_plan") local policy = require("clever_f.policy") local sequence_state = require("clever_f.sequence_state") local state_transitions = require("clever_f.state_transitions") local target_plan_factory = require("clever_f.target_plan") local text_topology = require("clever_f.text_topology") local M = {} local AcquisitionRequest = {} local AcquisitionResult = {} local AcquisitionService = {} local TemporaryResourceScope = {} AcquisitionService.__index = AcquisitionService M.AcquisitionRequest = AcquisitionRequest M.AcquisitionResult = AcquisitionResult M.AcquisitionService = AcquisitionService M.TemporaryResourceScope = TemporaryResourceScope M.RepeatedDirection = { SAME = "same", } M.PROMPT = "clever-f: " M.PREVIOUS_INPUT_NOT_FOUND = "Previous input not found." local request_records = setmetatable({}, { __mode = "k" }) local result_records = setmetatable({}, { __mode = "k" }) local service_records = setmetatable({}, { __mode = "k" }) local scope_records = setmetatable({}, { __mode = "k" }) local function fail(message, level) error(message, (level or 1) + 1) end local function normalize_macro_state(value) if type(value) == "table" and not domain.MacroState.is(value) then value = value.register end return domain.MacroState.new(value) end local request_metatable = { __index = function(request, key) local method = AcquisitionRequest[key] if method ~= nil then return method end return request_records[request][key] end, __newindex = function() fail("AcquisitionRequest values are immutable", 2) end, __tostring = function(request) return "acquisition-request:" .. request_records[request].descriptor.value end, __metatable = "clever_f.acquisition_service.AcquisitionRequest", } function AcquisitionRequest.new(descriptor, context, position, count, macro_state) if AcquisitionRequest.is(descriptor) then return descriptor end if type(descriptor) == "table" and not domain.Descriptor.is(descriptor) then local options = descriptor descriptor = options.descriptor context = options.context position = options.position or options.origin count = options.count macro_state = options.macro_state end local request = setmetatable({}, request_metatable) request_records[request] = { descriptor = domain.Descriptor.from_string(descriptor), context = domain.ModeContext.from_full_mode(context), position = domain.Position.coerce(position), count = domain.Count.new(count), macro_state = normalize_macro_state(macro_state), repeated_direction = M.RepeatedDirection.SAME, } return request end function AcquisitionRequest.is(value) return type(value) == "table" and request_records[value] ~= nil end function AcquisitionRequest:to_table() return { descriptor = self.descriptor.value, context = self.context.key, position = self.position:to_table(), count = self.count.value, macro_register = self.macro_state.register, repeated_direction = self.repeated_direction, } end local result_metatable = { __index = function(result, key) local method = AcquisitionResult[key] if method ~= nil then return method end return result_records[result][key] end, __newindex = function() fail("AcquisitionResult values are immutable", 2) end, __tostring = function(result) local outcome = result_records[result].outcome return outcome and tostring(outcome) or "acquisition:resolved" end, __metatable = "clever_f.acquisition_service.AcquisitionResult", } function AcquisitionResult.new(request, options) if AcquisitionResult.is(request) and options == nil then return request end if not AcquisitionRequest.is(request) then fail("acquisition result requires an AcquisitionRequest", 2) end options = options or {} if type(options) ~= "table" then fail("acquisition result options must be a table", 2) end local outcome = options.outcome if outcome ~= nil and not domain.ActionOutcome.is(outcome) then fail("acquisition result outcome must be an ActionOutcome", 2) end local target = options.target if target ~= nil and not domain.TargetValue.is(target) then fail("acquisition result target must be a TargetValue", 2) end local target_plan = options.target_plan if target_plan ~= nil and not domain.TargetPlan.is(target_plan) then fail("acquisition result target_plan must be a TargetPlan", 2) end local motion_plan = options.motion_plan if motion_plan ~= nil and not domain.ResolvedMotionPlan.is(motion_plan) then fail("acquisition result motion_plan must be a ResolvedMotionPlan", 2) end local result = setmetatable({}, result_metatable) result_records[result] = { request = request, outcome = outcome, target = target, target_plan = target_plan, motion_plan = motion_plan, resolved_motion_plan = motion_plan, previous_input_trigger = options.previous_input_trigger, previous_target_source = options.previous_target_source, cached_target = options.cached_target, missing_previous_input = options.missing_previous_input == true, acquisition_time_ms = options.acquisition_time_ms, persistent_feedback_request = options.persistent_feedback_request, resolved = target ~= nil and target_plan ~= nil and motion_plan ~= nil, completed = outcome ~= nil or (target ~= nil and target_plan ~= nil and motion_plan ~= nil), } return result end function AcquisitionResult.is(value) return type(value) == "table" and result_records[value] ~= nil end function AcquisitionResult:has_outcome() return self.outcome ~= nil end function AcquisitionResult:resolved_values() return self.target, self.target_plan, self.motion_plan end AcquisitionResult.unpack = AcquisitionResult.resolved_values local scope_metatable = { __index = function(scope, key) local method = TemporaryResourceScope[key] if method ~= nil then return method end return scope_records[scope][key] end, __newindex = function() fail("TemporaryResourceScope values are read-only", 2) end, __metatable = "clever_f.acquisition_service.TemporaryResourceScope", } function TemporaryResourceScope.new(request, feedback, host) if not AcquisitionRequest.is(request) then fail("temporary resource scope requires an AcquisitionRequest", 2) end local scope = setmetatable({}, scope_metatable) scope_records[scope] = { request = request, feedback = feedback, host = host, active = true, interactive = not request.macro_state.executing, prompt_shown = false, input_completed = false, acquisition_completed = false, cursor_marker = nil, direct_marker = nil, cursor_presentation_lease = nil, input_packet = nil, acquired_target = nil, resolved_target = nil, previous_input_trigger = nil, previous_target_source = nil, cached_target = nil, missing_previous_input = false, text_view = nil, target_plan = nil, motion_plan = nil, outcome = nil, } return scope end function TemporaryResourceScope.is(value) return type(value) == "table" and scope_records[value] ~= nil end local function set_scope_resource(scope, field, resource) local record = scope_records[scope] if record == nil or not record.active then fail("temporary resource scope must be active", 3) end record[field] = resource return resource end function TemporaryResourceScope:set_cursor_marker(marker) return set_scope_resource(self, "cursor_marker", marker) end function TemporaryResourceScope:set_direct_marker(marker) return set_scope_resource(self, "direct_marker", marker) end function TemporaryResourceScope:set_cursor_presentation_lease(lease) return set_scope_resource(self, "cursor_presentation_lease", lease) end function TemporaryResourceScope:set_input_packet(packet) return set_scope_resource(self, "input_packet", packet) end function TemporaryResourceScope: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() record.host: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 acquisition.mark_cursor then scope:set_cursor_marker(record.feedback:create_cursor_marker( request.position, current_window(record) )) if interactive then record.host:redraw("screen") end 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 )) record.host: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 record.policy:sample_markers().mark_char and feedback_service.persistent_context_eligible(request.context) then local window = scope.cursor_marker and scope.cursor_marker.window or scope.direct_marker and scope.direct_marker.window or current_window(record) persistent_feedback_request = record.feedback:request_persistent({ context = request.context, anchor = request.position, target_plan = target_plan, motion_plan = motion_plan, window = window, }) end local result = AcquisitionResult.new(request, { target = target, target_plan = target_plan, motion_plan = motion_plan, acquisition_time_ms = acquisition_time_ms, persistent_feedback_request = persistent_feedback_request, previous_input_trigger = trigger, previous_target_source = scope.previous_target_source, cached_target = scope.cached_target, missing_previous_input = scope.missing_previous_input, }) scope:mark_acquisition_completed() return result end local function error_message(failure) local message = tostring(failure) if message == "" then return "clever-f: Acquisition failed" end return message end function AcquisitionService:acquire(descriptor, context, position, count, macro_state) local request = self:request(descriptor, context, position, count, macro_state) local scope = self:start_temporary_scope(request) local record = service_records[self] local ok, result = xpcall(function() return acquire_in_scope(record, request, scope) end, function(failure) return failure end) if not ok then local diagnostic = error_message(result) result = AcquisitionResult.new(request, { outcome = domain.ActionOutcome.error(request.position, diagnostic), }) end local cleanup_ok, cleanup_error = pcall(function() scope:release() end) if not cleanup_ok and ok then local diagnostic = error_message(cleanup_error) result = AcquisitionResult.new(request, { outcome = domain.ActionOutcome.error(request.position, diagnostic), }) ok = false end if not ok then pcall(record.host.emit_diagnostic, record.host, "error", result.outcome.diagnostic) end return result end function M.new(options, dependencies) return AcquisitionService.new(options, dependencies) end setmetatable(M, { __call = function(_, options, dependencies) return AcquisitionService.new(options, dependencies) end, }) return M