summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lua/clever_f/text_topology.lua991
-rw-r--r--tests/run.lua288
2 files changed, 1278 insertions, 1 deletions
diff --git a/lua/clever_f/text_topology.lua b/lua/clever_f/text_topology.lua
new file mode 100644
index 0000000..3b9bf0f
--- /dev/null
+++ b/lua/clever_f/text_topology.lua
@@ -0,0 +1,991 @@
+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
+
+ local count = vim.fn.strchars(segment, true)
+ for character_index = 0, count - 1 do
+ local first = vim.fn.byteidx(segment, character_index)
+ local following = vim.fn.byteidx(segment, character_index + 1)
+ if first < 0 or following <= first then
+ fail("Nvim could not index an editor character", 3)
+ end
+ result[#result + 1] = segment:sub(first + 1, 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
+
+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 lines = {}
+ for line_number = 1, snapshot.line_count do
+ lines[line_number] = index_line(
+ snapshot:line(line_number),
+ encoding,
+ splitter,
+ encoder
+ )
+ end
+
+ local view = setmetatable({}, text_view_metatable)
+ view_records[view] = {
+ snapshot = snapshot,
+ requested_encoding = requested_encoding,
+ encoding = encoding,
+ lines = 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
+ return record.lines[line_number]
+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: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 record.lines[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 = record.lines[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 = record.lines[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 = record.lines[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 = record.lines[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 record.lines[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 = record.lines[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.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
diff --git a/tests/run.lua b/tests/run.lua
index 2eb49b3..1cf9053 100644
--- a/tests/run.lua
+++ b/tests/run.lua
@@ -11,6 +11,7 @@ local capabilities = require("clever_f.capabilities")
local policy = require("clever_f.policy")
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 MemoryHost = require("clever_f.testing.memory_host")
local tests = {}
@@ -1197,6 +1198,291 @@ test("ClearAllLandingsAndDirection retains movement initialization", function()
same(domain.Descriptor.TILL_FORWARD, state:get_previous_descriptor("v"))
end)
+local function collect_iteration(iterator)
+ local positions = {}
+ local characters = {}
+ local spans = {}
+ while true do
+ local position, character, span = iterator()
+ if position == nil then
+ break
+ end
+ positions[#positions + 1] = position
+ characters[#characters + 1] = character
+ spans[#spans + 1] = span
+ end
+ return positions, characters, spans
+end
+
+local function position_strings(positions)
+ local result = {}
+ for index, position in ipairs(positions) do
+ result[index] = tostring(position)
+ end
+ return result
+end
+
+local MIXED_JAPANESE = "A\227\129\130\239\189\178\230\188\162B"
+local MIXED_CHARACTERS = {
+ "A",
+ "\227\129\130",
+ "\239\189\178",
+ "\230\188\162",
+ "B",
+}
+
+test("TextView indexes editor characters in each required encoding", function()
+ local cases = {
+ {
+ encoding = "utf-8",
+ starts = { 1, 2, 5, 8, 11 },
+ lengths = { 1, 3, 3, 3, 1 },
+ line_length = 11,
+ },
+ {
+ encoding = "cp932",
+ starts = { 1, 2, 4, 5, 7 },
+ lengths = { 1, 2, 1, 2, 1 },
+ line_length = 7,
+ },
+ {
+ encoding = "euc-jp",
+ starts = { 1, 2, 4, 6, 8 },
+ lengths = { 1, 2, 2, 2, 1 },
+ line_length = 8,
+ },
+ }
+
+ for _, case in ipairs(cases) do
+ local view = text_topology.new({ MIXED_JAPANESE }, case.encoding)
+ same(case.encoding, view.encoding)
+ same(1, view.line_count)
+ same(5, view:line_character_count(1))
+ same(case.line_length, view:line_byte_length(1))
+ same(case.line_length, #view:line_encoded_text(1))
+
+ for character_index = 1, #MIXED_CHARACTERS do
+ local column = view:byte_column_for_character_index(1, character_index)
+ same(case.starts[character_index], column)
+ same(
+ character_index,
+ view:character_index_for_byte_column(1, column)
+ )
+ same(MIXED_CHARACTERS[character_index], view:character_at(1, column))
+ local span = view:byte_span_for_character_index(1, character_index)
+ same(case.lengths[character_index], span.byte_length)
+ same(column, span.byte_start)
+ same(column + span.byte_length - 1, span.byte_end)
+ truthy(view:is_character_start(span.position))
+ end
+ end
+
+ local ascii = text_topology.new({ "plain" }, "utf8")
+ same("utf-8", ascii.encoding)
+ same(5, ascii:line_byte_length(1))
+ same(5, ascii:line_character_count(1))
+ for index = 1, 5 do
+ same(index, ascii:character_index_to_byte_column(1, index))
+ same(index, ascii:byte_column_to_character_index(1, index))
+ end
+end)
+
+test("TextView rejects interior and boundary bytes as character starts", function()
+ local view = text_topology.new({ MIXED_JAPANESE }, "utf-8")
+ local valid = {
+ [1] = true,
+ [2] = true,
+ [5] = true,
+ [8] = true,
+ [11] = true,
+ }
+
+ for column = 1, view:line_byte_length(1) do
+ same(valid[column] == true, view:is_character_start(1, column))
+ if not valid[column] then
+ same(nil, view:try_character_index_for_byte_column(1, column))
+ fails(function()
+ view:character_index_for_byte_column(1, column)
+ end, "inside an editor character")
+ end
+ end
+
+ falsy(view:is_character_start(1, 12))
+ fails(function()
+ view:character_index_for_byte_column(1, 12)
+ end, "does not identify")
+ fails(function()
+ view:byte_column_for_character_index(1, 0)
+ end, "character_index")
+end)
+
+test("TextView treats Nvim grapheme clusters as editor characters", function()
+ local combining = "e\204\129x"
+ local view = text_topology.new({ combining }, "utf-8")
+
+ same(2, view:line_character_count(1))
+ same(4, view:line_byte_length(1))
+ same(1, view:byte_column_for_character_index(1, 1))
+ same(4, view:byte_column_for_character_index(1, 2))
+ same("e\204\129", view:character_at_index(1, 1))
+ falsy(view:is_character_start(1, 2))
+ falsy(view:is_character_start(1, 3))
+end)
+
+test("Forward and backward iteration enumerate reverse character starts", function()
+ local view = text_topology.new({
+ "a\227\129\130",
+ "",
+ "\239\189\178z",
+ }, "utf-8")
+
+ local forward, characters, spans = collect_iteration(view:iter_buffer_forward())
+ list_same({ "(1,1)", "(1,2)", "(3,1)", "(3,4)" }, position_strings(forward))
+ list_same({ "a", "\227\129\130", "\239\189\178", "z" }, characters)
+ for index, position in ipairs(forward) do
+ truthy(view:is_character_start(position))
+ same(position, spans[index].position)
+ end
+
+ local backward = collect_iteration(view:iter_buffer_backward())
+ list_same({ "(3,4)", "(3,1)", "(1,2)", "(1,1)" }, position_strings(backward))
+
+ local line_forward = collect_iteration(view:iter_line_forward(1))
+ local line_backward = collect_iteration(view:iter_line_backward(1))
+ list_same({ "(1,1)", "(1,2)" }, position_strings(line_forward))
+ list_same({ "(1,2)", "(1,1)" }, position_strings(line_backward))
+end)
+
+test("Bounded iterators include and stop at their selected boundaries", function()
+ local view = text_topology.new({ "ab", "cd", "ef" }, "utf-8")
+ local first = domain.Position.new(1, 2)
+ local last = domain.Position.new(3, 1)
+
+ local forward = collect_iteration(view:iter_forward(first, last))
+ list_same({ "(1,2)", "(2,1)", "(2,2)", "(3,1)" }, position_strings(forward))
+ local backward = collect_iteration(view:iter_backward(last, first))
+ list_same({ "(3,1)", "(2,2)", "(2,1)", "(1,2)" }, position_strings(backward))
+
+ fails(function()
+ view:iter_forward(last, first)
+ end, "must not follow")
+ fails(function()
+ view:iter_backward(first, last)
+ end, "must not precede")
+ fails(function()
+ view:iter_forward(domain.Position.new(1, 3), last)
+ end, "must start an editor character")
+end)
+
+test("Strict-side iteration obeys line and buffer match-start bounds", function()
+ local view = text_topology.new({ "abc", "", "def" }, "utf-8")
+ local origin = domain.Position.new(1, 2)
+
+ local line_forward = collect_iteration(view:iter_strict_forward(
+ origin,
+ domain.SearchScope.CURRENT_LINE
+ ))
+ list_same({ "(1,3)" }, position_strings(line_forward))
+
+ local buffer_forward = collect_iteration(view:iter_strict_forward(
+ origin,
+ domain.SearchScope.BUFFER
+ ))
+ list_same({ "(1,3)", "(3,1)", "(3,2)", "(3,3)" }, position_strings(buffer_forward))
+
+ local line_backward = collect_iteration(view:iter_strict_backward(
+ origin,
+ "current_line"
+ ))
+ list_same({ "(1,1)" }, position_strings(line_backward))
+
+ local buffer_backward = collect_iteration(view:iter_strict_backward(
+ domain.Position.new(3, 2),
+ "buffer"
+ ))
+ list_same({ "(3,1)", "(1,3)", "(1,2)", "(1,1)" }, position_strings(buffer_backward))
+
+ local empty_line_bounds = view:line_match_start_bounds(2)
+ truthy(empty_line_bounds.empty)
+ same(nil, empty_line_bounds.first)
+ same(nil, empty_line_bounds.last)
+ same(0, #position_strings(collect_iteration(view:iter_line_forward(2))))
+ same(0, #position_strings(collect_iteration(view:iter_strict_forward(
+ domain.Position.new(2, 1),
+ "current_line"
+ ))))
+
+ local buffer_bounds = view:match_start_bounds("buffer")
+ falsy(buffer_bounds.empty)
+ same(domain.Position.new(1, 1), buffer_bounds.first)
+ same(domain.Position.new(3, 3), buffer_bounds.last)
+ truthy(buffer_bounds:contains(domain.Position.new(2, 1)))
+end)
+
+test("Endpoint adjacency crosses lines and normalizes cursor boundaries", function()
+ local view = text_topology.new({
+ "a\227\129\130",
+ "",
+ "\239\189\178z",
+ }, "utf-8")
+
+ same(nil, view:predecessor(domain.Position.new(1, 1)))
+ same(domain.Position.new(1, 1), view:predecessor(domain.Position.new(1, 2)))
+ same(domain.Position.new(2, 1), view:successor(domain.Position.new(1, 2)))
+ same(domain.Position.new(1, 2), view:predecessor(domain.Position.new(2, 1)))
+ same(domain.Position.new(3, 1), view:successor(domain.Position.new(2, 1)))
+ same(domain.Position.new(2, 1), view:predecessor(domain.Position.new(3, 1)))
+ same(domain.Position.new(3, 4), view:successor(domain.Position.new(3, 1)))
+ same(nil, view:successor(domain.Position.new(3, 4)))
+
+ same(domain.Position.new(1, 2), view:normalize_endpoint(1, 3))
+ same(domain.Position.new(1, 2), view:normalize_endpoint(1, 5))
+ same(domain.Position.new(2, 1), view:normalize_endpoint(2, 7))
+ truthy(view:is_valid_cursor_position(view:normalize_endpoint(1, 3)))
+ truthy(view:is_valid_cursor_position(view:normalize_endpoint(2, 7)))
+
+ fails(function()
+ view:successor(domain.Position.new(1, 3))
+ end, "inside an editor character")
+ fails(function()
+ view:predecessor(domain.Position.new(2, 2))
+ end, "empty line")
+end)
+
+test("TextView creation reads one operation-local snapshot", function()
+ local host = MemoryHost.new({
+ buffer_lines = { "ab" },
+ effective_encoding = "utf-8",
+ })
+ host:clear_operations()
+ local first = text_topology.from_host(host)
+ local operations = host:operations()
+ same(2, #operations)
+ same("read_text", operations[1].operation)
+ same("read_encoding", operations[2].operation)
+
+ host:set_text({ "xyz" })
+ local second = text_topology.from_host(host)
+ same("ab", first:line_text(1))
+ same(2, first:line_character_count(1))
+ same("xyz", second:line_text(1))
+ same(3, second:line_character_count(1))
+ falsy(first == second)
+ fails(function()
+ first.encoding = "cp932"
+ end, "immutable")
+end)
+
+test("All-empty buffers expose empty full-buffer iteration", function()
+ local view = text_topology.new({ "", "", "" }, "utf-8")
+ local bounds = view:buffer_match_start_bounds()
+ truthy(bounds:is_empty())
+ same(nil, bounds.first)
+ same(nil, bounds.last)
+ same(0, #position_strings(collect_iteration(view:iter_buffer_forward())))
+ same(0, #position_strings(collect_iteration(view:iter_buffer_backward())))
+end)
+
for _, item in ipairs(tests) do
local ok, failure = xpcall(item.body, debug.traceback)
if not ok then
@@ -1206,4 +1492,4 @@ for _, item in ipairs(tests) do
passed = passed + 1
end
-io.stdout:write(string.format("Phase 4: %d tests passed\n", passed))
+io.stdout:write(string.format("Phase 5: %d tests passed\n", passed))