summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lua/clever_f/case_policy.lua190
-rw-r--r--lua/clever_f/policy.lua17
-rw-r--r--lua/clever_f/target_plan.lua306
-rw-r--r--lua/clever_f/text_topology.lua7
-rw-r--r--tests/run.lua338
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))