summaryrefslogtreecommitdiff
path: root/lua/clever_f
diff options
context:
space:
mode:
Diffstat (limited to 'lua/clever_f')
-rw-r--r--lua/clever_f/capabilities.lua154
-rw-r--r--lua/clever_f/domain.lua1285
-rw-r--r--lua/clever_f/testing/memory_host.lua825
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