local case_policy = require("clever_tee.case_policy") local domain = require("clever_tee.domain") local migemo_catalog = require("clever_tee.migemo_catalog") local text_topology = require("clever_tee.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 function require_migemo_catalog(catalog) if catalog ~= nil and not migemo_catalog.MigemoCatalog.is(catalog) then fail("TargetPlanFactory migemo_catalog must be a MigemoCatalog", 3) end return catalog end local factory_metatable = { __index = TargetPlanFactory, __newindex = function() fail("TargetPlanFactory values are immutable", 2) end, __tostring = function() return "target-plan-factory" end, __metatable = "clever_tee.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 ), migemo_catalog = require_migemo_catalog( options.migemo_catalog or options.catalog ), } 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, use_migemo = 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 local use_migemo = match_policy.use_migemo if use_migemo == nil then use_migemo = false end return { ignore_case = require_boolean(match_policy.ignore_case, "ignore_case"), smart_case = require_boolean(match_policy.smart_case, "smart_case"), use_migemo = require_boolean(use_migemo, "use_migemo"), 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 local function is_ascii_alphabetic(character) if type(character) ~= "string" or #character ~= 1 then return false end local code = character:byte(1) return (code >= string.byte("a") and code <= string.byte("z")) or (code >= string.byte("A") and code <= string.byte("Z")) end M.is_ascii_alphabetic = is_ascii_alphabetic local function context_field(context, primary, alternate) local value = context[primary] if value == nil and alternate ~= nil then value = context[alternate] end return value end local function context_table(match_policy, search_context) if text_topology.TextView.is(search_context) then return { text_view = search_context } end if search_context ~= nil and type(search_context) ~= "table" then fail("target search context must be a table or TextView", 3) end local context = search_context or {} if search_context == nil and type(match_policy) == "table" then if match_policy.text_view ~= nil or match_policy.view ~= nil or match_policy.search_scope ~= nil or match_policy.scope ~= nil or match_policy.origin ~= nil or match_policy.current_line ~= nil or match_policy.effective_encoding ~= nil or match_policy.encoding ~= nil then context = match_policy end end return context end local function active_policy_service(factory, match_policy) if type(match_policy) == "table" and type(match_policy.sample_match) == "function" then return match_policy end return factory_records[factory].policy end local function search_scope(factory, match_policy, context) local value = context_field(context, "search_scope", "scope") if value == nil and type(match_policy) == "table" then value = match_policy.search_scope if value == nil and match_policy.search_current_line_only ~= nil then value = match_policy.search_current_line_only and domain.SearchScope.CURRENT_LINE or domain.SearchScope.BUFFER end end if value == nil then local service = active_policy_service(factory, match_policy) if service ~= nil and type(service.sample_search) == "function" then value = service:sample_search().search_scope end end if value == nil then return domain.SearchScope.BUFFER end if value == "line" then value = domain.SearchScope.CURRENT_LINE end return domain.SearchScope.from_string(value) end local function migemo_search_context(factory, match_policy, search_context) local context = context_table(match_policy, search_context) local view = context_field(context, "text_view", "view") if not text_topology.TextView.is(view) then fail("Migemo target planning requires a TextView", 3) end local scope = search_scope(factory, match_policy, context) local origin = context.origin if origin == nil then origin = context.current_line end if scope == domain.SearchScope.CURRENT_LINE and origin == nil then fail("current-line Migemo planning requires an origin line", 3) end local line_number if scope == domain.SearchScope.CURRENT_LINE then line_number = type(origin) == "number" and origin or domain.Position.coerce(origin).line end local encoding = context_field(context, "effective_encoding", "encoding") or view.requested_encoding or view.effective_encoding return { view = view, scope = scope, origin = origin, line_number = line_number, encoding = encoding, bounds = view:match_start_bounds(scope, origin), } end local function selected_migemo_catalog(factory, match_policy) local record = factory_records[factory] if record.migemo_catalog == nil then record.migemo_catalog = migemo_catalog.new({ policy = active_policy_service(factory, match_policy), }) end return record.migemo_catalog end local function migemo_matcher( target_character, case_mode, resolver, dictionary, context ) local target_equal = resolver:comparator(target_character, case_mode) local assertion = dictionary:predicate(target_character, case_mode) return function(candidate_character, candidate_position, candidate_view) if candidate_position == nil then fail("Migemo matching requires a candidate Position", 2) end local position = domain.Position.coerce(candidate_position) local view = candidate_view or context.view if not text_topology.TextView.is(view) then fail("Migemo matching requires a TextView", 2) end if not context.bounds:contains(position) or not view:is_character_start(position) then return false end local actual_character = view:character_at(position) if candidate_character ~= actual_character then return false end if is_ascii_alphabetic(actual_character) and not target_equal(actual_character) then return false end return assertion(view:text_suffix(position)) end end function TargetPlanFactory:build(target, match_policy, search_context) 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 if sampled.use_migemo and is_ascii_alphabetic(target.value) then local context = migemo_search_context(self, match_policy, search_context) local active = context.scope == domain.SearchScope.BUFFER or context.view:line_byte_length(context.line_number) > context.view:line_character_count(context.line_number) if active then local dictionary = selected_migemo_catalog(self, match_policy):get( context.encoding, active_policy_service(self, match_policy) ) return new_plan( target, domain.TargetPlanKind.MIGEMO, case_mode, migemo_matcher( target.value, case_mode, record.case_resolver, dictionary, context ) ) end 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 local function is_search_context(value) return text_topology.TextView.is(value) or (type(value) == "table" and ( value.text_view ~= nil or value.view ~= nil or value.search_scope ~= nil or value.scope ~= nil or value.origin ~= nil or value.current_line ~= nil or value.effective_encoding ~= nil or value.encoding ~= nil )) end function M.build(target, match_policy, options, search_context) if search_context == nil and is_search_context(options) then search_context = options options = nil end return TargetPlanFactory.new(options):build( target, match_policy, search_context ) end function M.build_for_view(target, view, origin, scope, match_policy, options) return TargetPlanFactory.new(options):build(target, match_policy, { text_view = view, origin = origin, search_scope = scope, }) end M.create = M.build M.create_plan = M.build M.SYMBOL_SET_STRING = M.SYMBOLS return M