summaryrefslogtreecommitdiff
path: root/lua/clever_f/target_plan.lua
diff options
context:
space:
mode:
Diffstat (limited to 'lua/clever_f/target_plan.lua')
-rw-r--r--lua/clever_f/target_plan.lua306
1 files changed, 306 insertions, 0 deletions
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