From 3b1447358ce9ddddd454e080003c94c494320e6d Mon Sep 17 00:00:00 2001 From: Jackson Moore Date: Thu, 3 Sep 2026 23:07:34 +0200 Subject: Implement Migemo target matching --- lua/clever_f/domain.lua | 8 + lua/clever_f/migemo_catalog.lua | 527 ++++++++++++++++++++++++++++++++++++++++ lua/clever_f/target_plan.lua | 241 +++++++++++++++++- lua/clever_f/text_topology.lua | 22 ++ 4 files changed, 795 insertions(+), 3 deletions(-) create mode 100644 lua/clever_f/migemo_catalog.lua (limited to 'lua') diff --git a/lua/clever_f/domain.lua b/lua/clever_f/domain.lua index a5b8d39..6a40092 100644 --- a/lua/clever_f/domain.lua +++ b/lua/clever_f/domain.lua @@ -987,6 +987,14 @@ function TargetPlan:matches(...) return self.matcher(...) end +function TargetPlan:matches_at(text_view, position) + if type(text_view) ~= "table" or type(text_view.character_at) ~= "function" then + fail("target plan match requires a text view", 2) + end + position = Position.coerce(position) + return self.matcher(text_view:character_at(position), position, text_view) +end + function TargetPlan:to_table() return { target = self.target:to_table(), diff --git a/lua/clever_f/migemo_catalog.lua b/lua/clever_f/migemo_catalog.lua new file mode 100644 index 0000000..18d9541 --- /dev/null +++ b/lua/clever_f/migemo_catalog.lua @@ -0,0 +1,527 @@ +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 diff --git a/lua/clever_f/target_plan.lua b/lua/clever_f/target_plan.lua index 385b111..78d0b41 100644 --- a/lua/clever_f/target_plan.lua +++ b/lua/clever_f/target_plan.lua @@ -1,5 +1,6 @@ local case_policy = require("clever_f.case_policy") local domain = require("clever_f.domain") +local migemo_catalog = require("clever_f.migemo_catalog") local text_topology = require("clever_f.text_topology") local M = {} @@ -167,6 +168,13 @@ local function require_splitter(splitter) 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() @@ -190,6 +198,9 @@ function TargetPlanFactory.new(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 @@ -212,6 +223,7 @@ local function default_match_policy() return { ignore_case = false, smart_case = false, + use_migemo = false, chars_match_any_signs = "", } end @@ -236,9 +248,15 @@ local function sampled_policy(factory, target, match_policy) 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" @@ -255,7 +273,169 @@ local function new_plan(target, kind, case_mode, matcher) }) end -function TargetPlanFactory:build(target, match_policy) +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) @@ -274,6 +454,31 @@ function TargetPlanFactory:build(target, match_policy) ) 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( @@ -295,8 +500,38 @@ function TargetPlanFactory:build(target, match_policy) ) end -function M.build(target, match_policy, options) - return TargetPlanFactory.new(options):build(target, match_policy) +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 diff --git a/lua/clever_f/text_topology.lua b/lua/clever_f/text_topology.lua index 2941cf2..56f3b9c 100644 --- a/lua/clever_f/text_topology.lua +++ b/lua/clever_f/text_topology.lua @@ -418,6 +418,26 @@ function TextView:line_encoded_text(line_number) return line_record(self, line_number).encoded end +function TextView:text_suffix(position) + position = domain.Position.coerce(position) + local record = view_record(self) + local line = line_record(self, position.line) + local character_index = self:character_index_for_byte_column( + position.line, + position.byte_column + ) + local parts = {} + + for index = character_index, line.character_count do + parts[#parts + 1] = line.entries[index].character + end + for line_number = position.line + 1, record.snapshot.line_count do + parts[#parts + 1] = "\n" + parts[#parts + 1] = record.lines[line_number].text + end + return table.concat(parts) +end + function TextView:line_byte_length(line_number) return line_record(self, line_number).byte_length end @@ -970,6 +990,8 @@ TextView.character_index_to_byte_column = TextView.byte_column_for_character_ind TextView.byte_column_to_character_index = TextView.character_index_for_byte_column TextView.character_count = TextView.line_character_count TextView.byte_length = TextView.line_byte_length +TextView.text_from = TextView.text_suffix +TextView.suffix_from = TextView.text_suffix TextView.character_span = TextView.byte_span_for_character_index TextView.predecessor_endpoint = TextView.predecessor TextView.successor_endpoint = TextView.successor -- cgit v1.2.3