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 destination_engine = require("clever_f.destination_engine") local case_policy = require("clever_f.case_policy") local policy = require("clever_f.policy") local migemo_catalog = require("clever_f.migemo_catalog") local motion_plan = require("clever_f.motion_plan") local motion_executor = require("clever_f.motion_executor") local sequence_state = require("clever_f.sequence_state") local state_transitions = require("clever_f.state_transitions") local text_topology = require("clever_f.text_topology") local target_plan = require("clever_f.target_plan") 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) test("Policy schema contains every default and color target", function() local defaults = policy.defaults() local false_settings = { "search_current_line_only", "ignore_case", "smart_case", "use_migemo", "fix_key_direction", "show_prompt", "mark_direct", } local true_settings = { "mark_cursor", "hide_cursor_on_cmdline", "mark_char", "clean_labels_eagerly", } for _, name in ipairs(false_settings) do same(false, defaults[name], name) end for _, name in ipairs(true_settings) do same(true, defaults[name], name) end same("", defaults.chars_match_any_signs) same(0, defaults.repeat_timeout_ms) same(0, defaults.highlight_timeout_ms) list_same({ "\r" }, defaults.repeat_last_char_inputs) same(nil, policy.default("mark_cursor_color")) same(nil, policy.default("mark_char_color")) same(nil, policy.default("mark_direct_color")) local schema = policy.schema() same("Cursor", schema.mark_cursor_color.default_target) same("CleverFDefaultLabel", schema.mark_char_color.default_target) same("CleverFDefaultLabel", schema.mark_direct_color.default_target) same(policy.ValueType.OPTIONAL_GROUP_NAME, schema.mark_cursor_color.value_type) same(policy.ValueType.PRESENCE, schema[policy.DEFAULT_MAP_SUPPRESSION_SENTINEL].value_type) defaults.repeat_last_char_inputs[1] = "changed" schema.repeat_last_char_inputs.default[1] = "changed" list_same({ "\r" }, policy.default("repeat_last_char_inputs")) end) test("Policy typed accessors validate semantic values", function() local host = MemoryHost.new({ configuration = { search_current_line_only = true, chars_match_any_signs = ";:", repeat_last_char_inputs = { "x", "" }, mark_cursor_color = "IncSearch", repeat_timeout_ms = 25, }, }) local service = policy.new(host) truthy(service:get_boolean("search_current_line_only")) same(";:", service:get_string("chars_match_any_signs")) list_same({ "x", "" }, service:get_string_list("repeat_last_char_inputs")) same("IncSearch", service:get_optional_group_name("mark_cursor_color")) same(25, service:get_nonnegative_integer("repeat_timeout_ms")) same(nil, service:get_optional_group_name("mark_char_color")) local inputs = service:get_string_list("repeat_last_char_inputs") inputs[1] = "changed" same("x", service:get_string_list("repeat_last_char_inputs")[1]) host:set_configuration("ignore_case", 1) fails(function() service:get_boolean("ignore_case") end, "must be a Boolean") host:set_configuration("chars_match_any_signs", {}) fails(function() service:get_string("chars_match_any_signs") end, "must be a string") host:set_configuration("repeat_last_char_inputs", { "x", 2 }) fails(function() service:get_string_list("repeat_last_char_inputs") end, "list of strings") host:set_configuration("mark_cursor_color", "") fails(function() service:get_optional_group_name("mark_cursor_color") end, "optional group name") host:set_configuration("repeat_timeout_ms", -1) fails(function() service:get_nonnegative_integer("repeat_timeout_ms") end, "nonnegative integer") host:set_configuration("repeat_timeout_ms", 1.5) fails(function() service:get_nonnegative_integer("repeat_timeout_ms") end, "nonnegative integer") fails(function() service:get_string("ignore_case") end, "does not have type string") fails(function() service:get("unknown") end, "unknown policy setting") end) test("Default-map suppression is presence-based and activation values are retained", function() local absent_host = MemoryHost.new() local absent_policy = policy.new(absent_host) falsy(absent_policy:default_maps_suppressed()) truthy(absent_policy:capture_activation().install_default_mappings) for _, sentinel_value in ipairs({ false, 0 }) do local configuration = { clean_labels_eagerly = false, } configuration[policy.DEFAULT_MAP_SUPPRESSION_SENTINEL] = sentinel_value local host = MemoryHost.new({ configuration = configuration }) local service = policy.new(host) host:clear_operations() truthy(service:default_maps_suppressed()) local operations = host:operations() same(1, #operations) same("configuration_present", operations[1].operation) local activation = service:capture_activation() falsy(activation.install_default_mappings) falsy(activation.clean_labels_eagerly) host:unset_configuration(policy.DEFAULT_MAP_SUPPRESSION_SENTINEL) host:set_configuration("clean_labels_eagerly", true) local retained = service:capture_activation() falsy(retained.install_default_mappings) falsy(retained.clean_labels_eagerly) local next_activation = policy.new(host):capture_activation() truthy(next_activation.install_default_mappings) truthy(next_activation.clean_labels_eagerly) end end) test("Runtime policy samples read current behavior values", function() local host = MemoryHost.new() local service = policy.new(host) local target = domain.TargetValue.character("a", 97) same(domain.SearchScope.BUFFER, service:sample_search().search_scope) same(domain.CaseMode.SENSITIVE, service:sample_match(target).case_mode) falsy(service:sample_direction().fix_key_direction) falsy(service:sample_acquisition().show_prompt) truthy(service:sample_markers().mark_char) same(0, service:sample_timeouts().repeat_timeout_ms) list_same({ "\r" }, service:sample_previous_input().repeat_last_char_inputs) host:set_configuration("search_current_line_only", true) host:set_configuration("ignore_case", true) host:set_configuration("smart_case", true) host:set_configuration("use_migemo", true) host:set_configuration("chars_match_any_signs", ";") host:set_configuration("fix_key_direction", true) host:set_configuration("show_prompt", true) host:set_configuration("mark_cursor", false) host:set_configuration("hide_cursor_on_cmdline", false) host:set_configuration("mark_char", false) host:set_configuration("mark_direct", true) host:set_configuration("repeat_timeout_ms", 75) host:set_configuration("highlight_timeout_ms", 125) host:set_configuration("repeat_last_char_inputs", { "x", "yz" }) local search = service:sample_search() truthy(search.search_current_line_only) same(domain.SearchScope.CURRENT_LINE, search.search_scope) local match = service:sample_match(target) truthy(match.ignore_case) truthy(match.smart_case) truthy(match.use_migemo) same(";", match.chars_match_any_signs) same(domain.CaseMode.INSENSITIVE, match.case_mode) truthy(service:sample_direction().fix_key_direction) local acquisition = service:sample_acquisition() truthy(acquisition.show_prompt) falsy(acquisition.mark_cursor) falsy(acquisition.hide_cursor_on_cmdline) truthy(acquisition.mark_direct) local markers = service:sample_markers() falsy(markers.mark_cursor) falsy(markers.mark_char) truthy(markers.mark_direct) local timeouts = service:sample_timeouts() same(75, timeouts.repeat_timeout_ms) same(125, timeouts.highlight_timeout_ms) list_same({ "x", "yz" }, service:sample_previous_input().repeat_last_char_inputs) end) test("Highlight policy is reevaluated with live feature and color values", function() local host = MemoryHost.new() local service = policy.new(host) service:capture_activation() local initial = service:evaluate_highlight_links() truthy(initial.CleverFCursor.enabled) same(nil, initial.CleverFCursor.configured_target) same("Cursor", initial.CleverFCursor.target) truthy(initial.CleverFChar.enabled) same("CleverFDefaultLabel", initial.CleverFChar.target) falsy(initial.CleverFDirect.enabled) same("CleverFDefaultLabel", initial.CleverFDirect.target) host:set_configuration("mark_cursor", false) host:set_configuration("mark_cursor_color", "Search") host:set_configuration("mark_char", false) host:set_configuration("mark_char_color", "ErrorMsg") host:set_configuration("mark_direct", true) host:set_configuration("mark_direct_color", "IncSearch") local refreshed = service:evaluate_highlight_links() falsy(refreshed.CleverFCursor.enabled) same("Search", refreshed.CleverFCursor.target) falsy(refreshed.CleverFChar.enabled) same("ErrorMsg", refreshed.CleverFChar.target) truthy(refreshed.CleverFDirect.enabled) same("IncSearch", refreshed.CleverFDirect.target) end) test("Case policy resolves an explicit mode for each target", function() local host = MemoryHost.new() local service = policy.new(host) local lower = domain.TargetValue.character("a", 97) local upper = domain.TargetValue.character("A", 65) local multibyte = domain.TargetValue.character("\227\129\130", 0x3042) same(domain.CaseMode.SENSITIVE, service:case_mode(lower)) host:set_configuration("smart_case", true) same(domain.CaseMode.INSENSITIVE, service:case_mode(lower)) same(domain.CaseMode.SENSITIVE, service:case_mode(upper)) same(domain.CaseMode.SENSITIVE, service:case_mode(multibyte)) host:set_configuration("ignore_case", true) same(domain.CaseMode.INSENSITIVE, service:case_mode(upper)) local match = service:sample_match(upper) local plan = domain.TargetPlan.new({ target = upper, kind = domain.TargetPlanKind.LITERAL, case_mode = match.case_mode, matcher = function() return true end, }) same(domain.CaseMode.INSENSITIVE, plan.case_mode) end) test("Unsupported Migemo policy mutation updates live configuration", function() local host = MemoryHost.new({ configuration = { use_migemo = true }, }) local service = policy.new(host) truthy(service:get_boolean("use_migemo")) host:clear_operations() service:disable_migemo_for_unsupported_encoding() local operations = host:operations() same(1, #operations) same("write_configuration", operations[1].operation) same("use_migemo", operations[1].name) same(false, operations[1].value) falsy(service:get_boolean("use_migemo")) host:set_configuration("use_migemo", true) truthy(service:get_boolean("use_migemo")) end) local function fresh_sequence_state() local transitions = state_transitions.new() transitions:ClearTemporaryOverlays() transitions:DiagnosticFullReset() return sequence_state.get(), transitions end local function map_size(values) local count = 0 for _ in pairs(values) do count = count + 1 end return count end test("SequenceState has one empty plugin-global instance", function() local state, transitions = fresh_sequence_state() same(state, sequence_state.new()) same(state, sequence_state.global) same(state, transitions:state()) same(state, state_transitions.new():state()) same(0, map_size(state.previous_descriptor)) same(0, map_size(state.previous_landing)) same(0, map_size(state.first_move)) same(0, map_size(state.previous_target)) same(0, map_size(state.migemo_cache)) same(nil, state.last_input_context) falsy(state.moved_forward) falsy(state.moved_forward_initialized) same(0, state.repeat_timestamp_ms) same(1, state.repeat_timestamp_ms + 1) same(nil, state.highlight_timer) same(0, #state.target_overlays) same(0, #state.temporary_overlays) same(0, #state.finalizers) end) test("Per-context transitions use normalized ModeContext keys", function() local state, transitions = fresh_sequence_state() local operator = domain.ModeContext.from_full_mode("no") local target = domain.TargetValue.character("x", 120) transitions:BeginAcquisition("nov", "F") transitions:CommitAcquiredTarget("no" .. string.char(0x16), target, 12.5) same(domain.Descriptor.FIND_BACKWARD, state:get_previous_descriptor("noV")) truthy(state:get_first_move(operator)) same(target, state:get_previous_target("no")) same(operator, state.last_input_context) same(12.5, state.repeat_timestamp_ms) same(1, map_size(state.previous_descriptor)) same(domain.Descriptor.FIND_BACKWARD, state.previous_descriptor[operator]) transitions:CommitCommandSuccess("noV", { line = 3, byte_column = 6 }, false) local serialized = state:to_table() same(3, serialized.contexts.no.previous_landing.line) same(6, serialized.contexts.no.previous_landing.byte_column) same(2, map_size(serialized.contexts.no.previous_landing)) same(nil, serialized.contexts.nov) local normal_target = domain.TargetValue.character("a", 97) local visual_target = domain.TargetValue.character("b", 98) transitions:BeginAcquisition("n", "f") transitions:CommitAcquiredTarget("n", normal_target) transitions:BeginAcquisition("v", "t") transitions:CommitAcquiredTarget("v", visual_target) same(normal_target, state:get_previous_target("n")) same(visual_target, state:get_previous_target("v")) same(domain.Descriptor.FIND_FORWARD, state:get_previous_descriptor("n")) same(domain.Descriptor.TILL_FORWARD, state:get_previous_descriptor("v")) same(domain.ModeContext.from_full_mode("v"), state.last_input_context) end) test("Success transitions commit only their mode-sensitive fields", function() local state, transitions = fresh_sequence_state() local target = domain.TargetValue.character("h", 104) local command_destination = domain.Position.new(2, 4) transitions:BeginAcquisition("n", "f") transitions:CommitAcquiredTarget("n", target, 31) transitions:CommitCommandSuccess( "n", command_destination, domain.Direction.FORWARD ) same(command_destination, state:get_previous_landing("n")) falsy(state:get_first_move("n")) truthy(state.moved_forward) truthy(state.moved_forward_initialized) local visual_destination = domain.Position.new(3, 2) transitions:BeginAcquisition("v", "T") transitions:CommitAcquiredTarget("v", target) transitions:CommitVisualSuccess("v", visual_destination) same(visual_destination, state:get_previous_landing("v")) falsy(state:get_first_move("v")) truthy(state.moved_forward, "Visual success must retain movement direction") same(31, state.repeat_timestamp_ms, "an absent acquisition time must be retained") transitions:CommitCommandSuccess("n", domain.Position.new(1, 1), "backward") falsy(state.moved_forward) truthy(state.moved_forward_initialized) end) test("Failed and partial outcomes preserve successful history silently", function() local state, transitions = fresh_sequence_state() local target = domain.TargetValue.character("a", 97) local landing = domain.Position.new(1, 5) transitions:BeginAcquisition("n", "f") transitions:CommitAcquiredTarget("n", target, 17) transitions:CommitCommandSuccess("n", landing, true) local descriptor = state:get_previous_descriptor("n") local first_move = state:get_first_move("n") local moved_forward = state.moved_forward local before_timestamp = state.repeat_timestamp_ms local failed = domain.ActionOutcome.from_search( domain.SearchOutcome.boundary_before_any(landing), "f" ) local partial_endpoint = domain.Position.new(1, 7) local partial = domain.ActionOutcome.from_search( domain.SearchOutcome.boundary_after_partial(partial_endpoint, 2), "f" ) same(domain.ActionKind.FAILED_SEARCH, failed.kind) same(nil, failed.diagnostic) same(0, failed.successful_steps) same(domain.ActionKind.FAILED_SEARCH, partial.kind) same(partial_endpoint, partial.position) same(2, partial.successful_steps) same(nil, partial.diagnostic) same(descriptor, state:get_previous_descriptor("n")) same(landing, state:get_previous_landing("n")) same(first_move, state:get_first_move("n")) same(target, state:get_previous_target("n")) same(moved_forward, state.moved_forward) same(before_timestamp, state.repeat_timestamp_ms) end) test("State resource updates retain host locations and timer identity", function() local state, transitions = fresh_sequence_state() transitions:AddTargetOverlay("char-1", "window-1", 4) transitions:AddTargetOverlay("char-1", "window-2", 8) transitions:AddTemporaryOverlay("cursor-1", "window-1", "CleverFCursor") transitions:AddTemporaryOverlay("direct-1", "window-2", "CleverFDirect") transitions:AddFinalizer("finalizer-1", "buffer-1") transitions:AddFinalizer("finalizer-1", "buffer-2") local resources = state:resources() same("window-1", resources.target_overlays[1].window) same(4, resources.target_overlays[1].anchor_line) same("window-2", resources.target_overlays[2].window) same("CleverFDirect", resources.temporary_overlays[2].group) same("buffer-1", resources.finalizers[1].buffer) same("buffer-2", resources.finalizers[2].buffer) resources.target_overlays[1].window = "changed" same("window-1", state.target_overlays[1].window) fails(function() transitions:AddTargetOverlay("char-1", "window-1") end, "already active") fails(function() transitions:AddFinalizer("missing-location") end, "host location") same(nil, transitions:SetHighlightTimer("timer-1")) same("timer-1", transitions:SetHighlightTimer("timer-2")) local cleared, current = transitions:ClearHighlightTimer("timer-1") same(nil, cleared) falsy(current) same("timer-2", state.highlight_timer) cleared, current = transitions:ClearHighlightTimer("timer-2") same("timer-2", cleared) truthy(current) same(nil, state.highlight_timer) local removed = transitions:RemoveTargetOverlay("char-1", "window-1") same(1, #removed) same("window-1", removed[1].window) same(1, #state.target_overlays) same("window-2", state.target_overlays[1].window) end) test("SequenceState exposes copies and rejects direct mutation", function() local state, transitions = fresh_sequence_state() transitions:BeginAcquisition("n", "t") transitions:AddTargetOverlay("char-copy", "window-copy", 2) fails(function() state.moved_forward = true end, "StateTransitions") local descriptor_map = state.previous_descriptor descriptor_map[domain.ModeContext.from_full_mode("n")] = nil same(domain.Descriptor.TILL_FORWARD, state:get_previous_descriptor("n")) local overlays = state.target_overlays overlays[1].anchor_line = 99 overlays[1] = nil same(2, state.target_overlays[1].anchor_line) end) test("PublicReset applies its exact clear and retain sets", function() local state, transitions = fresh_sequence_state() local normal_target = domain.TargetValue.character("h", 104) local visual_target = domain.TargetValue.character("x", 120) transitions:BeginAcquisition("n", "f") transitions:CommitAcquiredTarget("n", normal_target, 77) transitions:CommitCommandSuccess("n", domain.Position.new(1, 2), true) transitions:BeginAcquisition("v", "t") transitions:CommitAcquiredTarget("v", visual_target) transitions:CommitVisualSuccess("v", domain.Position.new(2, 3)) transitions:CacheMigemo("utf-8", { dictionary = "utf-8" }) transitions:SetHighlightTimer("timer-1") transitions:AddTargetOverlay("char-1", "window-1", 1) transitions:AddTargetOverlay("char-2", "window-2", 2) transitions:AddTemporaryOverlay("cursor-1", "window-1", "CleverFCursor") transitions:AddFinalizer("finalizer-1", "buffer-1") local input_context = state.last_input_context local cleanup = transitions:PublicReset("window-1") same(nil, state:get_previous_descriptor("n")) same(nil, state:get_previous_descriptor("v")) same(nil, state:get_previous_landing("n")) same(nil, state:get_previous_landing("v")) same(nil, state:get_first_move("n")) same(nil, state:get_first_move("v")) same(normal_target, state:get_previous_target("n")) same(visual_target, state:get_previous_target("v")) same(input_context, state.last_input_context) truthy(state.moved_forward) truthy(state.moved_forward_initialized) same(0, map_size(state.migemo_cache)) same(0, state.repeat_timestamp_ms) same(nil, state.highlight_timer) same(1, #state.target_overlays) same("window-2", state.target_overlays[1].window) same(1, #state.temporary_overlays) same(1, #state.finalizers) same("timer-1", cleanup.highlight_timer) same(1, #cleanup.target_overlays) same("char-1", cleanup.target_overlays[1].identity) same("window-1", cleanup.target_overlays[1].window) same(0, #cleanup.finalizers) end) test("FullFinalization clears feedback and direction while retaining history", function() local state, transitions = fresh_sequence_state() local normal_target = domain.TargetValue.character("h", 104) local visual_target = domain.TargetValue.character("x", 120) local dictionary = { dictionary = "utf-8" } transitions:BeginAcquisition("n", "f") transitions:CommitAcquiredTarget("n", normal_target, 91) transitions:CommitCommandSuccess("n", domain.Position.new(1, 2), true) transitions:BeginAcquisition("v", "T") transitions:CommitAcquiredTarget("v", visual_target) transitions:CommitVisualSuccess("v", domain.Position.new(2, 3)) transitions:CacheMigemo("utf-8", dictionary) transitions:SetHighlightTimer("timer-finalize") transitions:AddTargetOverlay("char-current", "window-1", 1) transitions:AddTargetOverlay("char-peer", "window-2", 2) transitions:AddTemporaryOverlay("direct-current", "window-1", "CleverFDirect") transitions:AddFinalizer("finalizer-a", "buffer-1") transitions:AddFinalizer("finalizer-b", "buffer-2") local input_context = state.last_input_context local cleanup = transitions:FullFinalization("window-1") same(nil, state:get_previous_landing("n")) same(nil, state:get_previous_landing("v")) falsy(state.moved_forward) truthy(state.moved_forward_initialized) same(domain.Descriptor.FIND_FORWARD, state:get_previous_descriptor("n")) same(domain.Descriptor.TILL_BACKWARD, state:get_previous_descriptor("v")) same(normal_target, state:get_previous_target("n")) same(visual_target, state:get_previous_target("v")) falsy(state:get_first_move("n")) falsy(state:get_first_move("v")) same(input_context, state.last_input_context) same(dictionary, state:get_migemo("utf-8")) same(91, state.repeat_timestamp_ms) same(nil, state.highlight_timer) same(1, #state.target_overlays) same("window-2", state.target_overlays[1].window) same(1, #state.temporary_overlays) same(0, #state.finalizers) same("timer-finalize", cleanup.highlight_timer) same("window-1", cleanup.target_overlays[1].window) same(2, #cleanup.finalizers) same("buffer-1", cleanup.finalizers[1].buffer) end) test("DiagnosticFullReset adds only its diagnostic clear set", function() local state, transitions = fresh_sequence_state() local target = domain.TargetValue.character("z", 122) transitions:BeginAcquisition("n", "f") transitions:CommitAcquiredTarget("n", target, 55) transitions:CommitCommandSuccess("n", domain.Position.new(4, 7), true) transitions:CacheMigemo("cp932", { dictionary = "cp932" }) transitions:SetHighlightTimer("timer-diagnostic") transitions:AddTargetOverlay("char-diagnostic", "window-3", 4) transitions:AddTemporaryOverlay("cursor-diagnostic", "window-3", "CleverFCursor") transitions:AddFinalizer("finalizer-diagnostic", "buffer-3") local cleanup = transitions:DiagnosticFullReset() same(nil, state:get_previous_descriptor("n")) same(nil, state:get_previous_landing("n")) same(nil, state:get_first_move("n")) same(nil, state:get_previous_target("n")) same(nil, state.last_input_context) falsy(state.moved_forward) falsy(state.moved_forward_initialized) same(0, map_size(state.migemo_cache)) same(0, state.repeat_timestamp_ms) same(nil, state.highlight_timer) same(0, #state.target_overlays) same(1, #state.temporary_overlays) same("window-3", state.temporary_overlays[1].window) same(0, #state.finalizers) same("timer-diagnostic", cleanup.highlight_timer) same("window-3", cleanup.target_overlays[1].window) same("buffer-3", cleanup.finalizers[1].buffer) transitions:ClearTemporaryOverlays() end) test("ClearAllLandingsAndDirection retains movement initialization", function() local state, transitions = fresh_sequence_state() local target = domain.TargetValue.character("a", 97) transitions:BeginAcquisition("n", "f") transitions:CommitAcquiredTarget("n", target) transitions:CommitCommandSuccess("n", domain.Position.new(1, 9), true) transitions:BeginAcquisition("v", "t") transitions:CommitVisualSuccess("v", domain.Position.new(2, 1)) transitions:ClearAllLandingsAndDirection() same(nil, state:get_previous_landing("n")) same(nil, state:get_previous_landing("v")) falsy(state.moved_forward) truthy(state.moved_forward_initialized) same(domain.Descriptor.FIND_FORWARD, state:get_previous_descriptor("n")) same(target, state:get_previous_target("n")) same(domain.Descriptor.TILL_FORWARD, state:get_previous_descriptor("v")) end) local function collect_iteration(iterator) local positions = {} local characters = {} local spans = {} while true do local position, character, span = iterator() if position == nil then break end positions[#positions + 1] = position characters[#characters + 1] = character spans[#spans + 1] = span end return positions, characters, spans end local function position_strings(positions) local result = {} for index, position in ipairs(positions) do result[index] = tostring(position) end return result end local MIXED_JAPANESE = "A\227\129\130\239\189\178\230\188\162B" local MIXED_CHARACTERS = { "A", "\227\129\130", "\239\189\178", "\230\188\162", "B", } test("TextView indexes editor characters in each required encoding", function() local cases = { { encoding = "utf-8", starts = { 1, 2, 5, 8, 11 }, lengths = { 1, 3, 3, 3, 1 }, line_length = 11, }, { encoding = "cp932", starts = { 1, 2, 4, 5, 7 }, lengths = { 1, 2, 1, 2, 1 }, line_length = 7, }, { encoding = "euc-jp", starts = { 1, 2, 4, 6, 8 }, lengths = { 1, 2, 2, 2, 1 }, line_length = 8, }, } for _, case in ipairs(cases) do local view = text_topology.new({ MIXED_JAPANESE }, case.encoding) same(case.encoding, view.encoding) same(1, view.line_count) same(5, view:line_character_count(1)) same(case.line_length, view:line_byte_length(1)) same(case.line_length, #view:line_encoded_text(1)) for character_index = 1, #MIXED_CHARACTERS do local column = view:byte_column_for_character_index(1, character_index) same(case.starts[character_index], column) same( character_index, view:character_index_for_byte_column(1, column) ) same(MIXED_CHARACTERS[character_index], view:character_at(1, column)) local span = view:byte_span_for_character_index(1, character_index) same(case.lengths[character_index], span.byte_length) same(column, span.byte_start) same(column + span.byte_length - 1, span.byte_end) truthy(view:is_character_start(span.position)) end end local ascii = text_topology.new({ "plain" }, "utf8") same("utf-8", ascii.encoding) same(5, ascii:line_byte_length(1)) same(5, ascii:line_character_count(1)) for index = 1, 5 do same(index, ascii:character_index_to_byte_column(1, index)) same(index, ascii:byte_column_to_character_index(1, index)) end end) test("TextView rejects interior and boundary bytes as character starts", function() local view = text_topology.new({ MIXED_JAPANESE }, "utf-8") local valid = { [1] = true, [2] = true, [5] = true, [8] = true, [11] = true, } for column = 1, view:line_byte_length(1) do same(valid[column] == true, view:is_character_start(1, column)) if not valid[column] then same(nil, view:try_character_index_for_byte_column(1, column)) fails(function() view:character_index_for_byte_column(1, column) end, "inside an editor character") end end falsy(view:is_character_start(1, 12)) fails(function() view:character_index_for_byte_column(1, 12) end, "does not identify") fails(function() view:byte_column_for_character_index(1, 0) end, "character_index") end) test("TextView treats Nvim grapheme clusters as editor characters", function() local combining = "e\204\129x" local view = text_topology.new({ combining }, "utf-8") same(2, view:line_character_count(1)) same(4, view:line_byte_length(1)) same(1, view:byte_column_for_character_index(1, 1)) same(4, view:byte_column_for_character_index(1, 2)) same("e\204\129", view:character_at_index(1, 1)) falsy(view:is_character_start(1, 2)) falsy(view:is_character_start(1, 3)) end) test("Forward and backward iteration enumerate reverse character starts", function() local view = text_topology.new({ "a\227\129\130", "", "\239\189\178z", }, "utf-8") local forward, characters, spans = collect_iteration(view:iter_buffer_forward()) list_same({ "(1,1)", "(1,2)", "(3,1)", "(3,4)" }, position_strings(forward)) list_same({ "a", "\227\129\130", "\239\189\178", "z" }, characters) for index, position in ipairs(forward) do truthy(view:is_character_start(position)) same(position, spans[index].position) end local backward = collect_iteration(view:iter_buffer_backward()) list_same({ "(3,4)", "(3,1)", "(1,2)", "(1,1)" }, position_strings(backward)) local line_forward = collect_iteration(view:iter_line_forward(1)) local line_backward = collect_iteration(view:iter_line_backward(1)) list_same({ "(1,1)", "(1,2)" }, position_strings(line_forward)) list_same({ "(1,2)", "(1,1)" }, position_strings(line_backward)) end) test("Bounded iterators include and stop at their selected boundaries", function() local view = text_topology.new({ "ab", "cd", "ef" }, "utf-8") local first = domain.Position.new(1, 2) local last = domain.Position.new(3, 1) local forward = collect_iteration(view:iter_forward(first, last)) list_same({ "(1,2)", "(2,1)", "(2,2)", "(3,1)" }, position_strings(forward)) local backward = collect_iteration(view:iter_backward(last, first)) list_same({ "(3,1)", "(2,2)", "(2,1)", "(1,2)" }, position_strings(backward)) fails(function() view:iter_forward(last, first) end, "must not follow") fails(function() view:iter_backward(first, last) end, "must not precede") fails(function() view:iter_forward(domain.Position.new(1, 3), last) end, "must start an editor character") end) test("Strict-side iteration obeys line and buffer match-start bounds", function() local view = text_topology.new({ "abc", "", "def" }, "utf-8") local origin = domain.Position.new(1, 2) local line_forward = collect_iteration(view:iter_strict_forward( origin, domain.SearchScope.CURRENT_LINE )) list_same({ "(1,3)" }, position_strings(line_forward)) local buffer_forward = collect_iteration(view:iter_strict_forward( origin, domain.SearchScope.BUFFER )) list_same({ "(1,3)", "(3,1)", "(3,2)", "(3,3)" }, position_strings(buffer_forward)) local line_backward = collect_iteration(view:iter_strict_backward( origin, "current_line" )) list_same({ "(1,1)" }, position_strings(line_backward)) local buffer_backward = collect_iteration(view:iter_strict_backward( domain.Position.new(3, 2), "buffer" )) list_same({ "(3,1)", "(1,3)", "(1,2)", "(1,1)" }, position_strings(buffer_backward)) local empty_line_bounds = view:line_match_start_bounds(2) truthy(empty_line_bounds.empty) same(nil, empty_line_bounds.first) same(nil, empty_line_bounds.last) same(0, #position_strings(collect_iteration(view:iter_line_forward(2)))) same(0, #position_strings(collect_iteration(view:iter_strict_forward( domain.Position.new(2, 1), "current_line" )))) local buffer_bounds = view:match_start_bounds("buffer") falsy(buffer_bounds.empty) same(domain.Position.new(1, 1), buffer_bounds.first) same(domain.Position.new(3, 3), buffer_bounds.last) truthy(buffer_bounds:contains(domain.Position.new(2, 1))) end) test("Endpoint adjacency crosses lines and normalizes cursor boundaries", function() local view = text_topology.new({ "a\227\129\130", "", "\239\189\178z", }, "utf-8") same(nil, view:predecessor(domain.Position.new(1, 1))) same(domain.Position.new(1, 1), view:predecessor(domain.Position.new(1, 2))) same(domain.Position.new(2, 1), view:successor(domain.Position.new(1, 2))) same(domain.Position.new(1, 2), view:predecessor(domain.Position.new(2, 1))) same(domain.Position.new(3, 1), view:successor(domain.Position.new(2, 1))) same(domain.Position.new(2, 1), view:predecessor(domain.Position.new(3, 1))) same(domain.Position.new(3, 4), view:successor(domain.Position.new(3, 1))) same(nil, view:successor(domain.Position.new(3, 4))) same(domain.Position.new(1, 2), view:normalize_endpoint(1, 3)) same(domain.Position.new(1, 2), view:normalize_endpoint(1, 5)) same(domain.Position.new(2, 1), view:normalize_endpoint(2, 7)) truthy(view:is_valid_cursor_position(view:normalize_endpoint(1, 3))) truthy(view:is_valid_cursor_position(view:normalize_endpoint(2, 7))) fails(function() view:successor(domain.Position.new(1, 3)) end, "inside an editor character") fails(function() view:predecessor(domain.Position.new(2, 2)) end, "empty line") end) test("TextView creation reads one operation-local snapshot", function() local host = MemoryHost.new({ buffer_lines = { "ab" }, effective_encoding = "utf-8", }) host:clear_operations() local first = text_topology.from_host(host) local operations = host:operations() same(2, #operations) same("read_text", operations[1].operation) same("read_encoding", operations[2].operation) host:set_text({ "xyz" }) local second = text_topology.from_host(host) same("ab", first:line_text(1)) same(2, first:line_character_count(1)) same("xyz", second:line_text(1)) same(3, second:line_character_count(1)) falsy(first == second) fails(function() first.encoding = "cp932" end, "immutable") end) test("All-empty buffers expose empty full-buffer iteration", function() local view = text_topology.new({ "", "", "" }, "utf-8") local bounds = view:buffer_match_start_bounds() truthy(bounds:is_empty()) same(nil, bounds.first) same(nil, bounds.last) same(0, #position_strings(collect_iteration(view:iter_buffer_forward()))) same(0, #position_strings(collect_iteration(view:iter_buffer_backward()))) end) local function target(character, first_code) return domain.TargetValue.character( character, first_code or string.byte(character, 1) ) end local function matching_policy(overrides) local result = { ignore_case = false, smart_case = false, chars_match_any_signs = "", } for key, value in pairs(overrides or {}) do result[key] = value end return result end test("Case mode follows ignore-case and lower-ASCII smart-case priority", function() local resolver = case_policy.new() for code = string.byte("a"), string.byte("z") do local lower = target(string.char(code), code) same( domain.CaseMode.INSENSITIVE, resolver:resolve(lower, false, true), "smart case must fold lower ASCII" ) same( domain.CaseMode.INSENSITIVE, policy.resolve_case_mode(lower, false, true) ) end for code = string.byte("A"), string.byte("Z") do local upper = target(string.char(code), code) same(domain.CaseMode.SENSITIVE, resolver:resolve(upper, false, true)) same(domain.CaseMode.INSENSITIVE, resolver:resolve(upper, true, true)) end local multibyte = target("\195\164", 0x00e4) local symbol = target(";", string.byte(";")) local control = target(string.char(1), 1) same(domain.CaseMode.SENSITIVE, resolver:resolve(multibyte, false, true)) same(domain.CaseMode.SENSITIVE, resolver:resolve(symbol, false, true)) same(domain.CaseMode.SENSITIVE, resolver:resolve(control, false, true)) same(domain.CaseMode.INSENSITIVE, resolver:resolve(multibyte, true, false)) truthy(case_policy.is_lower_ascii("a")) falsy(case_policy.is_lower_ascii("A")) falsy(case_policy.is_lower_ascii("aa")) falsy(case_policy.is_lower_ascii("\195\164")) fails(function() resolver.lowercase = string.lower end, "immutable") end) test("Case comparison uses editor lowercase conversion explicitly", function() local resolver = case_policy.new() local upper_a_umlaut = "\195\132" local lower_a_umlaut = "\195\164" truthy(resolver:equal("A", "a", domain.CaseMode.INSENSITIVE)) falsy(resolver:equal("A", "a", domain.CaseMode.SENSITIVE)) truthy(resolver:equal( upper_a_umlaut, lower_a_umlaut, domain.CaseMode.INSENSITIVE )) same(vim.fn.tolower(upper_a_umlaut), resolver:lowercase(upper_a_umlaut)) local calls = {} local injected = case_policy.new(function(value) calls[#calls + 1] = value return value == "UP" and "folded" or value end) local compare = injected:comparator("UP", domain.CaseMode.INSENSITIVE) truthy(compare("folded")) list_same({ "UP", "folded" }, calls) end) test("Trigger parsing uses complete editor characters", function() local combining_character = "e\204\129" local japanese_character = "\227\129\130" local configured = "x" .. combining_character .. japanese_character list_same( { "x", combining_character, japanese_character }, text_topology.split_editor_characters(configured) ) list_same( { "x", combining_character, japanese_character }, target_plan.parse_trigger_characters(configured) ) local factory = target_plan.new() local plan = factory:build( target(combining_character, string.byte("e")), matching_policy({ chars_match_any_signs = configured }) ) same(domain.TargetPlanKind.SYMBOL, plan.kind) truthy(plan:matches("!")) falsy(plan:matches(combining_character)) end) test("Symbol plans match the exact shared 32-character set", function() local expected_symbols = "!\"#$%&'()=~|\\-^@`[]{};:+*<>,.?_/" same(expected_symbols, target_plan.SYMBOLS) same(32, #target_plan.symbol_characters()) local expected = {} for index = 1, #expected_symbols do expected[expected_symbols:sub(index, index)] = true end local plan = target_plan.build( target(";"), matching_policy({ ignore_case = true, chars_match_any_signs = ";", }) ) same(domain.TargetPlanKind.SYMBOL, plan.kind) same(domain.CaseMode.INSENSITIVE, plan.case_mode) local accepted = 0 for code = 0, 127 do local character = string.char(code) local matches = plan:matches(character) same(expected[character] == true, matches, "ASCII code " .. tostring(code)) if matches then accepted = accepted + 1 end end same(32, accepted) falsy(plan:matches(" ")) falsy(plan:matches("a")) falsy(plan:matches("Z")) falsy(plan:matches("0")) falsy(plan:matches("9")) falsy(plan:matches("\227\129\130")) end) test("Every configured editor character selects the symbol branch", function() local factory = target_plan.new() local cases = { { target("a"), "a" }, { target(";"), ";" }, { target("\227\129\130", 0x3042), "x\227\129\130y" }, } for _, case in ipairs(cases) do local plan = factory:build( case[1], matching_policy({ chars_match_any_signs = case[2] }) ) same(domain.TargetPlanKind.SYMBOL, plan.kind) truthy(plan:matches("!")) truthy(plan:matches("/")) same(target_plan.is_symbol(case[1].value), plan:matches(case[1].value)) end end) test("Pattern punctuation stays literal when it is not a trigger", function() local factory = target_plan.new() local pattern_characters = { "^", "[", "]", "(", ")", ".", "*", "+", "?", "$", "%", "-", "|", } for _, character in ipairs(pattern_characters) do local plan = factory:build(target(character), matching_policy()) same(domain.TargetPlanKind.LITERAL, plan.kind, character) truthy(plan:matches(character), character) falsy(plan:matches("x"), character) end local backslash = factory:build(target("\\"), matching_policy()) same(domain.TargetPlanKind.BACKSLASH, backslash.kind) truthy(backslash:matches("\\")) falsy(backslash:matches("\\\\")) falsy(backslash:matches("/")) end) test("Symbol selection precedes the literal backslash branch", function() local factory = target_plan.new() local literal = factory:build(target("\\"), matching_policy()) local wildcard = factory:build( target("\\"), matching_policy({ chars_match_any_signs = "\\" }) ) same(domain.TargetPlanKind.BACKSLASH, literal.kind) same(domain.TargetPlanKind.SYMBOL, wildcard.kind) truthy(wildcard:matches(".")) truthy(wildcard:matches("\\")) falsy(wildcard:matches("a")) end) test("Literal plans apply ignore-case and smart-case modes", function() local factory = target_plan.new() local lower_smart = factory:build( target("a"), matching_policy({ smart_case = true }) ) local upper_smart = factory:build( target("A"), matching_policy({ smart_case = true }) ) local upper_ignored = factory:build( target("A"), matching_policy({ ignore_case = true, smart_case = true }) ) local multibyte_smart = factory:build( target("\195\164", 0x00e4), matching_policy({ smart_case = true }) ) local multibyte_ignored = factory:build( target("\195\132", 0x00c4), matching_policy({ ignore_case = true }) ) same(domain.CaseMode.INSENSITIVE, lower_smart.case_mode) truthy(lower_smart:matches("A")) same(domain.CaseMode.SENSITIVE, upper_smart.case_mode) falsy(upper_smart:matches("a")) same(domain.CaseMode.INSENSITIVE, upper_ignored.case_mode) truthy(upper_ignored:matches("a")) same(domain.CaseMode.SENSITIVE, multibyte_smart.case_mode) falsy(multibyte_smart:matches("\195\132")) same(domain.CaseMode.INSENSITIVE, multibyte_ignored.case_mode) truthy(multibyte_ignored:matches("\195\164")) end) test("Matching ignores ambient editor case options", function() local saved_ignorecase = vim.o.ignorecase local saved_smartcase = vim.o.smartcase vim.o.ignorecase = true vim.o.smartcase = true local sensitive = target_plan.build(target("a"), matching_policy()) local sensitive_match = sensitive:matches("A") vim.o.ignorecase = false vim.o.smartcase = false local insensitive = target_plan.build( target("a"), matching_policy({ ignore_case = true }) ) local insensitive_match = insensitive:matches("A") vim.o.ignorecase = saved_ignorecase vim.o.smartcase = saved_smartcase falsy(sensitive_match) truthy(insensitive_match) end) test("Special keys are empty plans and controls remain literal", function() local factory = target_plan.new() local special = domain.TargetValue.special_key(string.char(0x80, 0xfd, 1)) local empty = factory:build( special, matching_policy({ chars_match_any_signs = string.char(0x80) }) ) same(domain.TargetPlanKind.EMPTY, empty.kind) falsy(empty:matches("a")) falsy(empty:matches("!")) local first_code_controls = target(string.char(0x80), 0x80) same( domain.TargetPlanKind.EMPTY, factory:build(first_code_controls, matching_policy()).kind ) for _, code in ipairs({ 1, 9, 13, 26, 31, 127 }) do local character = string.char(code) local plan = factory:build(target(character, code), matching_policy()) same(domain.TargetPlanKind.LITERAL, plan.kind) truthy(plan:matches(character)) falsy(plan:matches(string.char((code + 1) % 128))) end local fallback = factory:build( domain.TargetValue.code_fallback(), matching_policy({ ignore_case = true }) ) same(domain.TargetPlanKind.LITERAL, fallback.kind) falsy(fallback:matches("")) falsy(fallback:matches(string.char(1))) end) test("Policy-backed factories sample live values into immutable plans", function() local host = MemoryHost.new() local service = policy.new(host) local factory = target_plan.new(service) local typed_target = target("a") local first = factory:build(typed_target) same(domain.TargetPlanKind.LITERAL, first.kind) same(domain.CaseMode.SENSITIVE, first.case_mode) falsy(first:matches("A")) host:set_configuration("ignore_case", true) host:set_configuration("chars_match_any_signs", "a") local second = factory:build(typed_target) same(domain.TargetPlanKind.SYMBOL, second.kind) same(domain.CaseMode.INSENSITIVE, second.case_mode) truthy(second:matches("!")) same(domain.TargetPlanKind.LITERAL, first.kind) falsy(first:matches("A")) local serialized = second:to_table() same("symbol", serialized.kind) same("insensitive", serialized.case_mode) fails(function() second.kind = domain.TargetPlanKind.LITERAL end, "immutable") end) test("Target plan inputs enforce matching contracts", function() local factory = target_plan.new() fails(function() factory:build({}, matching_policy()) end, "TargetValue") fails(function() factory:build(target("a"), matching_policy({ ignore_case = 1 })) end, "Boolean") fails(function() factory:build(target("a"), matching_policy({ chars_match_any_signs = {} })) end, "string") fails(function() target_plan.parse_trigger_characters("abc", function() return { "a", "c" } end) end, "preserve") end) local MIGEMO_HIRAGANA_A = "\227\129\130" local MIGEMO_KANJI = "\230\188\162" local MIGEMO_SHELTER = "\229\142\166" local MIGEMO_GATE = "\233\150\128" local function read_binary(path) local handle, open_error = io.open(path, "rb") if handle == nil then error("could not open " .. path .. ": " .. tostring(open_error), 2) end local contents = handle:read("*a") handle:close() return contents end local function migemo_components(configuration) local _, transitions = fresh_sequence_state() local host = MemoryHost.new({ configuration = configuration }) local service = policy.new(host) local catalog = migemo_catalog.new({ policy = service, transitions = transitions, }) local factory = target_plan.new({ policy = service, migemo_catalog = catalog, }) return host, service, catalog, factory, transitions end test("Bundled Migemo assets equal the normative source data", function() local assets = { { encoding = "utf-8", file = "utf8.vim", hash = "fccd16452c105fcb7303d855b96d841f8174d37affa0c43d8b49a54e68289f3e", }, { encoding = "cp932", file = "cp932.vim", hash = "3823fda790f43876be78d53e116187f2e0eedc14d203756ea66a3814ca9707f1", }, { encoding = "euc-jp", file = "eucjp.vim", hash = "d6d7a2a795d7e3e14cdefd0cf2597bc48bd3d89dbdc41019f460027377855079", }, } for _, asset in ipairs(assets) do local bundled_path = migemo_catalog.bundled_asset_path(asset.encoding) local bundled = read_binary(bundled_path) same(asset.hash, vim.fn.sha256(bundled), asset.file .. " checksum") end end) test("Migemo dictionaries validate ordered keys and cache each encoding", function() local state, transitions = fresh_sequence_state() local catalog = migemo_catalog.new({ transitions = transitions }) local expected_keys = migemo_catalog.expected_keys() same(52, #expected_keys) same( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", table.concat(expected_keys) ) local encodings = { { requested = "UTF8", canonical = "utf-8" }, { requested = "CP-932", canonical = "cp932" }, { requested = "EUCJP", canonical = "euc-jp" }, } for _, item in ipairs(encodings) do local first = catalog:get(item.requested) local second = catalog:get(item.canonical) same(first, second) same(first, state:get_migemo(item.canonical)) same(item.canonical, first.encoding) same(52, first.entry_count) list_same(expected_keys, first:keys()) same(1, catalog:load_count(item.canonical)) for _, key in ipairs(expected_keys) do same("function", type(first[key])) truthy(first:has(key)) truthy(first:matches(key, key, domain.CaseMode.SENSITIVE)) end fails(function() first.encoding = "changed" end, "immutable") end same(3, map_size(state.migemo_cache)) end) test("Migemo catalog rejects wrong entry counts and key order", function() local _, transitions = fresh_sequence_state() local keys = migemo_catalog.expected_keys() local data = {} for _, key in ipairs(keys) do data[key] = key end local compiler = function() return function() return false end end local wrong_order = migemo_catalog.expected_keys() wrong_order[1], wrong_order[2] = wrong_order[2], wrong_order[1] local order_catalog = migemo_catalog.new({ transitions = transitions, asset_loader = function() return data, wrong_order, "ordered-test" end, pattern_compiler = compiler, }) fails(function() order_catalog:get("utf-8") end, "ordered a through z, then A through Z") data[keys[#keys]] = nil local count_catalog = migemo_catalog.new({ transitions = transitions, asset_loader = function() return data, keys, "count-test" end, pattern_compiler = compiler, }) fails(function() count_catalog:get("utf-8") end, "exactly 52 keys") end) test("Native Migemo predicates agree across a fixed encoding corpus", function() local _, transitions = fresh_sequence_state() local catalog = migemo_catalog.new({ transitions = transitions }) local corpus = { { "a", "a", domain.CaseMode.SENSITIVE, true }, { "a", "A", domain.CaseMode.SENSITIVE, false }, { "a", "A", domain.CaseMode.INSENSITIVE, true }, { "a", MIGEMO_HIRAGANA_A, domain.CaseMode.SENSITIVE, true }, { "k", MIGEMO_KANJI, domain.CaseMode.SENSITIVE, true }, { "a", MIGEMO_SHELTER .. "\n" .. MIGEMO_GATE, domain.CaseMode.SENSITIVE, true, }, { "a", "x" .. MIGEMO_HIRAGANA_A, domain.CaseMode.SENSITIVE, false }, } local saved_ignorecase = vim.o.ignorecase local saved_smartcase = vim.o.smartcase vim.o.ignorecase = true vim.o.smartcase = true for _, encoding in ipairs(migemo_catalog.supported_encodings()) do local dictionary = catalog:get(encoding) for index, item in ipairs(corpus) do same( item[4], dictionary:matches(item[1], item[2], item[3]), encoding .. " corpus item " .. tostring(index) ) end end vim.o.ignorecase = saved_ignorecase vim.o.smartcase = saved_smartcase end) test("Migemo activation uses live policy, scope, and branch precedence", function() local host, _, _, factory = migemo_components({ use_migemo = true, search_current_line_only = false, chars_match_any_signs = "a;", }) local ascii_view = text_topology.new({ "xax" }, "utf-8") local context = { text_view = ascii_view, origin = domain.Position.new(1, 1), } local migemo = factory:build(target("a"), nil, context) same(domain.TargetPlanKind.MIGEMO, migemo.kind) host:set_configuration("use_migemo", false) local symbol = factory:build(target("a"), nil, context) same(domain.TargetPlanKind.SYMBOL, symbol.kind) host:set_configuration("use_migemo", true) same(domain.TargetPlanKind.SYMBOL, factory:build(target(";"), nil, context).kind) same( domain.TargetPlanKind.LITERAL, factory:build(target(MIGEMO_HIRAGANA_A, 0x3042), nil, context).kind ) same(domain.TargetPlanKind.LITERAL, factory:build(target("ab", 97), nil, context).kind) host:set_configuration("chars_match_any_signs", "") host:set_configuration("search_current_line_only", true) local ascii_line = factory:build(target("a"), nil, context) same(domain.TargetPlanKind.LITERAL, ascii_line.kind) local multibyte_view = text_topology.new({ "x" .. MIGEMO_HIRAGANA_A }, "utf-8") local multibyte_line = factory:build(target("a"), nil, { text_view = multibyte_view, origin = domain.Position.new(1, 1), }) same(domain.TargetPlanKind.MIGEMO, multibyte_line.kind) end) test("Migemo plans enforce case and one-character candidate constraints", function() local host, _, catalog, factory = migemo_components({ use_migemo = true, ignore_case = false, smart_case = false, }) local view = text_topology.new({ "aA" .. MIGEMO_HIRAGANA_A .. "Es", }, "utf-8") local context = { text_view = view, search_scope = domain.SearchScope.BUFFER, } local sensitive = factory:build(target("a"), nil, context) same(domain.CaseMode.SENSITIVE, sensitive.case_mode) truthy(sensitive:matches_at(view, view:position_for_character_index(1, 1))) falsy(sensitive:matches_at(view, view:position_for_character_index(1, 2))) truthy(sensitive:matches_at(view, view:position_for_character_index(1, 3))) local other_letter = view:position_for_character_index(1, 4) truthy(catalog:get("utf-8"):matches( "a", view:text_suffix(other_letter), domain.CaseMode.SENSITIVE )) falsy(sensitive:matches_at(view, other_letter)) falsy(sensitive:matches("x", view:position_for_character_index(1, 1), view)) host:set_configuration("ignore_case", true) local insensitive = factory:build(target("a"), nil, context) same(domain.CaseMode.INSENSITIVE, insensitive.case_mode) truthy(insensitive:matches_at(view, view:position_for_character_index(1, 2))) end) test("Migemo assertions inspect later lines while limiting candidate starts", function() local _, _, _, factory = migemo_components({ use_migemo = true, search_current_line_only = true, }) local view = text_topology.new({ "x" .. MIGEMO_SHELTER, "", MIGEMO_GATE, MIGEMO_HIRAGANA_A, }, "utf-8") local candidate = view:position_for_character_index(1, 2) local plan = factory:build(target("a"), nil, { text_view = view, origin = domain.Position.new(1, 1), }) same(domain.TargetPlanKind.MIGEMO, plan.kind) same( MIGEMO_SHELTER .. "\n\n" .. MIGEMO_GATE .. "\n" .. MIGEMO_HIRAGANA_A, view:text_suffix(candidate) ) truthy(plan:matches_at(view, candidate)) local matched_start = plan:matches_at(view, candidate) and candidate or nil same(candidate, matched_start) falsy(plan:matches_at(view, domain.Position.new(4, 1))) end) test("Unsupported Migemo encoding disables live policy and raises exactly", function() local host, service, _, factory = migemo_components({ use_migemo = true, search_current_line_only = false, }) local view = text_topology.new({ "a" }, "latin1") local ok, diagnostic = pcall(function() factory:build(target("a"), nil, { text_view = view, search_scope = domain.SearchScope.BUFFER, }) end) falsy(ok) same( "clever-f: Encoding 'latin1' is not supported. Migemo is disabled", diagnostic ) falsy(service:get_boolean("use_migemo")) same(false, host:read_configuration("use_migemo")) same(0, map_size(sequence_state.get().migemo_cache)) local literal = factory:build(target("a"), nil, { text_view = view, search_scope = domain.SearchScope.BUFFER, }) same(domain.TargetPlanKind.LITERAL, literal.kind) end) test("Public Reset discards loaded Migemo dictionary objects", function() local state, transitions = fresh_sequence_state() local catalog = migemo_catalog.new({ transitions = transitions }) local first = catalog:get("utf-8") same(first, state:get_migemo("utf-8")) transitions:PublicReset() same(nil, state:get_migemo("utf-8")) same(nil, catalog:cached("utf-8")) local second = catalog:get("utf-8") falsy(first == second) same(second, state:get_migemo("utf-8")) same(2, catalog:load_count("utf-8")) end) test("MotionPlanFactory combines resolved motion values", function() local target_match = target_plan.build(target("a"), matching_policy()) local factory = motion_plan.new() local descriptors = { "f", "F", "t", "T" } for _, descriptor in ipairs(descriptors) do local plan = factory:build( target_match, descriptor, domain.SearchScope.CURRENT_LINE, domain.EndpointPolicy.VISUAL_EXCLUSIVE ) same(target_match, plan.target_plan) same(domain.Descriptor.from_string(descriptor), plan.descriptor) same(domain.SearchScope.CURRENT_LINE, plan.search_scope) same(domain.EndpointPolicy.VISUAL_EXCLUSIVE, plan.endpoint_policy) end local defaulted = motion_plan.build(target_match, "f") same(domain.SearchScope.BUFFER, defaulted.search_scope) same(domain.EndpointPolicy.REGULAR, defaulted.endpoint_policy) fails(function() defaulted.search_scope = domain.SearchScope.CURRENT_LINE end, "immutable") fails(function() factory:build({}, "f", "buffer", "regular") end, "TargetPlan") end) test("DestinationEngine accepts pure calculation inputs", function() local view = text_topology.new({ "abc" }, "utf-8") local plan = motion_plan.build( target_plan.build(target("z"), matching_policy()), "f" ) local engine = destination_engine.new() local origin = domain.Position.new(1, 1) local outcome = engine:calculate(view, origin, plan, nil, true) same(domain.SearchStatus.BOUNDARY_BEFORE_ANY, outcome.status) same(origin, outcome.endpoint) same(0, outcome.successful_steps) truthy(destination_engine.DestinationEngine.is(engine)) fails(function() engine.mutable = true end, "immutable") fails(function() engine:calculate({}, origin, plan, 1, true) end, "TextView") fails(function() engine:calculate(view, origin, {}, 1, true) end, "ResolvedMotionPlan") fails(function() engine:calculate(view, origin, plan, 0, true) end, "positive") fails(function() engine:calculate(view, origin, plan, 1, nil) end, "Boolean") fails(function() engine:calculate(view, domain.Position.new(1, 4), plan, 1, true) end, "valid cursor") end) test("DestinationEngine enumerates target starts in motion order", function() local view = text_topology.new({ "abaca" }, "utf-8") local origin = domain.Position.new(1, 3) local visits = {} local target_match = domain.TargetPlan.new({ target = target("a"), kind = domain.TargetPlanKind.LITERAL, case_mode = domain.CaseMode.SENSITIVE, matcher = function(character, position) visits[#visits + 1] = character .. ":" .. tostring(position) return character == "a" end, }) local engine = destination_engine.new() local forward = engine:calculate( view, origin, motion_plan.build(target_match, "f"), 1, true ) same(domain.Position.new(1, 5), forward.endpoint) list_same({ "c:(1,4)", "a:(1,5)" }, visits) visits = {} local backward = engine:calculate( view, origin, motion_plan.build(target_match, "F"), 1, true ) same(domain.Position.new(1, 1), backward.endpoint) list_same({ "b:(1,2)", "a:(1,1)" }, visits) end) test("DestinationEngine stops target starts at selected boundaries", function() local view = text_topology.new({ "axa", "", "axa" }, "utf-8") local target_match = target_plan.build(target("a"), matching_policy()) local engine = destination_engine.new() local line_forward = motion_plan.build(target_match, "f", "current_line") local buffer_forward = motion_plan.build(target_match, "f", "buffer") local line_backward = motion_plan.build(target_match, "F", "current_line") local buffer_backward = motion_plan.build(target_match, "F", "buffer") same( domain.Position.new(1, 3), engine:calculate( view, domain.Position.new(1, 1), line_forward, 1, true ).endpoint ) same( domain.SearchStatus.BOUNDARY_BEFORE_ANY, engine:calculate( view, domain.Position.new(1, 3), line_forward, 1, false ).status ) same( domain.Position.new(3, 1), engine:calculate( view, domain.Position.new(1, 3), buffer_forward, 1, false ).endpoint ) same( domain.Position.new(3, 1), engine:calculate( view, domain.Position.new(3, 3), line_backward, 1, true ).endpoint ) same( domain.Position.new(1, 3), engine:calculate( view, domain.Position.new(3, 1), buffer_backward, 1, false ).endpoint ) same( domain.Position.new(3, 1), engine:calculate( view, domain.Position.new(2, 1), buffer_forward, 1, true ).endpoint ) end) test("FIND destinations use matching target positions", function() local view = text_topology.new({ "poge huga hiyo poyo" }, "utf-8") local engine = destination_engine.new() local h = target_plan.build(target("h"), matching_policy()) local forward = motion_plan.build(h, "f") local backward = motion_plan.build(h, "F") local first = engine:calculate( view, domain.Position.new(1, 1), forward, 1, true ) same(domain.Position.new(1, 6), first.endpoint) local second = engine:calculate(view, first.endpoint, forward, 1, false) same(domain.Position.new(1, 11), second.endpoint) same( domain.Position.new(1, 6), engine:calculate(view, second.endpoint, backward, 1, false).endpoint ) local o = target_plan.build(target("o"), matching_policy()) local find_o_backward = motion_plan.build(o, "F") local previous = engine:calculate( view, domain.Position.new(1, 19), find_o_backward, 1, true ) same(domain.Position.new(1, 17), previous.endpoint) same( domain.Position.new(1, 14), engine:calculate(view, previous.endpoint, find_o_backward, 1, false).endpoint ) end) test("Forward TILL destinations use target predecessors", function() local view = text_topology.new({ "poge huga hiyo poyo", "x", }, "utf-8") local engine = destination_engine.new() local till_h = motion_plan.build( target_plan.build(target("h"), matching_policy()), "t" ) same( domain.Position.new(1, 5), engine:calculate( view, domain.Position.new(1, 1), till_h, 1, true ).endpoint ) local till_x = motion_plan.build( target_plan.build(target("x"), matching_policy()), "t" ) local cross_line = engine:calculate( view, domain.Position.new(1, 18), till_x, 1, true ) same(domain.Position.new(1, 19), cross_line.endpoint) truthy(view:is_valid_cursor_position(cross_line.endpoint)) end) test("Backward TILL destinations use target successors", function() local engine = destination_engine.new() local same_line_view = text_topology.new({ "xabx" }, "utf-8") local till_a = motion_plan.build( target_plan.build(target("a"), matching_policy()), "T" ) same( domain.Position.new(1, 3), engine:calculate( same_line_view, domain.Position.new(1, 4), till_a, 1, true ).endpoint ) local cross_line_view = text_topology.new({ "x", "abc" }, "utf-8") local till_x = motion_plan.build( target_plan.build(target("x"), matching_policy()), "T" ) local cross_line = engine:calculate( cross_line_view, domain.Position.new(2, 2), till_x, 1, true ) same(domain.Position.new(2, 1), cross_line.endpoint) truthy(cross_line_view:is_valid_cursor_position(cross_line.endpoint)) end) test("Forward Visual-exclusive FIND uses target successors", function() local view = text_topology.new({ "abx", "cd" }, "utf-8") local engine = destination_engine.new() local target_match = target_plan.build(target("x"), matching_policy()) local regular = motion_plan.build( target_match, "f", "buffer", domain.EndpointPolicy.REGULAR ) local exclusive = motion_plan.build( target_match, "f", "buffer", domain.EndpointPolicy.VISUAL_EXCLUSIVE ) local origin = domain.Position.new(1, 1) same( domain.Position.new(1, 3), engine:calculate(view, origin, regular, 1, true).endpoint ) local adjusted = engine:calculate(view, origin, exclusive, 1, true) same(domain.Position.new(2, 1), adjusted.endpoint) truthy(view:is_valid_cursor_position(adjusted.endpoint)) end) test("Forward Visual-exclusive TILL uses target positions", function() local view = text_topology.new({ "abx", "x" }, "utf-8") local engine = destination_engine.new() local target_match = target_plan.build(target("x"), matching_policy()) local regular = motion_plan.build( target_match, "t", "buffer", domain.EndpointPolicy.REGULAR ) local exclusive = motion_plan.build( target_match, "t", "buffer", domain.EndpointPolicy.VISUAL_EXCLUSIVE ) local origin = domain.Position.new(1, 1) same( domain.Position.new(1, 2), engine:calculate(view, origin, regular, 1, true).endpoint ) same( domain.Position.new(1, 3), engine:calculate(view, origin, exclusive, 1, true).endpoint ) local cross_line_view = text_topology.new({ "abc", "x" }, "utf-8") local cross_line_origin = domain.Position.new(1, 1) same( domain.Position.new(1, 3), engine:calculate( cross_line_view, cross_line_origin, regular, 1, false ).endpoint ) same( domain.Position.new(2, 1), engine:calculate( cross_line_view, cross_line_origin, exclusive, 1, false ).endpoint ) end) test("MotionPlanFactory limits exclusive policy to character and line Visual", function() local view = text_topology.new({ "axz" }, "utf-8") local origin = domain.Position.new(1, 1) local target_match = target_plan.build(target("x"), matching_policy()) local factory = motion_plan.new() local engine = destination_engine.new() local cases = { { "v", domain.EndpointPolicy.VISUAL_EXCLUSIVE }, { "V", domain.EndpointPolicy.VISUAL_EXCLUSIVE }, { string.char(0x16), domain.EndpointPolicy.REGULAR }, { "s", domain.EndpointPolicy.REGULAR }, { "S", domain.EndpointPolicy.REGULAR }, { string.char(0x13), domain.EndpointPolicy.REGULAR }, { "n", domain.EndpointPolicy.REGULAR }, { "no", domain.EndpointPolicy.REGULAR }, } for _, case in ipairs(cases) do local context = domain.ModeContext.from_full_mode(case[1]) local kind = context.visual_kind or context.select_kind local selection = kind ~= nil and domain.Selection.active( kind, origin, origin, domain.SelectionOption.EXCLUSIVE ) or domain.Selection.inactive(domain.SelectionOption.EXCLUSIVE) local plan = factory:build_for_context( target_match, "f", context, selection, domain.SearchScope.BUFFER ) same(case[2], plan.endpoint_policy, case[1]) local expected_column = case[2] == domain.EndpointPolicy.VISUAL_EXCLUSIVE and 3 or 2 same( domain.Position.new(1, expected_column), engine:calculate(view, origin, plan, 1, true).endpoint, case[1] ) end local inclusive_visual = factory:build_for_context( target_match, "f", "v", domain.SelectionOption.INCLUSIVE, "buffer" ) same(domain.EndpointPolicy.REGULAR, inclusive_visual.endpoint_policy) local exclusive_backward = factory:build_for_context( target_match, "F", "v", domain.SelectionOption.EXCLUSIVE, "buffer" ) same( domain.Position.new(1, 2), engine:calculate( view, domain.Position.new(1, 3), exclusive_backward, 1, true ).endpoint ) end) test("FIND requires destinations on the strict motion side", function() local view = text_topology.new({ "axa" }, "utf-8") local engine = destination_engine.new() local target_match = target_plan.build(target("a"), matching_policy()) local forward = motion_plan.build(target_match, "f") local backward = motion_plan.build(target_match, "F") same( domain.Position.new(1, 3), engine:calculate( view, domain.Position.new(1, 1), forward, 1, true ).endpoint ) same( domain.Position.new(1, 1), engine:calculate( view, domain.Position.new(1, 3), backward, 1, true ).endpoint ) same( domain.SearchStatus.BOUNDARY_BEFORE_ANY, engine:calculate( view, domain.Position.new(1, 3), forward, 1, false ).status ) same( domain.SearchStatus.BOUNDARY_BEFORE_ANY, engine:calculate( view, domain.Position.new(1, 1), backward, 1, false ).status ) end) test("First TILL moves accept an adjacent stationary destination", function() local engine = destination_engine.new() local forward_view = text_topology.new({ "ab" }, "utf-8") local forward = motion_plan.build( target_plan.build(target("b"), matching_policy()), "t" ) local forward_origin = domain.Position.new(1, 1) local forward_outcome = engine:calculate( forward_view, forward_origin, forward, 1, true ) same(domain.SearchStatus.COMPLETE, forward_outcome.status) same(forward_origin, forward_outcome.endpoint) same(1, forward_outcome.successful_steps) local backward_view = text_topology.new({ "ba" }, "utf-8") local backward = motion_plan.build( target_plan.build(target("b"), matching_policy()), "T" ) local backward_origin = domain.Position.new(1, 2) local backward_outcome = engine:calculate( backward_view, backward_origin, backward, 1, true ) same(domain.SearchStatus.COMPLETE, backward_outcome.status) same(backward_origin, backward_outcome.endpoint) same(1, backward_outcome.successful_steps) end) test("Later TILL moves skip an adjacent stationary destination", function() local engine = destination_engine.new() local forward_view = text_topology.new({ "abxb" }, "utf-8") local forward_origin = domain.Position.new(1, 1) local forward = motion_plan.build( target_plan.build(target("b"), matching_policy()), "t" ) same( forward_origin, engine:calculate( forward_view, forward_origin, forward, 1, true ).endpoint ) same( domain.Position.new(1, 3), engine:calculate( forward_view, forward_origin, forward, 1, false ).endpoint ) local backward_view = text_topology.new({ "bxba" }, "utf-8") local backward_origin = domain.Position.new(1, 4) local backward = motion_plan.build( target_plan.build(target("b"), matching_policy()), "T" ) same( backward_origin, engine:calculate( backward_view, backward_origin, backward, 1, true ).endpoint ) same( domain.Position.new(1, 2), engine:calculate( backward_view, backward_origin, backward, 1, false ).endpoint ) end) test("Sequential counts reuse each accepted destination as origin", function() local view = text_topology.new({ "abxbxb" }, "utf-8") local matched_starts = {} local target_match = domain.TargetPlan.new({ target = target("b"), kind = domain.TargetPlanKind.LITERAL, case_mode = domain.CaseMode.SENSITIVE, matcher = function(character, position) if character == "b" then matched_starts[#matched_starts + 1] = tostring(position) return true end return false end, }) local outcome = destination_engine.calculate( view, domain.Position.new(1, 1), motion_plan.build(target_match, "t"), 3, true ) same(domain.SearchStatus.COMPLETE, outcome.status) same(domain.Position.new(1, 5), outcome.endpoint) same(3, outcome.successful_steps) list_same( { "(1,2)", "(1,2)", "(1,4)", "(1,4)", "(1,6)" }, matched_starts ) end) test("Count calculation stops when its fixed boundary is reached", function() local visits = 0 local view = text_topology.new({ "axaxa" }, "utf-8") local target_match = domain.TargetPlan.new({ target = target("a"), kind = domain.TargetPlanKind.LITERAL, case_mode = domain.CaseMode.SENSITIVE, matcher = function(character) visits = visits + 1 return character == "a" end, }) local plan = motion_plan.build(target_match, "f", "buffer") local engine = destination_engine.new() local origin = domain.Position.new(1, 1) local complete = engine:calculate(view, origin, plan, 2, true) same(domain.SearchStatus.COMPLETE, complete.status) same(domain.Position.new(1, 5), complete.endpoint) same(4, visits) visits = 0 local incomplete = engine:calculate(view, origin, plan, 3, true) falsy(incomplete.complete) same(4, visits) end) test("Complete outcomes report the last destination after every count unit", function() local view = text_topology.new({ "ababa" }, "utf-8") local target_match = target_plan.build(target("a"), matching_policy()) local engine = destination_engine.new() local forward = motion_plan.build(target_match, "f") local backward = motion_plan.build(target_match, "F") local forward_outcome = engine:calculate( view, domain.Position.new(1, 1), forward, domain.Count.new(2), true ) same(domain.SearchStatus.COMPLETE, forward_outcome.status) truthy(forward_outcome.complete) same(domain.Position.new(1, 5), forward_outcome.endpoint) same(2, forward_outcome.successful_steps) local backward_outcome = engine:calculate( view, forward_outcome.endpoint, backward, 2, false ) same(domain.SearchStatus.COMPLETE, backward_outcome.status) truthy(backward_outcome.complete) same(domain.Position.new(1, 1), backward_outcome.endpoint) same(2, backward_outcome.successful_steps) end) test("Partial outcomes retain the last intermediate destination", function() local engine = destination_engine.new() local find_view = text_topology.new({ "axaxa" }, "utf-8") local find_plan = motion_plan.build( target_plan.build(target("a"), matching_policy()), "f" ) local find_partial = engine:calculate( find_view, domain.Position.new(1, 1), find_plan, 3, true ) same(domain.SearchStatus.BOUNDARY_AFTER_PARTIAL, find_partial.status) falsy(find_partial.complete) same(domain.Position.new(1, 5), find_partial.endpoint) same(2, find_partial.successful_steps) local till_view = text_topology.new({ "abxb" }, "utf-8") local till_plan = motion_plan.build( target_plan.build(target("b"), matching_policy()), "t" ) local till_partial = engine:calculate( till_view, domain.Position.new(1, 1), till_plan, 3, true ) same(domain.SearchStatus.BOUNDARY_AFTER_PARTIAL, till_partial.status) same(domain.Position.new(1, 3), till_partial.endpoint) same(2, till_partial.successful_steps) end) test("Boundary-before-any outcomes preserve the original position", function() local engine = destination_engine.new() local view = text_topology.new({ "", "abc" }, "utf-8") local origin = domain.Position.new(1, 1) local missing = motion_plan.build( target_plan.build(target("z"), matching_policy()), "f", "buffer" ) local missing_outcome = engine:calculate(view, origin, missing, 4, true) same(domain.SearchStatus.BOUNDARY_BEFORE_ANY, missing_outcome.status) same(origin, missing_outcome.endpoint) same(0, missing_outcome.successful_steps) local adjacent_view = text_topology.new({ "ab" }, "utf-8") local adjacent_origin = domain.Position.new(1, 1) local repeated_till = motion_plan.build( target_plan.build(target("b"), matching_policy()), "t" ) local adjacent_outcome = engine:calculate( adjacent_view, adjacent_origin, repeated_till, 1, false ) same(domain.SearchStatus.BOUNDARY_BEFORE_ANY, adjacent_outcome.status) same(adjacent_origin, adjacent_outcome.endpoint) same(0, adjacent_outcome.successful_steps) local all_empty = text_topology.new({ "", "" }, "utf-8") local empty_origin = domain.Position.new(2, 1) local empty_outcome = engine:calculate( all_empty, empty_origin, missing, 1, true ) same(domain.SearchStatus.BOUNDARY_BEFORE_ANY, empty_outcome.status) same(empty_origin, empty_outcome.endpoint) end) test("MotionPlanFactory samples live search scope into each plan", function() local host = MemoryHost.new({ configuration = { search_current_line_only = false }, }) local service = policy.new(host) local factory = motion_plan.new(service) local target_match = target_plan.build(target("a"), matching_policy()) local buffer_plan = factory:build(target_match, "f") same(domain.SearchScope.BUFFER, buffer_plan.search_scope) host:set_configuration("search_current_line_only", true) local line_plan = factory:build(target_match, "f") same(domain.SearchScope.CURRENT_LINE, line_plan.search_scope) same(domain.SearchScope.BUFFER, buffer_plan.search_scope) end) test("DestinationEngine matches the required basic TILL sequences", function() local view = text_topology.new({ "poge huga hiyo poyo" }, "utf-8") local engine = destination_engine.new() local till_h = motion_plan.build( target_plan.build(target("h"), matching_policy()), "t" ) local first_h = engine:calculate( view, domain.Position.new(1, 1), till_h, 1, true ) same(domain.Position.new(1, 5), first_h.endpoint) local second_h = engine:calculate(view, first_h.endpoint, till_h, 1, false) same(domain.Position.new(1, 10), second_h.endpoint) local till_o = motion_plan.build( target_plan.build(target("o"), matching_policy()), "t" ) local first_o = engine:calculate( view, domain.Position.new(1, 14), till_o, 1, true ) same(domain.Position.new(1, 16), first_o.endpoint) local second_o = engine:calculate(view, first_o.endpoint, till_o, 1, false) same(domain.Position.new(1, 18), second_o.endpoint) end) test("Buffer-wide destinations cross lines and ignore ambient wrapping", function() local view = text_topology.new({ "foo bar baz", "poge huga hiyo poyo", }, "utf-8") local target_match = target_plan.build(target("a"), matching_policy()) local engine = destination_engine.new() local forward = motion_plan.build(target_match, "f", "buffer") local backward = motion_plan.build(target_match, "F", "buffer") local current = domain.Position.new(1, 1) local expected = { domain.Position.new(1, 6), domain.Position.new(1, 10), domain.Position.new(2, 9), domain.Position.new(1, 10), domain.Position.new(1, 6), } local plans = { forward, forward, forward, backward, backward } for index, plan in ipairs(plans) do local outcome = engine:calculate(view, current, plan, 1, index == 1) same(domain.SearchStatus.COMPLETE, outcome.status) same(expected[index], outcome.endpoint) current = outcome.endpoint end local line_forward = motion_plan.build(target_match, "f", "current_line") local line_last = engine:calculate( view, domain.Position.new(1, 6), line_forward, 1, false ) same(domain.Position.new(1, 10), line_last.endpoint) local line_boundary = engine:calculate( view, line_last.endpoint, line_forward, 1, false ) same(domain.SearchStatus.BOUNDARY_BEFORE_ANY, line_boundary.status) same(line_last.endpoint, line_boundary.endpoint) local saved_wrapscan = vim.o.wrapscan local outcomes = {} for index, wrapscan in ipairs({ false, true }) do vim.o.wrapscan = wrapscan outcomes[index] = engine:calculate( view, domain.Position.new(2, 9), forward, 1, false ) end vim.o.wrapscan = saved_wrapscan for _, outcome in ipairs(outcomes) do same(domain.SearchStatus.BOUNDARY_BEFORE_ANY, outcome.status) same(domain.Position.new(2, 9), outcome.endpoint) end end) test("Destination requests retain their initial match-start region", function() local view = text_topology.new({ "ax", "ax" }, "utf-8") local target_match = target_plan.build(target("x"), matching_policy()) local plan = motion_plan.build( target_match, "f", "current_line", domain.EndpointPolicy.VISUAL_EXCLUSIVE ) local outcome = destination_engine.calculate( view, domain.Position.new(1, 1), plan, 2, true ) same(domain.SearchStatus.BOUNDARY_AFTER_PARTIAL, outcome.status) same(domain.Position.new(2, 1), outcome.endpoint) same(1, outcome.successful_steps) end) test("Multibyte destination sequences stay on valid byte starts", function() local expected_columns = { ["utf-8"] = 4, ["cp932"] = 3, ["euc-jp"] = 3, } for encoding, expected_column in pairs(expected_columns) do local view = text_topology.new({ "a", MIGEMO_HIRAGANA_A .. "a", "a", }, encoding) local target_match = target_plan.build(target("a"), matching_policy()) local forward = motion_plan.build(target_match, "f", "buffer") local backward = motion_plan.build(target_match, "F", "buffer") local plans = { forward, forward, backward, backward } local expected = { domain.Position.new(2, expected_column), domain.Position.new(3, 1), domain.Position.new(2, expected_column), domain.Position.new(1, 1), } local current = domain.Position.new(1, 1) for index, plan in ipairs(plans) do local outcome = destination_engine.calculate( view, current, plan, 1, index == 1 ) same(domain.SearchStatus.COMPLETE, outcome.status, encoding) same(expected[index], outcome.endpoint, encoding) truthy(view:is_character_start(outcome.endpoint), encoding) current = outcome.endpoint end local till = motion_plan.build(target_match, "t", "buffer") local till_outcome = destination_engine.calculate( view, domain.Position.new(1, 1), till, 1, true ) same(domain.Position.new(2, 1), till_outcome.endpoint, encoding) truthy(view:is_valid_cursor_position(till_outcome.endpoint), encoding) end end) test("DestinationEngine leaves cursor and sequence state unchanged", function() local state, transitions = fresh_sequence_state() local target_value = target("a") transitions:BeginAcquisition("n", "f") transitions:CommitAcquiredTarget("n", target_value) transitions:CommitCommandSuccess("n", domain.Position.new(1, 1), false) local host = MemoryHost.new({ buffer_lines = { "abaca" }, cursor = { line = 1, byte_column = 1 }, effective_encoding = "utf-8", }) local view = text_topology.from_host(host) local origin = host:read_cursor() host:clear_operations() local state_before = state:to_table() local plan = motion_plan.build( target_plan.build(target_value, matching_policy()), "f", "buffer", "regular" ) local plan_before = plan:to_table() local outcome = destination_engine.calculate(view, origin, plan, 2, true) same(domain.SearchStatus.COMPLETE, outcome.status) same(domain.Position.new(1, 5), outcome.endpoint) same(0, #host:operations()) truthy(vim.deep_equal(state_before, state:to_table())) truthy(vim.deep_equal(plan_before, plan:to_table())) same(origin, host:read_cursor()) end) test("Motion execution routes every Visual kind to the Visual path", function() local visual_modes = { "v", "V", string.char(0x16), } for _, mode in ipairs(visual_modes) do same( motion_executor.ExecutionPath.VISUAL, motion_executor.execution_path(mode), mode ) end end) test("Motion execution routes non-Visual contexts to the command path", function() local command_modes = { "n", "no", "nov", "noV", "no" .. string.char(0x16), "s", "S", string.char(0x13), "niI", "normal-extension", } for _, mode in ipairs(command_modes) do same( motion_executor.ExecutionPath.COMMAND, motion_executor.execution_path(mode), mode ) end end) test("Command execution saves its origin before destination calculation", function() local host = MemoryHost.new({ buffer_lines = { "abc" }, cursor = { line = 1, byte_column = 1 }, }) local view = text_topology.from_host(host) local saved_origin local engine = { calculate = function(_, _, origin) saved_origin = origin host:set_cursor(domain.Position.new(1, 3)) return domain.SearchOutcome.boundary_before_any(origin) end, } local executor = motion_executor.new({ host = host, destination_engine = engine, }) local plan = motion_plan.build( target_plan.build(target("z"), matching_policy()), "f" ) local outcome = executor:execute(view, "n", plan, 1, true) same(domain.Position.new(1, 1), saved_origin) same(domain.ActionKind.FAILED_SEARCH, outcome.kind) same(domain.Position.new(1, 1), outcome.position) end) test("Command execution applies the last reached count endpoint", function() local host = MemoryHost.new({ buffer_lines = { "axaxa" }, cursor = { line = 1, byte_column = 1 }, }) local view = text_topology.from_host(host) local executor = motion_executor.new(host) local plan = motion_plan.build( target_plan.build(target("a"), matching_policy()), "f" ) local outcome = executor:execute(view, "n", plan, 3, true) same(domain.ActionKind.FAILED_SEARCH, outcome.kind) same(domain.SearchStatus.BOUNDARY_AFTER_PARTIAL, outcome.search_outcome.status) same(2, outcome.successful_steps) same(domain.Position.new(1, 5), host:read_cursor()) same(domain.Position.new(1, 5), outcome.position) end) test("Incomplete command execution preserves successful history", function() local state, transitions = fresh_sequence_state() local previous_target = target("a") local previous_landing = domain.Position.new(1, 1) transitions:BeginAcquisition("n", "f") transitions:CommitAcquiredTarget("n", previous_target, 25) transitions:CommitCommandSuccess("n", previous_landing, false) local host = MemoryHost.new({ buffer_lines = { "axaxa" }, cursor = previous_landing, }) local executor = motion_executor.new(host) local plan = motion_plan.build( target_plan.build(previous_target, matching_policy()), "f" ) local descriptor_before = state:get_previous_descriptor("n") local first_move_before = state:get_first_move("n") local direction_before = state.moved_forward local outcome = executor:execute( text_topology.from_host(host), "n", plan, 3, false ) same(domain.ActionKind.FAILED_SEARCH, outcome.kind) same(domain.Position.new(1, 5), host:read_cursor()) same(previous_landing, state:get_previous_landing("n")) same(descriptor_before, state:get_previous_descriptor("n")) same(first_move_before, state:get_first_move("n")) same(previous_target, state:get_previous_target("n")) same(direction_before, state.moved_forward) same(25, state.repeat_timestamp_ms) end) test("Complete command direction compares destination with saved origin", function() local origin = domain.Position.new(2, 3) truthy( motion_executor.moved_forward(origin, domain.Position.new(2, 4)) ) truthy( motion_executor.moved_forward(origin, domain.Position.new(3, 1)) ) falsy( motion_executor.moved_forward(origin, domain.Position.new(2, 2)) ) falsy(motion_executor.moved_forward(origin, origin)) 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 8: %d tests passed\n", passed))