local domain = require("clever_f.domain") local M = {} local TextView = {} local MatchStartBounds = {} M.TextView = TextView M.MatchStartBounds = MatchStartBounds local view_records = setmetatable({}, { __mode = "k" }) local bounds_records = setmetatable({}, { __mode = "k" }) local function fail(message, level) error(message, (level or 1) + 1) end local function is_integer(value) return type(value) == "number" and value > -math.huge and value < math.huge and value == math.floor(value) end 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 canonical_encoding(encoding) encoding = require_nonempty_string(encoding, "effective encoding"):lower() encoding = encoding:gsub("_", "-") local aliases = { ["utf8"] = "utf-8", ["cp-932"] = "cp932", ["932"] = "cp932", ["windows-31j"] = "cp932", ["eucjp"] = "euc-jp", ["ujis"] = "euc-jp", ["unix-jis"] = "euc-jp", } return aliases[encoding] or encoding end M.normalize_encoding = canonical_encoding local function utf8_character_length(text, offset) local first = text:byte(offset) if first == nil then return nil end if first < 0x80 then return 1 end local length local minimum if first >= 0xc2 and first <= 0xdf then length = 2 minimum = 0x80 elseif first >= 0xe0 and first <= 0xef then length = 3 minimum = 0x800 elseif first >= 0xf0 and first <= 0xf4 then length = 4 minimum = 0x10000 else fail("text contains an invalid UTF-8 character", 3) end if offset + length - 1 > #text then fail("text contains an incomplete UTF-8 character", 3) end local codepoint = first % (2 ^ (8 - length - 1)) for index = offset + 1, offset + length - 1 do local byte = text:byte(index) if byte < 0x80 or byte > 0xbf then fail("text contains an invalid UTF-8 character", 3) end codepoint = codepoint * 0x40 + (byte - 0x80) end if codepoint < minimum or codepoint > 0x10ffff or (codepoint >= 0xd800 and codepoint <= 0xdfff) then fail("text contains an invalid UTF-8 character", 3) end return length end local function split_utf8_codepoints(text) local characters = {} local offset = 1 while offset <= #text do local length = utf8_character_length(text, offset) characters[#characters + 1] = text:sub(offset, offset + length - 1) offset = offset + length end return characters end local function nvim_split_segment(segment, result) if segment == "" then return end if segment:find("[\128-\255]") == nil then for index = 1, #segment do result[#result + 1] = segment:sub(index, index) end return end local offset = 0 while offset < #segment do local match = vim.fn.matchstrpos(segment, "\\m.", offset) local character = match[1] local first = match[2] local following = match[3] if first ~= offset or following <= first or character == "" then fail("Nvim could not index an editor character", 3) end result[#result + 1] = character offset = following end end local function default_split_editor_characters(text) local runtime = rawget(_G, "vim") if type(runtime) ~= "table" or type(runtime.fn) ~= "table" or type(runtime.fn.strchars) ~= "function" or type(runtime.fn.byteidx) ~= "function" then return split_utf8_codepoints(text) end local result = {} local offset = 1 while offset <= #text do local nul = text:find("\0", offset, true) local last = nul and (nul - 1) or #text nvim_split_segment(text:sub(offset, last), result) if nul == nil then break end result[#result + 1] = "\0" offset = nul + 1 end return result end local function default_encode(text, encoding) if encoding == "utf-8" then return text end local runtime = rawget(_G, "vim") if type(runtime) ~= "table" or type(runtime.iconv) ~= "function" then fail("text encoding conversion requires Nvim or an encoder", 3) end local ok, encoded = pcall(runtime.iconv, text, "utf-8", encoding) if not ok or encoded == nil then fail("text could not be converted to " .. encoding, 3) end return encoded end local function require_character_list(characters) 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 not is_integer(key) or key < 1 or key > #characters then fail("editor character splitter must return a list", 3) end if type(character) ~= "string" or character == "" then fail("editor character splitter must return nonempty strings", 3) end result[key] = character item_count = item_count + 1 end if item_count ~= #characters then fail("editor character splitter must return a list", 3) end 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 end if type(text) == "table" and text.lines ~= nil then text = text.lines end return domain.TextSnapshot.new(text) end local function require_options(options) if options == nil then return {} end if type(options) == "function" then return { encoder = options } end if type(options) ~= "table" then fail("TextView options must be a table", 2) end return options end local function selected_function(options, primary, alternate, fallback) local value = options[primary] if value == nil and alternate ~= nil then value = options[alternate] end if value == nil then return fallback end if type(value) ~= "function" then fail("TextView " .. primary .. " must be a function", 3) end return value end local function index_line(text, encoding, splitter, encoder) if text:find("\n", 1, true) ~= nil then fail("a text snapshot line must not contain a newline", 3) end local characters = require_character_list(splitter(text)) if table.concat(characters) ~= text then fail("editor character splitter must preserve the complete line", 3) end local entries = {} local starts = {} local by_start = {} local encoded_parts = {} local next_column = 1 for index, character in ipairs(characters) do local encoded = encoder(character, encoding) if type(encoded) ~= "string" or encoded == "" then fail("TextView encoder must return a nonempty byte string", 3) end local byte_length = #encoded local entry = { character = character, encoded = encoded, byte_start = next_column, byte_end = next_column + byte_length - 1, byte_length = byte_length, } entries[index] = entry starts[index] = next_column by_start[next_column] = index encoded_parts[index] = encoded next_column = next_column + byte_length end return { text = text, encoded = table.concat(encoded_parts), entries = entries, starts = starts, by_start = by_start, byte_length = next_column - 1, character_count = #entries, } end local text_view_metatable = { __index = function(view, key) local method = TextView[key] if method ~= nil then return method end local record = view_records[view] if key == "encoding" or key == "effective_encoding" then return record.encoding end if key == "requested_encoding" then return record.requested_encoding end if key == "line_count" then return record.snapshot.line_count end return nil end, __newindex = function() fail("TextView values are immutable", 2) end, __tostring = function(view) local record = view_records[view] return "text-view:" .. record.encoding .. ":" .. tostring(record.snapshot.line_count) end, __metatable = "clever_f.text_topology.TextView", } function TextView.new(text, effective_encoding, options) if TextView.is(text) and effective_encoding == nil and options == nil then return text end local snapshot = snapshot_value(text) local requested_encoding = require_nonempty_string( effective_encoding, "effective encoding" ) local encoding = canonical_encoding(requested_encoding) options = require_options(options) local splitter = selected_function( options, "splitter", "split_editor_characters", default_split_editor_characters ) local encoder = selected_function(options, "encoder", "encode", default_encode) local view = setmetatable({}, text_view_metatable) view_records[view] = { snapshot = snapshot, requested_encoding = requested_encoding, encoding = encoding, splitter = splitter, encoder = encoder, lines = {}, } return view end function TextView.from_host(host, options) if type(host) ~= "table" or type(host.read_text) ~= "function" or type(host.read_encoding) ~= "function" then fail("TextView host must provide read_text and read_encoding", 2) end local text = host:read_text() local encoding = host:read_encoding() return TextView.new(text, encoding, options) end function TextView.is(value) return type(value) == "table" and view_records[value] ~= nil end local function view_record(view) if not TextView.is(view) then fail("value must be a TextView", 3) end return view_records[view] end local function line_record(view, line_number) local record = view_record(view) if not is_integer(line_number) or line_number < 1 or line_number > record.snapshot.line_count then fail("line_number must identify a line in the TextView", 3) end local line = record.lines[line_number] if line == nil then line = index_line( record.snapshot:line(line_number), record.encoding, record.splitter, record.encoder ) record.lines[line_number] = line end return line end local function require_character_index(line, character_index) if not is_integer(character_index) or character_index < 1 or character_index > line.character_count then fail("character_index must identify an editor character", 3) end return character_index end local function require_byte_column(byte_column) if not is_integer(byte_column) or byte_column < 1 then fail("byte_column must be a positive one-based integer", 3) end return byte_column end local function position_arguments(position_or_line, byte_column, name) if byte_column == nil then local position = domain.Position.coerce(position_or_line) return position.line, position.byte_column end if not is_integer(position_or_line) or position_or_line < 1 then fail((name or "line_number") .. " must be a positive integer", 3) end return position_or_line, require_byte_column(byte_column) end function TextView:text_snapshot() return view_record(self).snapshot end function TextView:line_text(line_number) return line_record(self, line_number).text end 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.snapshot:line(line_number) end return table.concat(parts) end function TextView:line_byte_length(line_number) return line_record(self, line_number).byte_length end function TextView:line_character_count(line_number) return line_record(self, line_number).character_count end function TextView:line_is_empty(line_number) return self:line_character_count(line_number) == 0 end function TextView:character_at_index(line_number, character_index) local line = line_record(self, line_number) require_character_index(line, character_index) return line.entries[character_index].character end function TextView:encoded_character_at_index(line_number, character_index) local line = line_record(self, line_number) require_character_index(line, character_index) return line.entries[character_index].encoded end function TextView:byte_column_for_character_index(line_number, character_index) local line = line_record(self, line_number) require_character_index(line, character_index) return line.starts[character_index] end function TextView:position_for_character_index(line_number, character_index) return domain.Position.new( line_number, self:byte_column_for_character_index(line_number, character_index) ) end function TextView:try_character_index_for_byte_column(line_number, byte_column) local line = line_record(self, line_number) require_byte_column(byte_column) return line.by_start[byte_column] end function TextView:character_index_for_byte_column(line_number, byte_column) local line = line_record(self, line_number) require_byte_column(byte_column) local character_index = line.by_start[byte_column] if character_index == nil then if byte_column <= line.byte_length then fail("byte_column points inside an editor character", 2) end fail("byte_column does not identify an editor character", 2) end return character_index end function TextView:character_index_for_position(position) position = domain.Position.coerce(position) return self:character_index_for_byte_column(position.line, position.byte_column) end local function copy_span(line_number, character_index, entry) local position = domain.Position.new(line_number, entry.byte_start) return { line = line_number, character_index = character_index, character = entry.character, encoded = entry.encoded, position = position, byte_column = entry.byte_start, byte_start = entry.byte_start, byte_end = entry.byte_end, start_byte_column = entry.byte_start, end_byte_column = entry.byte_end, byte_length = entry.byte_length, } end function TextView:byte_span_for_character_index(line_number, character_index) local line = line_record(self, line_number) require_character_index(line, character_index) return copy_span(line_number, character_index, line.entries[character_index]) end function TextView:byte_span_at(position_or_line, byte_column) local line_number, column = position_arguments(position_or_line, byte_column) local character_index = self:character_index_for_byte_column(line_number, column) return self:byte_span_for_character_index(line_number, character_index) end function TextView:character_at(position_or_line, byte_column) local line_number, column = position_arguments(position_or_line, byte_column) local character_index = self:character_index_for_byte_column(line_number, column) return self:character_at_index(line_number, character_index) end function TextView:is_character_start(position_or_line, byte_column) local line_number, column = position_arguments(position_or_line, byte_column) local record = view_record(self) if line_number > record.snapshot.line_count then return false end return line_record(self, line_number).by_start[column] ~= nil end function TextView:is_valid_cursor_position(position_or_line, byte_column) local line_number, column = position_arguments(position_or_line, byte_column) local record = view_record(self) if line_number > record.snapshot.line_count then return false end local line = line_record(self, line_number) if line.character_count == 0 then return column == 1 end return line.by_start[column] ~= nil end local function containing_character_index(line, byte_column) for index = 1, line.character_count do local entry = line.entries[index] if byte_column >= entry.byte_start and byte_column <= entry.byte_end then return index end end return nil end function TextView:normalize_endpoint(position_or_line, byte_column) local line_number, column = position_arguments(position_or_line, byte_column) local line = line_record(self, line_number) if line.character_count == 0 then return domain.Position.new(line_number, 1) end if column > line.byte_length then return self:position_for_character_index(line_number, line.character_count) end local character_index = line.by_start[column] or containing_character_index(line, column) return self:position_for_character_index(line_number, character_index) end local function first_cursor_position(view, line_number) local line = line_record(view, line_number) if line.character_count == 0 then return domain.Position.new(line_number, 1) end return view:position_for_character_index(line_number, 1) end local function last_cursor_position(view, line_number) local line = line_record(view, line_number) if line.character_count == 0 then return domain.Position.new(line_number, 1) end return view:position_for_character_index(line_number, line.character_count) end function TextView:first_cursor_position(line_number) return first_cursor_position(self, line_number) end function TextView:last_cursor_position(line_number) return last_cursor_position(self, line_number) end function TextView:predecessor(position) position = domain.Position.coerce(position) local line = line_record(self, position.line) if line.character_count > 0 then local character_index = self:character_index_for_byte_column( position.line, position.byte_column ) if character_index > 1 then return self:position_for_character_index(position.line, character_index - 1) end elseif position.byte_column ~= 1 then fail("an empty line cursor position must use byte column one", 2) end if position.line == 1 then return nil end return last_cursor_position(self, position.line - 1) end function TextView:successor(position) position = domain.Position.coerce(position) local record = view_record(self) local line = line_record(self, position.line) if line.character_count > 0 then local character_index = self:character_index_for_byte_column( position.line, position.byte_column ) if character_index < line.character_count then return self:position_for_character_index(position.line, character_index + 1) end elseif position.byte_column ~= 1 then fail("an empty line cursor position must use byte column one", 2) end if position.line == record.snapshot.line_count then return nil end return first_cursor_position(self, position.line + 1) end local bounds_metatable = { __index = function(bounds, key) local method = MatchStartBounds[key] if method ~= nil then return method end return bounds_records[bounds][key] end, __newindex = function() fail("MatchStartBounds values are immutable", 2) end, __tostring = function(bounds) local record = bounds_records[bounds] if record.empty then return "match-start-bounds:empty" end return "match-start-bounds:" .. tostring(record.first) .. ":" .. tostring(record.last) end, __metatable = "clever_f.text_topology.MatchStartBounds", } local function new_bounds(scope, first_line, last_line, first, last) local bounds = setmetatable({}, bounds_metatable) bounds_records[bounds] = { scope = scope, first_line = first_line, last_line = last_line, first = first, last = last, start = first, finish = last, empty = first == nil, } return bounds end function MatchStartBounds.is(value) return type(value) == "table" and bounds_records[value] ~= nil end function MatchStartBounds:is_empty() return bounds_records[self].empty end function MatchStartBounds:contains(position) position = domain.Position.coerce(position) local record = bounds_records[self] if record.empty then return false end return domain.Position.compare(position, record.first) >= 0 and domain.Position.compare(position, record.last) <= 0 end function MatchStartBounds:to_table() local record = bounds_records[self] return { scope = record.scope.value, first_line = record.first_line, last_line = record.last_line, first = record.first and record.first:to_table() or nil, last = record.last and record.last:to_table() or nil, empty = record.empty, } end function TextView:line_match_start_bounds(line_number) local line = line_record(self, line_number) local first local last if line.character_count > 0 then first = self:position_for_character_index(line_number, 1) last = self:position_for_character_index(line_number, line.character_count) end return new_bounds( domain.SearchScope.CURRENT_LINE, line_number, line_number, first, last ) end function TextView:buffer_match_start_bounds() local record = view_record(self) local first local last for line_number = 1, record.snapshot.line_count do local line = line_record(self, line_number) if line.character_count > 0 then first = self:position_for_character_index(line_number, 1) break end end for line_number = record.snapshot.line_count, 1, -1 do local line = line_record(self, line_number) if line.character_count > 0 then last = self:position_for_character_index(line_number, line.character_count) break end end return new_bounds( domain.SearchScope.BUFFER, 1, record.snapshot.line_count, first, last ) end local function scope_value(scope) if scope == nil then return domain.SearchScope.BUFFER end if scope == "line" then return domain.SearchScope.CURRENT_LINE end return domain.SearchScope.from_string(scope) end function TextView:match_start_bounds(scope, origin) if domain.Position.is(scope) or type(scope) == "number" or (type(scope) == "table" and scope.line ~= nil) then scope, origin = origin, scope end scope = scope_value(scope) if scope == domain.SearchScope.BUFFER then return self:buffer_match_start_bounds() end if origin == nil then fail("current-line match bounds require an origin line", 2) end local line_number = type(origin) == "number" and origin or domain.Position.coerce(origin).line return self:line_match_start_bounds(line_number) end local function empty_iterator() return nil end local function iteration_endpoint(view, position, name) position = domain.Position.coerce(position) if not view:is_character_start(position) then fail((name or "iterator endpoint") .. " must start an editor character", 3) end return position end local function step_character(view, position, direction) local record = view_record(view) local line = line_record(view, position.line) local character_index = line.by_start[position.byte_column] if direction == domain.Direction.FORWARD then if character_index < line.character_count then return view:position_for_character_index(position.line, character_index + 1) end for line_number = position.line + 1, record.snapshot.line_count do if line_record(view, line_number).character_count > 0 then return view:position_for_character_index(line_number, 1) end end return nil end if character_index > 1 then return view:position_for_character_index(position.line, character_index - 1) end for line_number = position.line - 1, 1, -1 do local previous_line = line_record(view, line_number) if previous_line.character_count > 0 then return view:position_for_character_index( line_number, previous_line.character_count ) end end return nil end local function position_iterator(view, direction, start_position, boundary) if start_position == nil then return empty_iterator end start_position = iteration_endpoint(view, start_position, "iterator start") boundary = iteration_endpoint(view, boundary, "iterator boundary") local comparison = domain.Position.compare(start_position, boundary) if direction == domain.Direction.FORWARD and comparison > 0 then fail("a forward iterator start must not follow its boundary", 3) end if direction == domain.Direction.BACKWARD and comparison < 0 then fail("a backward iterator start must not precede its boundary", 3) end local current = start_position local finished = false return function() if finished then return nil end local position = current local character_index = view:character_index_for_position(position) local character = view:character_at_index(position.line, character_index) local span = view:byte_span_for_character_index(position.line, character_index) if position == boundary then finished = true else current = step_character(view, position, direction) if current == nil then fail("iterator reached the text boundary before its selected boundary", 2) end end return position, character, span end end local function iteration_arguments(view, direction, first, second) if MatchStartBounds.is(first) then local record = bounds_records[first] if record.empty then return nil, nil end if direction == domain.Direction.FORWARD then return record.first, record.last end return record.last, record.first end if type(first) == "number" and second == nil then local bounds = view:line_match_start_bounds(first) return iteration_arguments(view, direction, bounds) end if first == nil then local bounds = view:buffer_match_start_bounds() return iteration_arguments(view, direction, bounds) end first = domain.Position.coerce(first) if second ~= nil then return first, domain.Position.coerce(second) end local bounds = view:buffer_match_start_bounds() if bounds.empty then return nil, nil end return first, direction == domain.Direction.FORWARD and bounds.last or bounds.first end function TextView:iterate(direction, first, second) direction = domain.Direction.from_string(direction) local start_position, boundary = iteration_arguments(self, direction, first, second) return position_iterator(self, direction, start_position, boundary) end function TextView:iter_forward(first, boundary) return self:iterate(domain.Direction.FORWARD, first, boundary) end function TextView:iter_backward(first, boundary) return self:iterate(domain.Direction.BACKWARD, first, boundary) end function TextView:iter_line_forward(line_number) return self:iter_forward(self:line_match_start_bounds(line_number)) end function TextView:iter_line_backward(line_number) return self:iter_backward(self:line_match_start_bounds(line_number)) end function TextView:iter_buffer_forward() return self:iter_forward(self:buffer_match_start_bounds()) end function TextView:iter_buffer_backward() return self:iter_backward(self:buffer_match_start_bounds()) end local function strict_scope_bounds(view, origin, scope_or_bounds) if MatchStartBounds.is(scope_or_bounds) then return scope_or_bounds end local scope = scope_value(scope_or_bounds) return view:match_start_bounds(scope, origin) end function TextView:iter_strict(origin, direction, scope_or_bounds) origin = domain.Position.coerce(origin) line_record(self, origin.line) if not self:is_valid_cursor_position(origin) then fail("strict iterator origin must be a valid editor cursor position", 2) end direction = domain.Direction.from_string(direction) local bounds = strict_scope_bounds(self, origin, scope_or_bounds) local candidates = self:iterate(direction, bounds) return function() while true do local position, character, span = candidates() if position == nil then return nil end local comparison = domain.Position.compare(position, origin) if (direction == domain.Direction.FORWARD and comparison > 0) or (direction == domain.Direction.BACKWARD and comparison < 0) then return position, character, span end end end end function TextView:iter_strict_forward(origin, scope_or_bounds) return self:iter_strict(origin, domain.Direction.FORWARD, scope_or_bounds) end function TextView:iter_strict_backward(origin, scope_or_bounds) return self:iter_strict(origin, domain.Direction.BACKWARD, scope_or_bounds) end TextView.character_index_to_byte_column = TextView.byte_column_for_character_index 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 TextView.normalize_boundary_endpoint = TextView.normalize_endpoint TextView.bounds_for_line = TextView.line_match_start_bounds TextView.bounds_for_buffer = TextView.buffer_match_start_bounds TextView.iterate_forward = TextView.iter_forward TextView.iterate_backward = TextView.iter_backward TextView.forward = TextView.iter_forward TextView.backward = TextView.iter_backward TextView.strict_forward = TextView.iter_strict_forward TextView.strict_backward = TextView.iter_strict_backward function M.new(text, effective_encoding, options) return TextView.new(text, effective_encoding, options) end function M.from_host(host, options) return TextView.from_host(host, options) end M.build = M.new M.build_from_host = M.from_host M.is = TextView.is return M