diff options
Diffstat (limited to 'lua')
| -rw-r--r-- | lua/clever_f/capabilities.lua | 154 | ||||
| -rw-r--r-- | lua/clever_f/domain.lua | 1285 | ||||
| -rw-r--r-- | lua/clever_f/testing/memory_host.lua | 825 |
3 files changed, 2264 insertions, 0 deletions
diff --git a/lua/clever_f/capabilities.lua b/lua/clever_f/capabilities.lua new file mode 100644 index 0000000..77ce130 --- /dev/null +++ b/lua/clever_f/capabilities.lua @@ -0,0 +1,154 @@ +local M = {} + +M.read_methods = { + text = { "read_text" }, + cursor = { "read_cursor" }, + mode = { "read_mode", "read_pending_operator" }, + selection = { "read_selection" }, + count = { "read_count" }, + configuration = { "read_configuration", "configuration_present" }, + encoding = { "read_encoding" }, + macro_state = { "read_macro_state" }, + fold_state = { "read_fold_state" }, + time = { "read_time_ms" }, +} + +M.effect_methods = { + movement = { "apply_cursor", "apply_selection", "set_operator_inclusive" }, + input = { "read_input" }, + folds = { "open_fold" }, + prompt = { "show_prompt" }, + redraw = { "redraw" }, + diagnostics = { "emit_diagnostic" }, + highlights = { "create_highlight", "remove_highlight" }, + cursor_presentation = { + "suppress_cursor_presentation", + "restore_cursor_presentation", + }, + timers = { "supports_timers", "start_timer", "stop_timer" }, + events = { + "register_events", + "remove_event_registration", + "deliver_event", + "begin_action_transition", + "commit_action_transition", + }, + mappings = { "register_action", "register_mapping" }, + dot_repeat = { "register_dot_repeat" }, +} + +local function collect_methods(groups) + local result = {} + local group_names = {} + for group_name in pairs(groups) do + group_names[#group_names + 1] = group_name + end + table.sort(group_names) + for _, group_name in ipairs(group_names) do + for _, method_name in ipairs(groups[group_name]) do + result[#result + 1] = method_name + end + end + return result +end + +local all_methods = collect_methods(M.read_methods) +for _, method_name in ipairs(collect_methods(M.effect_methods)) do + all_methods[#all_methods + 1] = method_name +end +table.sort(all_methods) + +function M.required_methods() + local result = {} + for index = 1, #all_methods do + result[index] = all_methods[index] + end + return result +end + +function M.missing_methods(host) + local missing = {} + for _, method_name in ipairs(all_methods) do + if type(host) ~= "table" or type(host[method_name]) ~= "function" then + missing[#missing + 1] = method_name + end + end + return missing +end + +function M.assert_implements(host) + local missing = M.missing_methods(host) + if #missing > 0 then + error("host is missing semantic capabilities: " .. table.concat(missing, ", "), 2) + end + return host +end + +local EventQueue = {} +EventQueue.__index = EventQueue +M.EventQueue = EventQueue + +function EventQueue.new(deliver) + if type(deliver) ~= "function" then + error("event delivery must be a function", 2) + end + return setmetatable({ + _deliver = deliver, + _active_token = nil, + _pending = {}, + _next_token = 1, + }, EventQueue) +end + +function EventQueue:begin_transition() + if self._active_token ~= nil then + error("an action transition is already active", 2) + end + local token = "action-transition-" .. tostring(self._next_token) + self._next_token = self._next_token + 1 + self._active_token = token + self._pending = {} + return token +end + +function EventQueue:is_transition_active() + return self._active_token ~= nil +end + +function EventQueue:pending_count() + return #self._pending +end + +function EventQueue:emit(name, payload) + if type(name) ~= "string" or name == "" then + error("event name must be a nonempty string", 2) + end + if self._active_token ~= nil then + self._pending[#self._pending + 1] = { + name = name, + payload = payload, + } + return false + end + self._deliver(name, payload) + return true +end + +function EventQueue:commit_transition(token) + if self._active_token == nil then + error("no action transition is active", 2) + end + if token ~= self._active_token then + error("action transition token does not match", 2) + end + + local pending = self._pending + self._active_token = nil + self._pending = {} + for index = 1, #pending do + local event = pending[index] + self._deliver(event.name, event.payload) + end +end + +return M diff --git a/lua/clever_f/domain.lua b/lua/clever_f/domain.lua new file mode 100644 index 0000000..a5b8d39 --- /dev/null +++ b/lua/clever_f/domain.lua @@ -0,0 +1,1285 @@ +local M = {} + +local records = setmetatable({}, { __mode = "k" }) +local record_types = setmetatable({}, { __mode = "k" }) +local methods = {} +local formatters = {} +local equalities = {} +local metatables = {} + +local function fail(message, level) + error(message, (level or 1) + 1) +end + +local function is_integer(value) + return type(value) == "number" + and value > -math.huge + and value < math.huge + and value == math.floor(value) +end + +local function register_type(type_name, type_methods, formatter, equality) + methods[type_name] = type_methods or {} + formatters[type_name] = formatter + equalities[type_name] = equality + + local mt = { + __index = function(value, key) + local field = records[value][key] + if field ~= nil then + return field + end + return methods[type_name][key] + end, + __newindex = function() + fail(type_name .. " values are immutable", 2) + end, + __tostring = function(value) + local format = formatters[type_name] + if format then + return format(records[value]) + end + return type_name + end, + __eq = function(left, right) + if record_types[left] ~= type_name or record_types[right] ~= type_name then + return false + end + local equal = equalities[type_name] + if equal then + return equal(records[left], records[right]) + end + return rawequal(left, right) + end, + __metatable = "clever_f.domain." .. type_name, + } + metatables[type_name] = mt +end + +local function new_record(type_name, fields) + local value = {} + records[value] = fields + record_types[value] = type_name + return setmetatable(value, metatables[type_name]) +end + +local function is_record(value, type_name) + return record_types[value] == type_name +end + +local function require_record(value, type_name, name) + if not is_record(value, type_name) then + fail((name or "value") .. " must be a " .. type_name, 2) + end + return value +end + +local function require_string(value, name, allow_empty) + if type(value) ~= "string" or (not allow_empty and value == "") then + fail(name .. " must be " .. (allow_empty and "a string" or "a nonempty string"), 2) + end + return value +end + +local function require_boolean(value, name) + if type(value) ~= "boolean" then + fail(name .. " must be a Boolean", 2) + end + return value +end + +local function require_nonnegative_integer(value, name) + if not is_integer(value) or value < 0 then + fail(name .. " must be a nonnegative integer", 2) + end + return value +end + +function M.type_of(value) + return record_types[value] +end + +local function define_enum(type_name, entries) + local enum_methods = {} + local namespace = {} + local by_value = {} + + register_type(type_name, enum_methods, function(data) + return data.value + end) + + for constant, serialized in pairs(entries) do + local value = new_record(type_name, { + name = constant, + value = serialized, + }) + namespace[constant] = value + by_value[serialized] = value + end + + function namespace.from_string(value) + if is_record(value, type_name) then + return value + end + local result = by_value[value] + if result == nil then + fail("value must be a valid " .. type_name, 2) + end + return result + end + + function namespace.is(value) + return is_record(value, type_name) + end + + function enum_methods:to_string() + return records[self].value + end + + return namespace +end + +M.Family = define_enum("Family", { + FIND = "FIND", + TILL = "TILL", +}) + +M.Direction = define_enum("Direction", { + FORWARD = "forward", + BACKWARD = "backward", +}) + +M.SelectionKind = define_enum("SelectionKind", { + NONE = "none", + CHARACTER = "character", + LINE = "line", + BLOCK = "block", +}) + +M.SelectionOption = define_enum("SelectionOption", { + INCLUSIVE = "inclusive", + EXCLUSIVE = "exclusive", +}) + +M.TargetKind = define_enum("TargetKind", { + CHARACTER = "character", + SPECIAL_KEY = "special_key", + CODE_FALLBACK = "code_fallback", +}) + +M.CaseMode = define_enum("CaseMode", { + SENSITIVE = "sensitive", + INSENSITIVE = "insensitive", +}) + +M.TargetPlanKind = define_enum("TargetPlanKind", { + EMPTY = "empty", + LITERAL = "literal", + BACKSLASH = "backslash", + SYMBOL = "symbol", + MIGEMO = "migemo", +}) + +M.SearchScope = define_enum("SearchScope", { + BUFFER = "buffer", + CURRENT_LINE = "current_line", +}) + +M.EndpointPolicy = define_enum("EndpointPolicy", { + REGULAR = "regular", + VISUAL_EXCLUSIVE = "visual_exclusive", +}) + +M.SearchStatus = define_enum("SearchStatus", { + COMPLETE = "complete", + BOUNDARY_AFTER_PARTIAL = "boundary_after_partial", + BOUNDARY_BEFORE_ANY = "boundary_before_any", +}) + +M.ActionKind = define_enum("ActionKind", { + MOVEMENT = "movement", + NEUTRAL = "neutral", + ESCAPE = "escape", + FAILED_SEARCH = "failed_search", + ERROR = "error", + EMPTY = "empty", +}) + +local Position = {} +M.Position = Position + +register_type("Position", Position, function(data) + return string.format("(%d,%d)", data.line, data.byte_column) +end, function(left, right) + return left.line == right.line and left.byte_column == right.byte_column +end) + +function Position.new(line, byte_column) + if not is_integer(line) or line < 1 then + fail("line must be a positive one-based integer", 2) + end + if not is_integer(byte_column) or byte_column < 1 then + fail("byte_column must be a positive one-based integer", 2) + end + return new_record("Position", { + line = line, + byte_column = byte_column, + }) +end + +function Position.coerce(value) + if Position.is(value) then + return value + end + if type(value) ~= "table" then + fail("position must be a Position or position table", 2) + end + return Position.new(value.line, value.byte_column) +end + +function Position.is(value) + return is_record(value, "Position") +end + +function Position.compare(left, right) + require_record(left, "Position", "left") + require_record(right, "Position", "right") + if left.line < right.line then + return -1 + end + if left.line > right.line then + return 1 + end + if left.byte_column < right.byte_column then + return -1 + end + if left.byte_column > right.byte_column then + return 1 + end + return 0 +end + +function Position.equal(left, right) + return Position.compare(left, right) == 0 +end + +function Position.stationary(left, right) + return Position.equal(left, right) +end + +function Position.is_forward(candidate, origin) + return Position.compare(candidate, origin) > 0 +end + +function Position.is_backward(candidate, origin) + return Position.compare(candidate, origin) < 0 +end + +function Position:to_table() + return { + line = self.line, + byte_column = self.byte_column, + } +end + +local Descriptor = {} +M.Descriptor = Descriptor + +register_type("Descriptor", Descriptor, function(data) + return data.value +end) + +local descriptors_by_string = {} +local descriptors_by_parts = {} + +local function descriptor_key(family, direction) + return family.value .. ":" .. direction.value +end + +local function define_descriptor(name, serialized, family, direction) + local descriptor = new_record("Descriptor", { + name = name, + value = serialized, + family = family, + direction = direction, + uppercase = serialized:match("%u") ~= nil, + }) + descriptors_by_string[serialized] = descriptor + descriptors_by_parts[descriptor_key(family, direction)] = descriptor + Descriptor[name] = descriptor + Descriptor[serialized] = descriptor + return descriptor +end + +Descriptor.FIND_FORWARD = define_descriptor( + "FIND_FORWARD", + "f", + M.Family.FIND, + M.Direction.FORWARD +) +Descriptor.FIND_BACKWARD = define_descriptor( + "FIND_BACKWARD", + "F", + M.Family.FIND, + M.Direction.BACKWARD +) +Descriptor.TILL_FORWARD = define_descriptor( + "TILL_FORWARD", + "t", + M.Family.TILL, + M.Direction.FORWARD +) +Descriptor.TILL_BACKWARD = define_descriptor( + "TILL_BACKWARD", + "T", + M.Family.TILL, + M.Direction.BACKWARD +) + +function Descriptor.is(value) + return is_record(value, "Descriptor") +end + +function Descriptor.is_valid(value) + return Descriptor.is(value) or descriptors_by_string[value] ~= nil +end + +function Descriptor.from_string(value) + if Descriptor.is(value) then + return value + end + local descriptor = descriptors_by_string[value] + if descriptor == nil then + fail("descriptor must be one of f, F, t, or T", 2) + end + return descriptor +end + +function Descriptor.try_from_string(value) + if Descriptor.is(value) then + return value + end + return descriptors_by_string[value] +end + +function Descriptor.from_parts(family, direction) + family = M.Family.from_string(family) + direction = M.Direction.from_string(direction) + return descriptors_by_parts[descriptor_key(family, direction)] +end + +function Descriptor.to_string(value) + return Descriptor.from_string(value).value +end + +function Descriptor.is_uppercase(value) + return Descriptor.from_string(value).uppercase +end + +function Descriptor.is_lowercase(value) + return not Descriptor.is_uppercase(value) +end + +function Descriptor.swap(value) + local descriptor = Descriptor.from_string(value) + local direction = descriptor.direction == M.Direction.FORWARD + and M.Direction.BACKWARD + or M.Direction.FORWARD + return Descriptor.from_parts(descriptor.family, direction) +end + +function Descriptor.lowercase(value) + local descriptor = Descriptor.from_string(value) + return Descriptor.from_parts(descriptor.family, M.Direction.FORWARD) +end + +function Descriptor.uppercase(value) + local descriptor = Descriptor.from_string(value) + return Descriptor.from_parts(descriptor.family, M.Direction.BACKWARD) +end + +local Count = {} +M.Count = Count + +register_type("Count", Count, function(data) + return tostring(data.value) +end, function(left, right) + return left.value == right.value +end) + +local count_one + +function Count.new(value) + if Count.is(value) then + return value + end + if value == nil then + value = 1 + end + if not is_integer(value) or value < 1 then + fail("count must be a positive integer", 2) + end + if value == 1 and count_one ~= nil then + return count_one + end + local count = new_record("Count", { value = value }) + if value == 1 then + count_one = count + end + return count +end + +function Count.is(value) + return is_record(value, "Count") +end + +function Count.to_number(value) + return Count.new(value).value +end + +Count.ONE = Count.new(1) + +local ModeContext = {} +M.ModeContext = ModeContext + +register_type("ModeContext", ModeContext, function(data) + return data.key +end) + +local mode_contexts = {} +local CTRL_V = string.char(0x16) +local CTRL_S = string.char(0x13) + +local operator_modes = { + no = true, + nov = true, + noV = true, + ["no" .. CTRL_V] = true, +} + +local function mode_traits(full_mode) + local operator = operator_modes[full_mode] == true + local visual_kind + local select_kind + + if not operator then + local lead = full_mode:sub(1, 1) + if lead == "v" then + visual_kind = M.SelectionKind.CHARACTER + elseif lead == "V" then + visual_kind = M.SelectionKind.LINE + elseif lead == CTRL_V then + visual_kind = M.SelectionKind.BLOCK + elseif lead == "s" then + select_kind = M.SelectionKind.CHARACTER + elseif lead == "S" then + select_kind = M.SelectionKind.LINE + elseif lead == CTRL_S then + select_kind = M.SelectionKind.BLOCK + end + end + + return operator, visual_kind, select_kind +end + +function ModeContext.from_full_mode(full_mode) + if ModeContext.is(full_mode) then + return full_mode + end + require_string(full_mode, "full_mode", false) + + local operator, visual_kind, select_kind = mode_traits(full_mode) + local key = operator and "no" or full_mode + local context = mode_contexts[key] + if context ~= nil then + return context + end + + context = new_record("ModeContext", { + key = key, + full_mode = key, + operator = operator, + visual_kind = visual_kind, + select_kind = select_kind, + visual = visual_kind ~= nil, + select = select_kind ~= nil, + command_path = visual_kind == nil, + }) + mode_contexts[key] = context + return context +end + +function ModeContext.is(value) + return is_record(value, "ModeContext") +end + +function ModeContext.equal(left, right) + require_record(left, "ModeContext", "left") + require_record(right, "ModeContext", "right") + return left.key == right.key +end + +function ModeContext:to_key() + return self.key +end + +local Selection = {} +M.Selection = Selection + +register_type("Selection", Selection, function(data) + return data.active and ("selection:" .. data.kind.value) or "selection:none" +end, function(left, right) + return left.active == right.active + and left.kind == right.kind + and left.anchor == right.anchor + and left.focus == right.focus + and left.option == right.option +end) + +function Selection.new(options) + if Selection.is(options) then + return options + end + if type(options) ~= "table" then + fail("selection options must be a table", 2) + end + + local active = require_boolean(options.active, "selection.active") + local kind = M.SelectionKind.from_string(options.kind) + local option = M.SelectionOption.from_string(options.option or "inclusive") + local anchor = options.anchor + local focus = options.focus + + if active then + if kind == M.SelectionKind.NONE then + fail("an active selection must have a selection kind", 2) + end + anchor = Position.coerce(anchor) + focus = Position.coerce(focus) + else + if kind ~= M.SelectionKind.NONE then + fail("an inactive selection must use the none kind", 2) + end + if anchor ~= nil or focus ~= nil then + fail("an inactive selection must have empty endpoints", 2) + end + end + + return new_record("Selection", { + active = active, + kind = kind, + anchor = anchor, + focus = focus, + option = option, + }) +end + +function Selection.inactive(option) + return Selection.new({ + active = false, + kind = M.SelectionKind.NONE, + option = option or M.SelectionOption.INCLUSIVE, + }) +end + +function Selection.active(kind, anchor, focus, option) + return Selection.new({ + active = true, + kind = kind, + anchor = anchor, + focus = focus, + option = option or M.SelectionOption.INCLUSIVE, + }) +end + +function Selection.is(value) + return is_record(value, "Selection") +end + +function Selection:with_focus(focus, kind) + if not self.active then + fail("selection must be active", 2) + end + return Selection.active(kind or self.kind, self.anchor, focus, self.option) +end + +function Selection:to_table() + return { + active = self.active, + kind = self.kind.value, + anchor = self.anchor and self.anchor:to_table() or nil, + focus = self.focus and self.focus:to_table() or nil, + option = self.option.value, + } +end + +local TextSnapshot = {} +M.TextSnapshot = TextSnapshot +local text_lines = setmetatable({}, { __mode = "k" }) + +register_type("TextSnapshot", TextSnapshot, function(data) + return "text:" .. tostring(data.line_count) .. " lines" +end, function(left, right) + if left.line_count ~= right.line_count then + return false + end + local left_lines = text_lines[left.identity] + local right_lines = text_lines[right.identity] + for index = 1, left.line_count do + if left_lines[index] ~= right_lines[index] then + return false + end + end + return true +end) + +function TextSnapshot.new(lines) + if TextSnapshot.is(lines) then + return lines + end + if type(lines) ~= "table" or #lines < 1 then + fail("text lines must be a nonempty list", 2) + end + local copy = {} + for index = 1, #lines do + if type(lines[index]) ~= "string" then + fail("each text line must be a string", 2) + end + copy[index] = lines[index] + end + local identity = {} + text_lines[identity] = copy + return new_record("TextSnapshot", { + identity = identity, + line_count = #copy, + }) +end + +function TextSnapshot.is(value) + return is_record(value, "TextSnapshot") +end + +function TextSnapshot:line(line_number) + if not is_integer(line_number) or line_number < 1 or line_number > self.line_count then + fail("line_number must identify a line in the text snapshot", 2) + end + return text_lines[self.identity][line_number] +end + +function TextSnapshot:lines() + local result = {} + local source = text_lines[self.identity] + for index = 1, self.line_count do + result[index] = source[index] + end + return result +end + +function TextSnapshot:to_table() + return { lines = self:lines() } +end + +local MacroState = {} +M.MacroState = MacroState + +register_type("MacroState", MacroState, function(data) + return data.executing and ("macro:" .. data.register) or "macro:inactive" +end, function(left, right) + return left.register == right.register +end) + +function MacroState.new(register) + if MacroState.is(register) then + return register + end + if register == "" then + register = nil + end + if register ~= nil then + require_string(register, "macro register", false) + end + return new_record("MacroState", { + register = register, + executing = register ~= nil, + }) +end + +function MacroState.is(value) + return is_record(value, "MacroState") +end + +local FoldState = {} +M.FoldState = FoldState +local fold_policies = setmetatable({}, { __mode = "k" }) + +register_type("FoldState", FoldState, function(data) + return "folds:" .. tostring(data.closed_levels) +end) + +function FoldState.new(open_policy, closed_levels) + if FoldState.is(open_policy) and closed_levels == nil then + return open_policy + end + if type(open_policy) ~= "table" then + fail("fold open policy must be a list", 2) + end + require_nonnegative_integer(closed_levels, "closed fold levels") + + local identity = {} + local policies = {} + local seen = {} + for index = 1, #open_policy do + local policy = require_string(open_policy[index], "fold policy item", false) + if not seen[policy] then + seen[policy] = true + policies[#policies + 1] = policy + end + end + fold_policies[identity] = { + list = policies, + set = seen, + } + return new_record("FoldState", { + identity = identity, + closed_levels = closed_levels, + }) +end + +function FoldState.is(value) + return is_record(value, "FoldState") +end + +function FoldState:opens(policy) + require_string(policy, "fold policy", false) + return fold_policies[self.identity].set[policy] == true +end + +function FoldState:policies() + local result = {} + local source = fold_policies[self.identity].list + for index = 1, #source do + result[index] = source[index] + end + return result +end + +local InputPacket = {} +M.InputPacket = InputPacket +local packet_bytes = setmetatable({}, { __mode = "k" }) + +M.InputPacketKind = define_enum("InputPacketKind", { + TEXT = "text", + RAW_BYTES = "raw_bytes", + SPECIAL_KEY = "special_key", + ERROR = "error", +}) + +register_type("InputPacket", InputPacket, function(data) + return "input:" .. data.kind.value +end) + +function InputPacket.text(text) + require_string(text, "input text", false) + return new_record("InputPacket", { + kind = M.InputPacketKind.TEXT, + text = text, + }) +end + +function InputPacket.raw_bytes(bytes) + if type(bytes) ~= "table" or #bytes < 1 then + fail("raw input bytes must be a nonempty list", 2) + 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("raw input bytes must contain byte values", 2) + end + copy[index] = byte + end + local identity = {} + packet_bytes[identity] = copy + 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) + if encoded ~= nil then + require_string(encoded, "encoded special key", false) + end + return new_record("InputPacket", { + kind = M.InputPacketKind.SPECIAL_KEY, + name = name, + encoded = encoded, + }) +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.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 then + return nil + end + local source = packet_bytes[self.identity] + 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.encoded = self.encoded + 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: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 ActionOutcome = {} +M.ActionOutcome = ActionOutcome + +register_type("ActionOutcome", ActionOutcome, function(data) + return "action:" .. data.kind.value +end) + +local function new_action_outcome(options) + local kind = M.ActionKind.from_string(options.kind) + local position = Position.coerce(options.position) + local search_outcome = options.search_outcome + local descriptor = options.effective_descriptor + local dot_payload = options.dot_payload + + if search_outcome ~= nil then + require_record(search_outcome, "SearchOutcome", "search outcome") + end + if descriptor ~= nil then + descriptor = Descriptor.from_string(descriptor) + end + if dot_payload ~= nil then + require_record(dot_payload, "DotPayload", "dot payload") + end + if options.diagnostic ~= nil then + require_string(options.diagnostic, "diagnostic", false) + end + + if kind == M.ActionKind.MOVEMENT then + if search_outcome == nil or not search_outcome.complete then + fail("a movement action requires a complete search outcome", 3) + end + elseif kind == M.ActionKind.FAILED_SEARCH then + if search_outcome == nil or search_outcome.complete then + fail("a failed-search action requires an incomplete search outcome", 3) + end + elseif search_outcome ~= nil then + fail("only movement and failed-search actions can contain a search outcome", 3) + end + + if kind == M.ActionKind.ERROR and options.diagnostic == nil then + fail("an error action requires a diagnostic", 3) + end + + local complete + if search_outcome ~= nil then + complete = search_outcome.complete + end + + return new_record("ActionOutcome", { + kind = kind, + position = position, + search_outcome = search_outcome, + complete = complete, + successful_steps = search_outcome and search_outcome.successful_steps or 0, + effective_descriptor = descriptor, + dot_payload = dot_payload, + diagnostic = options.diagnostic, + }) +end + +function ActionOutcome.new(options) + if ActionOutcome.is(options) then + return options + end + if type(options) ~= "table" then + fail("action outcome options must be a table", 2) + end + return new_action_outcome(options) +end + +function ActionOutcome.from_search(search_outcome, descriptor, dot_payload) + require_record(search_outcome, "SearchOutcome", "search outcome") + return new_action_outcome({ + kind = search_outcome.complete and M.ActionKind.MOVEMENT or M.ActionKind.FAILED_SEARCH, + position = search_outcome.endpoint, + search_outcome = search_outcome, + effective_descriptor = descriptor, + dot_payload = dot_payload, + }) +end + +local function simple_action(kind, position, diagnostic) + return new_action_outcome({ + kind = kind, + position = position, + diagnostic = diagnostic, + }) +end + +function ActionOutcome.neutral(position) + return simple_action(M.ActionKind.NEUTRAL, position) +end + +function ActionOutcome.escape(position) + return simple_action(M.ActionKind.ESCAPE, position) +end + +function ActionOutcome.empty(position) + return simple_action(M.ActionKind.EMPTY, position) +end + +function ActionOutcome.error(position, diagnostic) + return simple_action(M.ActionKind.ERROR, position, diagnostic) +end + +function ActionOutcome.is(value) + return is_record(value, "ActionOutcome") +end + +function ActionOutcome:to_table() + return { + kind = self.kind.value, + position = self.position:to_table(), + complete = self.complete, + successful_steps = self.successful_steps, + effective_descriptor = self.effective_descriptor and self.effective_descriptor.value or nil, + dot_payload = self.dot_payload and self.dot_payload:to_table() or nil, + diagnostic = self.diagnostic, + } +end + +return M diff --git a/lua/clever_f/testing/memory_host.lua b/lua/clever_f/testing/memory_host.lua new file mode 100644 index 0000000..726d244 --- /dev/null +++ b/lua/clever_f/testing/memory_host.lua @@ -0,0 +1,825 @@ +local capabilities = require("clever_f.capabilities") +local domain = require("clever_f.domain") + +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 { "" }), + _cursor = domain.Position.coerce(options.cursor or { line = 1, byte_column = 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", + _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 = {}, + _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_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:read_encoding() + self:_record("read_encoding", { encoding = self._encoding }) + return self._encoding +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_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 + +function MemoryHost:apply_cursor(position) + position = domain.Position.coerce(position) + local previous = self._cursor + self._cursor = position + self:_record("apply_cursor", { position = position }) + 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: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: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) + for _, identity in ipairs(order) do + local registration = self._event_registrations[identity] + if registration.active and registration.name_set[name] 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, + } + 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 + return self._dot_repeat.callback(self._dot_repeat.payload, domain.Count.new(count)) +end + +return M |
