diff options
| -rw-r--r-- | lua/clever_f/case_policy.lua | 190 | ||||
| -rw-r--r-- | lua/clever_f/policy.lua | 17 | ||||
| -rw-r--r-- | lua/clever_f/target_plan.lua | 306 | ||||
| -rw-r--r-- | lua/clever_f/text_topology.lua | 7 | ||||
| -rw-r--r-- | tests/run.lua | 338 |
5 files changed, 842 insertions, 16 deletions
diff --git a/lua/clever_f/case_policy.lua b/lua/clever_f/case_policy.lua new file mode 100644 index 0000000..2631d5b --- /dev/null +++ b/lua/clever_f/case_policy.lua @@ -0,0 +1,190 @@ +local domain = require("clever_f.domain") + +local M = {} +local CasePolicyResolver = {} +M.CasePolicyResolver = CasePolicyResolver + +local resolver_records = setmetatable({}, { __mode = "k" }) + +local function fail(message, level) + error(message, (level or 1) + 1) +end + +local function require_target(target) + if not domain.TargetValue.is(target) then + fail("case policy target must be a TargetValue", 2) + end + return target +end + +local function require_boolean(value, name) + if type(value) ~= "boolean" then + fail((name or "value") .. " must be a Boolean", 2) + end + return value +end + +local function require_string(value, name, allow_empty) + if type(value) ~= "string" or (not allow_empty and value == "") then + fail((name or "value") .. " must be a string", 2) + end + return value +end + +local function default_lowercase(value) + local runtime = rawget(_G, "vim") + if type(runtime) ~= "table" + or type(runtime.fn) ~= "table" + or type(runtime.fn.tolower) ~= "function" + then + fail("editor-compatible case conversion requires Nvim or a lowercase converter", 2) + end + return runtime.fn.tolower(value) +end + +local function lowercase_function(options) + if options == nil then + return default_lowercase + end + if type(options) == "function" then + return options + end + if type(options) ~= "table" then + fail("case policy options must be a table or lowercase function", 3) + end + local lowercase = options.lowercase or options.to_lower + if lowercase == nil then + return default_lowercase + end + if type(lowercase) ~= "function" then + fail("case policy lowercase converter must be a function", 3) + end + return lowercase +end + +local resolver_metatable = { + __index = CasePolicyResolver, + __newindex = function() + fail("CasePolicyResolver values are immutable", 2) + end, + __tostring = function() + return "case-policy-resolver" + end, + __metatable = "clever_f.case_policy.CasePolicyResolver", +} + +function CasePolicyResolver.new(options) + if CasePolicyResolver.is(options) then + return options + end + local resolver = setmetatable({}, resolver_metatable) + resolver_records[resolver] = { + lowercase = lowercase_function(options), + } + return resolver +end + +function CasePolicyResolver.is(value) + return type(value) == "table" and resolver_records[value] ~= nil +end + +function M.new(options) + return CasePolicyResolver.new(options) +end + +setmetatable(M, { + __call = function(_, options) + return CasePolicyResolver.new(options) + end, +}) + +function M.is_lower_ascii(value) + if domain.TargetValue.is(value) then + value = value.value + end + if type(value) ~= "string" or #value ~= 1 then + return false + end + local byte = value:byte(1) + return byte >= string.byte("a") and byte <= string.byte("z") +end + +function M.resolve_case_mode(target, ignore_case, smart_case) + target = require_target(target) + require_boolean(ignore_case, "ignore_case") + require_boolean(smart_case, "smart_case") + + if ignore_case then + return domain.CaseMode.INSENSITIVE + end + if smart_case and M.is_lower_ascii(target) then + return domain.CaseMode.INSENSITIVE + end + return domain.CaseMode.SENSITIVE +end + +function CasePolicyResolver:resolve(target, ignore_case, smart_case) + if type(ignore_case) == "table" and smart_case == nil then + local match_policy = ignore_case + ignore_case = match_policy.ignore_case + smart_case = match_policy.smart_case + end + return M.resolve_case_mode(target, ignore_case, smart_case) +end + +function CasePolicyResolver:lowercase(value) + require_string(value, "case comparison value", true) + local lowercase = resolver_records[self].lowercase(value) + if type(lowercase) ~= "string" then + fail("case policy lowercase converter must return a string", 2) + end + return lowercase +end + +function CasePolicyResolver:equal(left, right, case_mode) + require_string(left, "left case comparison value", true) + require_string(right, "right case comparison value", true) + case_mode = domain.CaseMode.from_string(case_mode) + + if case_mode == domain.CaseMode.SENSITIVE then + return left == right + end + return self:lowercase(left) == self:lowercase(right) +end + +function CasePolicyResolver:comparator(target_character, case_mode) + require_string(target_character, "target character", true) + case_mode = domain.CaseMode.from_string(case_mode) + + if target_character == "" then + return function() + return false + end + end + + if case_mode == domain.CaseMode.SENSITIVE then + return function(candidate_character) + return type(candidate_character) == "string" + and candidate_character ~= "" + and candidate_character == target_character + end + end + + local folded_target = self:lowercase(target_character) + local lowercase = resolver_records[self].lowercase + return function(candidate_character) + if type(candidate_character) ~= "string" or candidate_character == "" then + return false + end + local folded_candidate = lowercase(candidate_character) + if type(folded_candidate) ~= "string" then + fail("case policy lowercase converter must return a string", 2) + end + return folded_candidate == folded_target + end +end + +M.resolve = M.resolve_case_mode +M.is_lowercase_ascii = M.is_lower_ascii + +return M diff --git a/lua/clever_f/policy.lua b/lua/clever_f/policy.lua index b56487f..3dbffa8 100644 --- a/lua/clever_f/policy.lua +++ b/lua/clever_f/policy.lua @@ -1,3 +1,4 @@ +local case_policy = require("clever_f.case_policy") local domain = require("clever_f.domain") local M = {} @@ -403,22 +404,8 @@ function PolicyService:evaluate_highlight_links() return result end -local function require_target(target) - if not domain.TargetValue.is(target) then - fail("case policy target must be a TargetValue", 2) - end - return target -end - function M.resolve_case_mode(target, ignore_case, smart_case) - target = require_target(target) - validate_boolean(ignore_case, "ignore_case") - validate_boolean(smart_case, "smart_case") - - if ignore_case or (smart_case and target.value:match("^[a-z]$") ~= nil) then - return domain.CaseMode.INSENSITIVE - end - return domain.CaseMode.SENSITIVE + return case_policy.resolve_case_mode(target, ignore_case, smart_case) end function PolicyService:case_mode(target) diff --git a/lua/clever_f/target_plan.lua b/lua/clever_f/target_plan.lua new file mode 100644 index 0000000..385b111 --- /dev/null +++ b/lua/clever_f/target_plan.lua @@ -0,0 +1,306 @@ +local case_policy = require("clever_f.case_policy") +local domain = require("clever_f.domain") +local text_topology = require("clever_f.text_topology") + +local M = {} +local TargetPlanFactory = {} +M.TargetPlanFactory = TargetPlanFactory + +M.SYMBOLS = "!\"#$%&'()=~|\\-^@`[]{};:+*<>,.?_/" + +local SYMBOL_CHARACTERS = {} +local SYMBOL_SET = {} +for index = 1, #M.SYMBOLS do + local character = M.SYMBOLS:sub(index, index) + SYMBOL_CHARACTERS[index] = character + SYMBOL_SET[character] = true +end + +local factory_records = setmetatable({}, { __mode = "k" }) + +local function fail(message, level) + error(message, (level or 1) + 1) +end + +local function require_target(target) + if not domain.TargetValue.is(target) then + fail("target plan target must be a TargetValue", 2) + end + return target +end + +local function require_boolean(value, name) + if type(value) ~= "boolean" then + fail("match policy " .. name .. " must be a Boolean", 2) + end + return value +end + +local function require_string(value, name) + if type(value) ~= "string" then + fail("match policy " .. name .. " must be a string", 2) + end + return value +end + +local function copy_list(values) + local result = {} + for index = 1, #values do + result[index] = values[index] + end + return result +end + +function M.symbol_characters() + return copy_list(SYMBOL_CHARACTERS) +end + +function M.is_symbol(character) + return type(character) == "string" and SYMBOL_SET[character] == true +end + +local function require_character_list(characters, source) + if type(characters) ~= "table" then + fail("editor character splitter must return a list", 3) + end + + local result = {} + local item_count = 0 + for key, character in pairs(characters) do + if type(key) ~= "number" + or key ~= math.floor(key) + or key < 1 + or key > #characters + or type(character) ~= "string" + or character == "" + then + fail("editor character splitter must return a list of nonempty strings", 3) + end + result[key] = character + item_count = item_count + 1 + end + if item_count ~= #characters or table.concat(result) ~= source then + fail("editor character splitter must preserve the configured trigger string", 3) + end + return result +end + +function M.parse_trigger_characters(value, splitter) + if type(value) ~= "string" then + fail("chars_match_any_signs must be a string", 2) + end + splitter = splitter or text_topology.split_editor_characters + if type(splitter) ~= "function" then + fail("editor character splitter must be a function", 2) + end + return require_character_list(splitter(value), value) +end + +local function trigger_set(value, splitter) + local result = {} + for _, character in ipairs(M.parse_trigger_characters(value, splitter)) do + result[character] = true + end + return result +end + +local function false_matcher() + return false +end + +local function symbol_matcher(candidate_character) + return M.is_symbol(candidate_character) +end + +local function normalize_factory_options(options) + if options == nil then + return {} + end + if type(options) == "function" then + return { lowercase = options } + end + if type(options) ~= "table" then + fail("TargetPlanFactory options must be a table", 3) + end + if type(options.sample_match) == "function" + and options.policy == nil + and options.policy_service == nil + and options.case_resolver == nil + and options.lowercase == nil + and options.splitter == nil + and options.split_editor_characters == nil + then + return { policy = options } + end + return options +end + +local function require_policy_service(service) + if service ~= nil and (type(service) ~= "table" + or type(service.sample_match) ~= "function") + then + fail("TargetPlanFactory policy must provide sample_match", 3) + end + return service +end + +local function require_case_resolver(resolver, options) + if resolver == nil then + return case_policy.new({ + lowercase = options.lowercase, + }) + end + if type(resolver) ~= "table" + or type(resolver.resolve) ~= "function" + or type(resolver.comparator) ~= "function" + then + fail("TargetPlanFactory case resolver is invalid", 3) + end + return resolver +end + +local function require_splitter(splitter) + splitter = splitter or text_topology.split_editor_characters + if type(splitter) ~= "function" then + fail("TargetPlanFactory editor character splitter must be a function", 3) + end + return splitter +end + +local factory_metatable = { + __index = TargetPlanFactory, + __newindex = function() + fail("TargetPlanFactory values are immutable", 2) + end, + __tostring = function() + return "target-plan-factory" + end, + __metatable = "clever_f.target_plan.TargetPlanFactory", +} + +function TargetPlanFactory.new(options) + if TargetPlanFactory.is(options) then + return options + end + options = normalize_factory_options(options) + local factory = setmetatable({}, factory_metatable) + factory_records[factory] = { + policy = require_policy_service(options.policy or options.policy_service), + case_resolver = require_case_resolver(options.case_resolver, options), + splitter = require_splitter( + options.split_editor_characters or options.splitter + ), + } + return factory +end + +function TargetPlanFactory.is(value) + return type(value) == "table" and factory_records[value] ~= nil +end + +function M.new(options) + return TargetPlanFactory.new(options) +end + +setmetatable(M, { + __call = function(_, options) + return TargetPlanFactory.new(options) + end, +}) + +local function default_match_policy() + return { + ignore_case = false, + smart_case = false, + chars_match_any_signs = "", + } +end + +local function sampled_policy(factory, target, match_policy) + local service + if match_policy == nil then + service = factory_records[factory].policy + if service == nil then + return default_match_policy() + end + elseif type(match_policy) == "table" + and type(match_policy.sample_match) == "function" + then + service = match_policy + end + + if service ~= nil then + match_policy = service:sample_match(target) + end + if type(match_policy) ~= "table" then + fail("target match policy must be a table", 3) + end + + return { + ignore_case = require_boolean(match_policy.ignore_case, "ignore_case"), + smart_case = require_boolean(match_policy.smart_case, "smart_case"), + chars_match_any_signs = require_string( + match_policy.chars_match_any_signs, + "chars_match_any_signs" + ), + } +end + +local function new_plan(target, kind, case_mode, matcher) + return domain.TargetPlan.new({ + target = target, + kind = kind, + case_mode = case_mode, + matcher = matcher, + }) +end + +function TargetPlanFactory:build(target, match_policy) + target = require_target(target) + local record = factory_records[self] + local sampled = sampled_policy(self, target, match_policy) + local case_mode = record.case_resolver:resolve( + target, + sampled.ignore_case, + sampled.smart_case + ) + + if target.first_code == 0x80 then + return new_plan( + target, + domain.TargetPlanKind.EMPTY, + case_mode, + false_matcher + ) + end + + local triggers = trigger_set(sampled.chars_match_any_signs, record.splitter) + if triggers[target.value] then + return new_plan( + target, + domain.TargetPlanKind.SYMBOL, + case_mode, + symbol_matcher + ) + end + + local kind = target.value == "\\" + and domain.TargetPlanKind.BACKSLASH + or domain.TargetPlanKind.LITERAL + return new_plan( + target, + kind, + case_mode, + record.case_resolver:comparator(target.value, case_mode) + ) +end + +function M.build(target, match_policy, options) + return TargetPlanFactory.new(options):build(target, match_policy) +end + +M.create = M.build +M.create_plan = M.build +M.SYMBOL_SET_STRING = M.SYMBOLS + +return M diff --git a/lua/clever_f/text_topology.lua b/lua/clever_f/text_topology.lua index 3b9bf0f..2941cf2 100644 --- a/lua/clever_f/text_topology.lua +++ b/lua/clever_f/text_topology.lua @@ -183,6 +183,13 @@ local function require_character_list(characters) return result end +function M.split_editor_characters(text) + if type(text) ~= "string" then + fail("text to split must be a string", 2) + end + return require_character_list(default_split_editor_characters(text)) +end + local function snapshot_value(text) if domain.TextSnapshot.is(text) then return text diff --git a/tests/run.lua b/tests/run.lua index 1cf9053..9034b97 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8,10 +8,12 @@ package.path = table.concat({ local domain = require("clever_f.domain") local capabilities = require("clever_f.capabilities") +local case_policy = require("clever_f.case_policy") local policy = require("clever_f.policy") 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 = {} @@ -1483,6 +1485,340 @@ test("All-empty buffers expose empty full-buffer iteration", function() 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) + for _, item in ipairs(tests) do local ok, failure = xpcall(item.body, debug.traceback) if not ok then @@ -1492,4 +1828,4 @@ for _, item in ipairs(tests) do passed = passed + 1 end -io.stdout:write(string.format("Phase 5: %d tests passed\n", passed)) +io.stdout:write(string.format("Phase 6: %d tests passed\n", passed)) |
