summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Makefile6
-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
-rw-r--r--tests/run.lua573
6 files changed, 2844 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..345e270
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/nvim.log
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b1faf93
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,6 @@
+NVIM ?= nvim
+
+.PHONY: test
+
+test:
+ NVIM_LOG_FILE=/dev/null $(NVIM) --headless -u NONE -i NONE -l tests/run.lua
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
diff --git a/tests/run.lua b/tests/run.lua
new file mode 100644
index 0000000..6a42d00
--- /dev/null
+++ b/tests/run.lua
@@ -0,0 +1,573 @@
+local script = debug.getinfo(1, "S").source:sub(2)
+local root = script:match("^(.*)/tests/run%.lua$") or "."
+package.path = table.concat({
+ root .. "/lua/?.lua",
+ root .. "/lua/?/init.lua",
+ package.path,
+}, ";")
+
+local domain = require("clever_f.domain")
+local capabilities = require("clever_f.capabilities")
+local MemoryHost = require("clever_f.testing.memory_host")
+
+local tests = {}
+local passed = 0
+
+local function test(name, body)
+ tests[#tests + 1] = { name = name, body = body }
+end
+
+local function same(expected, actual, message)
+ if expected ~= actual then
+ error((message or "values differ")
+ .. ": expected " .. tostring(expected)
+ .. ", got " .. tostring(actual), 2)
+ end
+end
+
+local function truthy(value, message)
+ if not value then
+ error(message or "value must be true", 2)
+ end
+end
+
+local function falsy(value, message)
+ if value then
+ error(message or "value must be false", 2)
+ end
+end
+
+local function fails(body, expected_text)
+ local ok, failure = pcall(body)
+ if ok then
+ error("operation must fail", 2)
+ end
+ if expected_text and not tostring(failure):find(expected_text, 1, true) then
+ error("failure does not contain '" .. expected_text .. "': " .. tostring(failure), 2)
+ end
+end
+
+local function list_same(expected, actual)
+ same(#expected, #actual, "list lengths differ")
+ for index = 1, #expected do
+ same(expected[index], actual[index], "list item " .. tostring(index) .. " differs")
+ end
+end
+
+test("Position validates one-based byte coordinates", function()
+ local position = domain.Position.new(1, 1)
+ same(1, position.line)
+ same(1, position.byte_column)
+ same("Position", domain.type_of(position))
+ fails(function()
+ domain.Position.new(0, 1)
+ end, "line")
+ fails(function()
+ domain.Position.new(1, 0)
+ end, "byte_column")
+ fails(function()
+ domain.Position.new(1.5, 1)
+ end, "line")
+ fails(function()
+ position.line = 2
+ end, "immutable")
+end)
+
+test("Position comparison is lexicographic and detects stationarity", function()
+ local first = domain.Position.new(1, 9)
+ local later_column = domain.Position.new(1, 10)
+ local later_line = domain.Position.new(2, 1)
+ local same_place = domain.Position.new(1, 9)
+
+ same(-1, domain.Position.compare(first, later_column))
+ same(-1, domain.Position.compare(later_column, later_line))
+ same(1, domain.Position.compare(later_line, first))
+ truthy(domain.Position.is_forward(later_line, first))
+ truthy(domain.Position.is_backward(first, later_line))
+ truthy(domain.Position.stationary(first, same_place))
+ falsy(domain.Position.is_forward(first, same_place))
+ falsy(domain.Position.is_backward(first, same_place))
+ truthy(first == same_place)
+
+ local serialized = first:to_table()
+ same(2, (function()
+ local count = 0
+ for _ in pairs(serialized) do
+ count = count + 1
+ end
+ return count
+ end)())
+end)
+
+test("Descriptor conversion defines all families and directions", function()
+ local cases = {
+ { "f", domain.Family.FIND, domain.Direction.FORWARD, false },
+ { "F", domain.Family.FIND, domain.Direction.BACKWARD, true },
+ { "t", domain.Family.TILL, domain.Direction.FORWARD, false },
+ { "T", domain.Family.TILL, domain.Direction.BACKWARD, true },
+ }
+
+ for _, case in ipairs(cases) do
+ local descriptor = domain.Descriptor.from_string(case[1])
+ same(case[1], domain.Descriptor.to_string(descriptor))
+ same(case[2], descriptor.family)
+ same(case[3], descriptor.direction)
+ same(case[4], domain.Descriptor.is_uppercase(descriptor))
+ same(descriptor, domain.Descriptor.from_parts(case[2], case[3]))
+ same(descriptor, domain.Descriptor.swap(domain.Descriptor.swap(descriptor)))
+ end
+
+ same(domain.Descriptor.FIND_BACKWARD, domain.Descriptor.swap("f"))
+ same(domain.Descriptor.TILL_FORWARD, domain.Descriptor.swap("T"))
+ same(domain.Descriptor.FIND_FORWARD, domain.Descriptor.lowercase("F"))
+ same(domain.Descriptor.TILL_BACKWARD, domain.Descriptor.uppercase("t"))
+ falsy(domain.Descriptor.is_valid("x"))
+ fails(function()
+ domain.Descriptor.from_string("x")
+ end, "f, F, t, or T")
+end)
+
+test("Count normalizes absent input and rejects nonpositive values", function()
+ same(domain.Count.ONE, domain.Count.new(nil))
+ same(1, domain.Count.to_number(nil))
+ same(3, domain.Count.to_number(domain.Count.new(3)))
+ fails(function()
+ domain.Count.new(0)
+ end, "positive")
+ fails(function()
+ domain.Count.new(-1)
+ end, "positive")
+ fails(function()
+ domain.Count.new(1.5)
+ end, "positive")
+end)
+
+test("ModeContext normalizes every operator-pending variant", function()
+ local operator = domain.ModeContext.from_full_mode("no")
+ local variants = {
+ "no",
+ "nov",
+ "noV",
+ "no" .. string.char(0x16),
+ }
+ for _, mode in ipairs(variants) do
+ local context = domain.ModeContext.from_full_mode(mode)
+ same(operator, context)
+ same("no", context.key)
+ truthy(context.operator)
+ truthy(context.command_path)
+ end
+end)
+
+test("ModeContext preserves other full strings and exposes mode traits", function()
+ local normal = domain.ModeContext.from_full_mode("n")
+ local insert_normal = domain.ModeContext.from_full_mode("niI")
+ local other_full_mode = domain.ModeContext.from_full_mode("normal-extension")
+ local visual_character = domain.ModeContext.from_full_mode("v")
+ local visual_line = domain.ModeContext.from_full_mode("V")
+ local visual_block = domain.ModeContext.from_full_mode(string.char(0x16))
+ local visual_from_select = domain.ModeContext.from_full_mode("vs")
+ local select_character = domain.ModeContext.from_full_mode("s")
+ local select_line = domain.ModeContext.from_full_mode("S")
+ local select_block = domain.ModeContext.from_full_mode(string.char(0x13))
+
+ same("n", normal.key)
+ same("niI", insert_normal.key)
+ same("normal-extension", other_full_mode.key)
+ falsy(other_full_mode.operator)
+ falsy(normal == insert_normal)
+ same(domain.SelectionKind.CHARACTER, visual_character.visual_kind)
+ same(domain.SelectionKind.LINE, visual_line.visual_kind)
+ same(domain.SelectionKind.BLOCK, visual_block.visual_kind)
+ same(domain.SelectionKind.CHARACTER, visual_from_select.visual_kind)
+ falsy(visual_character.command_path)
+ same(domain.SelectionKind.CHARACTER, select_character.select_kind)
+ same(domain.SelectionKind.LINE, select_line.select_kind)
+ same(domain.SelectionKind.BLOCK, select_block.select_kind)
+ truthy(select_character.command_path)
+ falsy(select_character.visual)
+ truthy(select_character.select)
+end)
+
+test("Selection and text snapshots preserve semantic input values", function()
+ local anchor = domain.Position.new(1, 1)
+ local focus = domain.Position.new(1, 3)
+ local selection = domain.Selection.active(
+ domain.SelectionKind.CHARACTER,
+ anchor,
+ focus,
+ domain.SelectionOption.EXCLUSIVE
+ )
+ truthy(selection.active)
+ same(domain.SelectionKind.CHARACTER, selection.kind)
+ same(domain.SelectionOption.EXCLUSIVE, selection.option)
+ same(domain.Position.new(1, 4), selection:with_focus(domain.Position.new(1, 4)).focus)
+
+ local inactive = domain.Selection.inactive()
+ falsy(inactive.active)
+ same(domain.SelectionKind.NONE, inactive.kind)
+
+ local text = domain.TextSnapshot.new({ "alpha", "beta" })
+ same(2, text.line_count)
+ same("alpha", text:line(1))
+ local lines = text:lines()
+ lines[1] = "changed"
+ same("alpha", text:line(1))
+end)
+
+test("TargetValue represents characters, special keys, and code fallback", function()
+ local character = domain.TargetValue.character("a", 97)
+ local special = domain.TargetValue.special_key(string.char(0x80, 0xfd, 0x01))
+ local fallback = domain.TargetValue.code_fallback()
+
+ same(domain.TargetKind.CHARACTER, character.kind)
+ same("a", character.value)
+ same(97, character.first_code)
+ same(domain.TargetKind.SPECIAL_KEY, special.kind)
+ same(0x80, special.first_code)
+ same(domain.TargetKind.CODE_FALLBACK, fallback.kind)
+ same(0, fallback.first_code)
+ same("", fallback.value)
+ fails(function()
+ domain.TargetValue.special_key("x")
+ end, "hexadecimal 80")
+ fails(function()
+ character.value = "b"
+ end, "immutable")
+end)
+
+test("InputPacket carries every input capability variant", function()
+ local text = domain.InputPacket.text("a")
+ local bytes = domain.InputPacket.raw_bytes({ 0x80, 0xfd, 0x60 })
+ local special = domain.InputPacket.special_key("Left", string.char(0x80, 1))
+ local failure = domain.InputPacket.error("input failed")
+
+ same(domain.InputPacketKind.TEXT, text.kind)
+ list_same({ 0x80, 0xfd, 0x60 }, bytes:bytes())
+ same("Left", special.name)
+ same("input failed", failure.message)
+end)
+
+test("Plans and requests carry typed immutable motion data", function()
+ local target = domain.TargetValue.character("h", 104)
+ local target_plan = domain.TargetPlan.new({
+ target = target,
+ kind = domain.TargetPlanKind.LITERAL,
+ case_mode = domain.CaseMode.SENSITIVE,
+ matcher = function(character)
+ return character == "h"
+ end,
+ })
+ truthy(target_plan:matches("h"))
+ falsy(target_plan:matches("H"))
+
+ local motion_plan = domain.ResolvedMotionPlan.new({
+ target_plan = target_plan,
+ descriptor = "f",
+ search_scope = domain.SearchScope.BUFFER,
+ endpoint_policy = domain.EndpointPolicy.REGULAR,
+ })
+ same(domain.Descriptor.FIND_FORWARD, motion_plan.descriptor)
+ same(target_plan, motion_plan.target_plan)
+
+ local policy = { search_current_line_only = false }
+ local request = domain.MotionRequest.new({
+ context = domain.ModeContext.from_full_mode("n"),
+ origin = domain.Position.new(1, 1),
+ descriptor = "f",
+ target = target,
+ count = nil,
+ policy = policy,
+ first_move = true,
+ })
+ same(1, request.count.value)
+ truthy(request.first_move)
+ same(policy, request.policy)
+ fails(function()
+ motion_plan.descriptor = domain.Descriptor.FIND_BACKWARD
+ end, "immutable")
+end)
+
+test("SearchOutcome distinguishes complete, partial, and first-step failure", function()
+ local origin = domain.Position.new(1, 1)
+ local reached = domain.Position.new(1, 5)
+ local complete = domain.SearchOutcome.complete(reached, 3)
+ local partial = domain.SearchOutcome.boundary_after_partial(reached, 2)
+ local before = domain.SearchOutcome.boundary_before_any(origin)
+
+ truthy(complete.complete)
+ same(domain.SearchStatus.COMPLETE, complete.status)
+ falsy(partial.complete)
+ same(domain.SearchStatus.BOUNDARY_AFTER_PARTIAL, partial.status)
+ same(2, partial.successful_steps)
+ same(origin, before.endpoint)
+ same(0, before.successful_steps)
+ fails(function()
+ domain.SearchOutcome.complete(reached, 0)
+ end, "successful step")
+end)
+
+test("ActionOutcome and DotPayload retain resolved result data", function()
+ local target = domain.TargetValue.character("e", 101)
+ local payload = domain.DotPayload.new("t", target)
+ local destination = domain.Position.new(1, 4)
+ local complete = domain.SearchOutcome.complete(destination, 1)
+ local movement = domain.ActionOutcome.from_search(complete, "t", payload)
+
+ same(domain.ActionKind.MOVEMENT, movement.kind)
+ same(destination, movement.position)
+ truthy(movement.complete)
+ same(1, movement.successful_steps)
+ same(domain.Descriptor.TILL_FORWARD, movement.effective_descriptor)
+ same(payload, movement.dot_payload)
+ same("t", payload:to_table().descriptor)
+
+ local failed = domain.ActionOutcome.from_search(
+ domain.SearchOutcome.boundary_before_any(destination),
+ "T"
+ )
+ same(domain.ActionKind.FAILED_SEARCH, failed.kind)
+ same(false, failed.complete)
+ same(domain.ActionKind.NEUTRAL, domain.ActionOutcome.neutral(destination).kind)
+ same(domain.ActionKind.ESCAPE, domain.ActionOutcome.escape(destination).kind)
+ same(domain.ActionKind.EMPTY, domain.ActionOutcome.empty(destination).kind)
+ same("problem", domain.ActionOutcome.error(destination, "problem").diagnostic)
+end)
+
+test("Capability contract reports every semantic method", function()
+ local host = MemoryHost.new()
+ same(host, capabilities.assert_implements(host))
+ same(0, #capabilities.missing_methods(host))
+ local required = capabilities.required_methods()
+ truthy(#required >= 30)
+
+ local incomplete = {}
+ local missing = capabilities.missing_methods(incomplete)
+ truthy(#missing == #required)
+ fails(function()
+ capabilities.assert_implements(incomplete)
+ end, "semantic capabilities")
+end)
+
+test("MemoryHost supplies all semantic reads with domain values", function()
+ local host = MemoryHost.new({
+ buffer_lines = { "alpha", "beta" },
+ cursor = { line = 2, byte_column = 2 },
+ mode = "nov",
+ selection = {
+ active = false,
+ kind = "none",
+ anchor = nil,
+ focus = nil,
+ option = "exclusive",
+ },
+ count = 3,
+ configuration = {
+ enabled = false,
+ triggers = { "x", "y" },
+ },
+ effective_encoding = "cp932",
+ macro_register = "q",
+ fold_open_policy = { "horizontal" },
+ closed_fold_levels = 2,
+ pending_operator = "delete",
+ time_values_ms = { 10.5, 12 },
+ })
+
+ same("alpha", host:read_text():line(1))
+ same(domain.Position.new(2, 2), host:read_cursor())
+ same("nov", host:read_mode())
+ same("no", host:read_mode_context().key)
+ same("delete", host:read_pending_operator())
+ same(domain.SelectionOption.EXCLUSIVE, host:read_selection().option)
+ same(3, host:read_count().value)
+ truthy(host:configuration_present("enabled"))
+ same(false, host:read_configuration("enabled"))
+ local triggers = host:read_configuration("triggers")
+ triggers[1] = "changed"
+ same("x", host:read_configuration("triggers")[1])
+ same("cp932", host:read_encoding())
+ truthy(host:read_macro_state().executing)
+ truthy(host:read_fold_state():opens("horizontal"))
+ same(2, host:read_fold_state().closed_levels)
+ same(10.5, host:read_time_ms())
+ same(12, host:read_time_ms())
+end)
+
+test("MemoryHost queues movement events until action state commits", function()
+ local host = MemoryHost.new({
+ buffer_lines = { "abc" },
+ cursor = { line = 1, byte_column = 1 },
+ })
+ local state = { committed = false }
+ local observations = {}
+ host:register_events("CursorMoved", function(name, payload)
+ observations[#observations + 1] = {
+ name = name,
+ position = payload.cursor,
+ committed = state.committed,
+ }
+ end)
+
+ local token = host:begin_action_transition()
+ host:apply_cursor(domain.Position.new(1, 2))
+ same(1, host:pending_event_count())
+ same(0, #observations)
+ state.committed = true
+ host:commit_action_transition(token)
+
+ same(0, host:pending_event_count())
+ same(1, #observations)
+ same("CursorMoved", observations[1].name)
+ same(domain.Position.new(1, 2), observations[1].position)
+ truthy(observations[1].committed)
+end)
+
+test("MemoryHost action invocation flushes events after the action callback", function()
+ local host = MemoryHost.new({ buffer_lines = { "abc" } })
+ local state = { landing = nil }
+ local observed_landing
+ host:register_events("CursorMoved", function()
+ observed_landing = state.landing
+ end)
+ host:register_action("Move", function()
+ local destination = domain.Position.new(1, 3)
+ host:apply_cursor(destination)
+ state.landing = destination
+ return "moved"
+ end)
+
+ same("moved", host:invoke_action("Move"))
+ same(domain.Position.new(1, 3), observed_landing)
+end)
+
+test("MemoryHost models movement, input, folds, and visible effects", function()
+ local host = MemoryHost.new({
+ buffer_lines = { "abcd" },
+ cursor = { line = 1, byte_column = 1 },
+ selection = {
+ active = true,
+ kind = "character",
+ anchor = { line = 1, byte_column = 1 },
+ focus = { line = 1, byte_column = 1 },
+ option = "inclusive",
+ },
+ input_packets = {
+ { kind = "text", text = "d" },
+ { kind = "raw_bytes", bytes = { 0x80, 0xfd, 0x60 } },
+ { kind = "error", message = "read failed" },
+ },
+ fold_open_policy = { "all" },
+ closed_fold_levels = 2,
+ })
+
+ host:apply_selection(domain.Position.new(1, 2), domain.SelectionKind.CHARACTER)
+ same(domain.Position.new(1, 2), host:read_cursor())
+ same(domain.Position.new(1, 2), host:read_selection().focus)
+ host:set_operator_inclusive(true)
+ truthy(host:operator_inclusive())
+
+ same("d", host:read_input().text)
+ list_same({ 0x80, 0xfd, 0x60 }, host:read_input():bytes())
+ fails(function()
+ host:read_input()
+ end, "read failed")
+
+ truthy(host:open_fold())
+ truthy(host:open_fold())
+ falsy(host:open_fold())
+ same(0, host:read_fold_state().closed_levels)
+
+ host:show_prompt("clever-f: ")
+ host:redraw("screen")
+ host:redraw("full")
+ host:emit_diagnostic("error", "problem")
+ same("clever-f: ", host:prompts()[1])
+ list_same({ "screen", "full" }, host:redraws())
+ same("problem", host:diagnostics()[1].text)
+end)
+
+test("MemoryHost manages highlight and cursor-presentation resources", function()
+ local presentation = {
+ guicursor = "n-v:block",
+ terminal_cursor_visible = true,
+ hidden = false,
+ }
+ local host = MemoryHost.new({ cursor_presentation = presentation })
+ local highlight = host:create_highlight({
+ group = "CleverFChar",
+ identity = "char-1",
+ position = domain.Position.new(1, 1),
+ priority = "high",
+ })
+ same("char-1", highlight)
+ truthy(host:highlights()[highlight] ~= nil)
+ truthy(host:remove_highlight(highlight))
+ falsy(host:remove_highlight(highlight))
+
+ local lease = host:suppress_cursor_presentation()
+ truthy(host:cursor_presentation().hidden)
+ truthy(host:restore_cursor_presentation(lease))
+ local restored = host:cursor_presentation()
+ same(presentation.guicursor, restored.guicursor)
+ same(presentation.terminal_cursor_visible, restored.terminal_cursor_visible)
+ same(presentation.hidden, restored.hidden)
+end)
+
+test("MemoryHost manages timers, events, mappings, and dot repeat", function()
+ local host = MemoryHost.new()
+ local fired
+ truthy(host:supports_timers())
+ local timer = host:start_timer(25, function(identity)
+ fired = identity
+ end)
+ truthy(host:timers()[timer].active)
+ truthy(host:fire_timer(timer))
+ same(timer, fired)
+ falsy(host:timers()[timer].active)
+ falsy(host:stop_timer(timer))
+
+ local delivered
+ local registration = host:register_events({ "InsertEnter", "TextChanged" }, function(name)
+ delivered = name
+ end, { buffer = "buffer-1" })
+ host:deliver_event("InsertEnter", {})
+ same("InsertEnter", delivered)
+ truthy(host:remove_event_registration(registration))
+ delivered = nil
+ host:deliver_event("TextChanged", {})
+ same(nil, delivered)
+
+ host:register_action("Neutral", function()
+ return domain.ActionOutcome.neutral(domain.Position.new(1, 1))
+ end)
+ local mapping = host:register_mapping(
+ { "n", "x", "o" },
+ "f",
+ "Neutral",
+ { silent = true, remap = false }
+ )
+ same("f", host:mappings()[mapping].lhs)
+ truthy(host:mappings()[mapping].options.silent)
+
+ local target = domain.TargetValue.character("a", 97)
+ local payload = domain.DotPayload.new("f", target)
+ host:register_dot_repeat(payload, function(replayed, count)
+ return replayed, count
+ end)
+ same(payload, host:dot_repeat_payload())
+ local replayed, count = host:replay_dot(2)
+ same(payload, replayed)
+ same(2, count.value)
+end)
+
+for _, item in ipairs(tests) do
+ local ok, failure = xpcall(item.body, debug.traceback)
+ if not ok then
+ io.stderr:write("FAIL: " .. item.name .. "\n" .. tostring(failure) .. "\n")
+ os.exit(1)
+ end
+ passed = passed + 1
+end
+
+io.stdout:write(string.format("Phase 2: %d tests passed\n", passed))