summaryrefslogtreecommitdiff
path: root/lua/clever_f/testing/memory_host.lua
diff options
context:
space:
mode:
authorJackson Moore <jacksonmoore@tuta.io>2026-09-03 18:22:01 +0200
committerJackson Moore <jacksonmoore@tuta.io>2026-09-03 21:31:43 +0200
commit66d5e304d5c543a264bffac6d72655c46a6155e1 (patch)
tree186992c5439e9c7e9efa61c6e3d73e88f5f920d7 /lua/clever_f/testing/memory_host.lua
parent48a5fb1d753c252780e0a267e1cba37485037fc8 (diff)
Defines motion domain and host capabilities
Diffstat (limited to 'lua/clever_f/testing/memory_host.lua')
-rw-r--r--lua/clever_f/testing/memory_host.lua825
1 files changed, 825 insertions, 0 deletions
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