local domain = require("clever_f.domain") 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 M = {} local MigemoCatalog = {} local MigemoDictionary = {} M.MigemoCatalog = MigemoCatalog M.MigemoDictionary = MigemoDictionary local catalog_records = setmetatable({}, { __mode = "k" }) local dictionary_records = setmetatable({}, { __mode = "k" }) local function fail(message, level) error(message, (level or 1) + 1) end local function copy_list(values) local result = {} for index = 1, #values do result[index] = values[index] end return result end local EXPECTED_KEYS = {} for code = string.byte("a"), string.byte("z") do EXPECTED_KEYS[#EXPECTED_KEYS + 1] = string.char(code) end for code = string.byte("A"), string.byte("Z") do EXPECTED_KEYS[#EXPECTED_KEYS + 1] = string.char(code) end local EXPECTED_KEY_SET = {} for _, key in ipairs(EXPECTED_KEYS) do EXPECTED_KEY_SET[key] = true end local ASSETS = { ["utf-8"] = { file = "utf8.vim", function_name = "clever_f#migemo#utf8#load_dict", }, cp932 = { file = "cp932.vim", function_name = "clever_f#migemo#cp932#load_dict", }, ["euc-jp"] = { file = "eucjp.vim", function_name = "clever_f#migemo#eucjp#load_dict", }, } local module_source = debug.getinfo(1, "S").source local module_file = module_source:sub(1, 1) == "@" and module_source:sub(2) or module_source local bundled_root = module_file:match("^(.*)/lua/clever_f/migemo_catalog%.lua$") local function require_nonempty_string(value, name) if type(value) ~= "string" or value == "" then fail((name or "value") .. " must be a nonempty string", 2) end return value end local function asset_path(asset) if bundled_root == nil then fail("Migemo catalog could not locate its bundled assets", 2) end return bundled_root .. "/autoload/clever_f/migemo/" .. asset.file end local function read_asset_key_order(path) local handle, open_error = io.open(path, "rb") if handle == nil then fail("Migemo asset could not be opened: " .. tostring(open_error), 2) end local keys = {} for line in handle:lines() do local key = line:match("^%s*\\%s*'([A-Za-z])'%s*:") if key ~= nil then keys[#keys + 1] = key end end handle:close() return keys end local function assert_key_order(keys, encoding) if type(keys) ~= "table" or #keys ~= #EXPECTED_KEYS then fail( "Migemo " .. encoding .. " asset must contain exactly 52 ordered keys", 2 ) end for index, expected in ipairs(EXPECTED_KEYS) do if keys[index] ~= expected then fail( "Migemo " .. encoding .. " asset keys must be ordered a through z, then A through Z", 2 ) end end end local function nvim_runtime() local runtime = rawget(_G, "vim") if type(runtime) ~= "table" or runtime.cmd == nil or type(runtime.fn) ~= "table" or type(runtime.fn.fnameescape) ~= "function" then fail("Migemo dictionary loading requires Nvim", 2) end return runtime end local function default_asset_loader(encoding, asset) local runtime = nvim_runtime() local path = asset_path(asset) local keys = read_asset_key_order(path) assert_key_order(keys, encoding) runtime.cmd("silent source " .. runtime.fn.fnameescape(path)) local loader = runtime.fn[asset.function_name] if type(loader) ~= "function" then fail("Migemo " .. encoding .. " asset did not define its dictionary loader", 2) end local dictionary = loader() return dictionary, keys, path end local function explicit_pattern(pattern, case_mode) local case_flag = case_mode == domain.CaseMode.INSENSITIVE and "\\c" or "\\C" return "\\m" .. case_flag .. "^" .. pattern end local function default_pattern_compiler(pattern, key, encoding) local runtime = nvim_runtime() if type(runtime.regex) ~= "function" or type(runtime.fn.match) ~= "function" then fail("Migemo pattern evaluation requires Nvim regular expressions", 2) end local sensitive = explicit_pattern(pattern, domain.CaseMode.SENSITIVE) local insensitive = explicit_pattern(pattern, domain.CaseMode.INSENSITIVE) local ok, compile_error = pcall(runtime.regex, sensitive) if ok then ok, compile_error = pcall(runtime.regex, insensitive) end if not ok then fail( "Migemo " .. encoding .. " pattern for '" .. key .. "' could not be compiled: " .. tostring(compile_error), 2 ) end return function(text, case_mode) if type(text) ~= "string" then fail("Migemo assertion text must be a string", 2) end case_mode = domain.CaseMode.from_string(case_mode) local selected = case_mode == domain.CaseMode.INSENSITIVE and insensitive or sensitive local matched, start_or_error = pcall(runtime.fn.match, text, selected) if not matched then fail( "Migemo " .. encoding .. " pattern for '" .. key .. "' could not be evaluated: " .. tostring(start_or_error), 2 ) end return start_or_error == 0 end end local dictionary_metatable = { __index = function(dictionary, key) local method = MigemoDictionary[key] if method ~= nil then return method end local record = dictionary_records[dictionary] if key == "encoding" or key == "effective_encoding" then return record.encoding end if key == "entry_count" then return #record.keys end if key == "asset_path" then return record.asset_path end if EXPECTED_KEY_SET[key] then return record.predicates[key] end return nil end, __newindex = function() fail("MigemoDictionary values are immutable", 2) end, __tostring = function(dictionary) return "migemo-dictionary:" .. dictionary_records[dictionary].encoding end, __metatable = "clever_f.migemo_catalog.MigemoDictionary", } local function validate_dictionary_data(data, ordered_keys, encoding) if type(data) ~= "table" then fail("Migemo " .. encoding .. " asset must return a dictionary", 3) end assert_key_order(ordered_keys, encoding) local count = 0 for key, pattern in pairs(data) do count = count + 1 if EXPECTED_KEY_SET[key] ~= true then fail("Migemo " .. encoding .. " asset contains an unexpected key", 3) end if type(pattern) ~= "string" or pattern == "" then fail("Migemo " .. encoding .. " patterns must be nonempty strings", 3) end end if count ~= #EXPECTED_KEYS then fail("Migemo " .. encoding .. " asset must contain exactly 52 keys", 3) end for _, key in ipairs(EXPECTED_KEYS) do if data[key] == nil then fail("Migemo " .. encoding .. " asset is missing key '" .. key .. "'", 3) end end end local function new_dictionary(encoding, data, ordered_keys, path, compiler) validate_dictionary_data(data, ordered_keys, encoding) local patterns = {} local predicates = {} for _, key in ipairs(EXPECTED_KEYS) do local pattern = data[key] patterns[key] = pattern local predicate = compiler(pattern, key, encoding) if type(predicate) ~= "function" then fail("Migemo pattern compiler must return a predicate", 3) end predicates[key] = predicate end local dictionary = setmetatable({}, dictionary_metatable) dictionary_records[dictionary] = { encoding = encoding, keys = copy_list(ordered_keys), patterns = patterns, predicates = predicates, asset_path = path, } return dictionary end function MigemoDictionary.is(value) return type(value) == "table" and dictionary_records[value] ~= nil end local function dictionary_record(dictionary) if not MigemoDictionary.is(dictionary) then fail("value must be a MigemoDictionary", 3) end return dictionary_records[dictionary] end function MigemoDictionary:keys() return copy_list(dictionary_record(self).keys) end function MigemoDictionary:has(key) return type(key) == "string" and dictionary_record(self).predicates[key] ~= nil end function MigemoDictionary:pattern(key) require_nonempty_string(key, "Migemo dictionary key") local pattern = dictionary_record(self).patterns[key] if pattern == nil then fail("Migemo dictionary key must be one ASCII alphabetic character", 2) end return pattern end function MigemoDictionary:predicate(key, case_mode) require_nonempty_string(key, "Migemo dictionary key") local predicate = dictionary_record(self).predicates[key] if predicate == nil then fail("Migemo dictionary key must be one ASCII alphabetic character", 2) end if case_mode == nil then return predicate end case_mode = domain.CaseMode.from_string(case_mode) return function(text) return predicate(text, case_mode) end end function MigemoDictionary:matches(key, text, case_mode) return self:predicate(key)(text, case_mode) end function MigemoDictionary:to_table() local record = dictionary_record(self) return { encoding = record.encoding, entry_count = #record.keys, keys = copy_list(record.keys), asset_path = record.asset_path, } end local function normalize_catalog_options(options) if options == nil then return {} end if type(options) ~= "table" then fail("MigemoCatalog options must be a table", 3) end if type(options.disable_migemo_for_unsupported_encoding) == "function" and options.policy == nil and options.policy_service == nil and options.transitions == nil and options.state == nil and options.asset_loader == nil and options.pattern_compiler == nil then return { policy = options } end return options end local function require_policy(service) if service ~= nil and (type(service) ~= "table" or type(service.disable_migemo_for_unsupported_encoding) ~= "function") then fail( "MigemoCatalog policy must provide disable_migemo_for_unsupported_encoding", 3 ) end return service end local function require_transitions(transitions, state) transitions = transitions or state_transitions.new(state) if type(transitions) ~= "table" or type(transitions.CacheMigemo) ~= "function" or type(transitions.state) ~= "function" or transitions:state() ~= state then fail("MigemoCatalog transitions must mutate its SequenceState", 3) end return transitions end local function selected_function(value, fallback, name) value = value or fallback if type(value) ~= "function" then fail("MigemoCatalog " .. name .. " must be a function", 3) end return value end local catalog_metatable = { __index = MigemoCatalog, __newindex = function() fail("MigemoCatalog values are immutable", 2) end, __tostring = function() return "migemo-catalog" end, __metatable = "clever_f.migemo_catalog.MigemoCatalog", } function MigemoCatalog.new(options) if MigemoCatalog.is(options) then return options end options = normalize_catalog_options(options) local state = options.state or sequence_state.get() if not sequence_state.is(state) then fail("MigemoCatalog requires the plugin-global SequenceState", 2) end local catalog = setmetatable({}, catalog_metatable) catalog_records[catalog] = { state = state, transitions = require_transitions(options.transitions, state), policy = require_policy(options.policy or options.policy_service), disable_migemo = options.disable_migemo, asset_loader = selected_function( options.asset_loader, default_asset_loader, "asset_loader" ), pattern_compiler = selected_function( options.pattern_compiler, default_pattern_compiler, "pattern_compiler" ), load_counts = {}, } if catalog_records[catalog].disable_migemo ~= nil and type(catalog_records[catalog].disable_migemo) ~= "function" then fail("MigemoCatalog disable_migemo must be a function", 2) end return catalog end function MigemoCatalog.is(value) return type(value) == "table" and catalog_records[value] ~= nil end function M.new(options) return MigemoCatalog.new(options) end setmetatable(M, { __call = function(_, options) return MigemoCatalog.new(options) end, }) local function catalog_record(catalog) if not MigemoCatalog.is(catalog) then fail("value must be a MigemoCatalog", 3) end return catalog_records[catalog] end local function unsupported(catalog, requested_encoding, policy_override) local record = catalog_record(catalog) local active_policy = policy_override or record.policy if active_policy ~= nil then require_policy(active_policy):disable_migemo_for_unsupported_encoding() elseif record.disable_migemo ~= nil then record.disable_migemo() end error( "clever-f: Encoding '" .. requested_encoding .. "' is not supported. Migemo is disabled", 0 ) end function MigemoCatalog:get(effective_encoding, policy_override) local requested = require_nonempty_string(effective_encoding, "effective encoding") local encoding = text_topology.normalize_encoding(requested) local asset = ASSETS[encoding] if asset == nil then return unsupported(self, requested, policy_override) end local record = catalog_record(self) local cached = record.state:get_migemo(encoding) if cached ~= nil then if not MigemoDictionary.is(cached) then fail("Migemo cache contains an invalid dictionary", 2) end return cached end local data, ordered_keys, path = record.asset_loader(encoding, asset) local dictionary = new_dictionary( encoding, data, ordered_keys, path, record.pattern_compiler ) record.transitions:CacheMigemo(encoding, dictionary) record.load_counts[encoding] = (record.load_counts[encoding] or 0) + 1 return dictionary end function MigemoCatalog:load_count(effective_encoding) local encoding = text_topology.normalize_encoding(effective_encoding) return catalog_record(self).load_counts[encoding] or 0 end function MigemoCatalog:cached(effective_encoding) local encoding = text_topology.normalize_encoding(effective_encoding) local value = catalog_record(self).state:get_migemo(encoding) if value ~= nil and not MigemoDictionary.is(value) then fail("Migemo cache contains an invalid dictionary", 2) end return value end MigemoCatalog.load = MigemoCatalog.get MigemoCatalog.select = MigemoCatalog.get MigemoCatalog.dictionary = MigemoCatalog.get function M.expected_keys() return copy_list(EXPECTED_KEYS) end function M.supported_encodings() return { "utf-8", "cp932", "euc-jp" } end function M.bundled_asset_path(effective_encoding) local encoding = text_topology.normalize_encoding(effective_encoding) local asset = ASSETS[encoding] if asset == nil then return nil end return asset_path(asset) end M.load = function(effective_encoding, options) return MigemoCatalog.new(options):get(effective_encoding) end M.EXPECTED_ENTRY_COUNT = #EXPECTED_KEYS return M