From ee3f8a66d6f38b4832804c03b44836e04b4e6761 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sun, 5 May 2024 07:47:12 +0100 Subject: [PATCH] fix: a lot of edge cases (#4) * refactor: next_word_boundry * refactorL prev_word_boundary * refactor: end_of_word test: edge case tests * fix: gutter placement messages and prios * chore: fmt * fix: edge cases around special chars * refactor: refactor into modules refactor(tests): refactor into seperate files * refactor(vertical_motions): refactor and test vertical motions * fix: dont pass content around, just bufnr * fix: luacheck * chore: fmt * refactor(vertical_motions): unified interface * refactor(horizontal_motions): unified interface * chore: wider formatting * refactor(gutter_hints): unified interface * fix(line_end): fix unified interface * refactor(vertical_motions): lua implementation of { and } --- .stylua.toml | 2 +- lua/precognition/horizontal_motions.lua | 129 +++++++ lua/precognition/init.lua | 213 ++--------- lua/precognition/utils.lua | 21 ++ lua/precognition/vertical_motions.lua | 77 ++++ tests/precognition/char_spec.lua | 338 +----------------- tests/precognition/gutter_hints_spec.lua | 128 ++++++- .../precognition/horizontal_motions_spec.lua | 173 +++++++++ tests/precognition/vertical_motions_spec.lua | 145 ++++++++ tests/precognition/virtline_spec.lua | 48 ++- 10 files changed, 735 insertions(+), 539 deletions(-) create mode 100644 lua/precognition/horizontal_motions.lua create mode 100644 lua/precognition/utils.lua create mode 100644 lua/precognition/vertical_motions.lua create mode 100644 tests/precognition/horizontal_motions_spec.lua create mode 100644 tests/precognition/vertical_motions_spec.lua diff --git a/.stylua.toml b/.stylua.toml index 0b2e146..c537618 100644 --- a/.stylua.toml +++ b/.stylua.toml @@ -1,4 +1,4 @@ -column_width = 80 +column_width = 120 line_endings = "Unix" indent_type = "Spaces" indent_width = 4 diff --git a/lua/precognition/horizontal_motions.lua b/lua/precognition/horizontal_motions.lua new file mode 100644 index 0000000..e30c117 --- /dev/null +++ b/lua/precognition/horizontal_motions.lua @@ -0,0 +1,129 @@ +local utils = require("precognition.utils") + +local M = {} + +---@param str string +---@param _cursorcol integer +---@param _linelen integer +---@return integer | nil +function M.line_start_non_whitespace(str, _cursorcol, _linelen) + return str:find("%S") or 0 +end + +---@param _str string +---@param _cursorcol integer +---@param linelen integer +---@return integer | nil +function M.line_end(_str, _cursorcol, linelen) + return linelen or nil +end + +---@param str string +---@param cursorcol integer +---@param _linelen integer +---@return integer | nil +function M.next_word_boundary(str, cursorcol, _linelen) + local offset = cursorcol + local len = vim.fn.strcharlen(str) + local char = vim.fn.strcharpart(str, offset - 1, 1) + local c_class = utils.char_class(char) + + if c_class ~= 0 then + while utils.char_class(char) == c_class and offset <= len do + offset = offset + 1 + char = vim.fn.strcharpart(str, offset - 1, 1) + end + end + + while utils.char_class(char) == 0 and offset <= len do + offset = offset + 1 + char = vim.fn.strcharpart(str, offset - 1, 1) + end + if offset > len then + return nil + end + + return offset +end + +---@param str string +---@param cursorcol integer +---@param _linelen integer +---@return integer | nil +function M.end_of_word(str, cursorcol, _linelen) + local len = vim.fn.strcharlen(str) + if cursorcol >= len then + return nil + end + local offset = cursorcol + local char = vim.fn.strcharpart(str, offset - 1, 1) + local c_class = utils.char_class(char) + local next_char_class = utils.char_class(vim.fn.strcharpart(str, (offset - 1) + 1, 1)) + local rev_offset + + if (c_class == 1 and next_char_class ~= 1) or (next_char_class == 1 and c_class ~= 1) then + offset = offset + 1 + char = vim.fn.strcharpart(str, offset - 1, 1) + c_class = utils.char_class(char) + next_char_class = utils.char_class(vim.fn.strcharpart(str, (offset - 1) + 1, 1)) + end + + if c_class ~= 0 and next_char_class ~= 0 then + while utils.char_class(char) == c_class and offset <= len do + offset = offset + 1 + char = vim.fn.strcharpart(str, offset - 1, 1) + end + end + + if c_class == 0 or next_char_class == 0 then + local next_word_start = M.next_word_boundary(str, offset, 0) + if next_word_start then + rev_offset = M.end_of_word(str, next_word_start + 1, 0) + end + end + + if rev_offset ~= nil and rev_offset <= 0 then + return nil + end + + if rev_offset ~= nil then + return rev_offset + end + return offset - 1 +end + +---@param str string +---@param cursorcol integer +---@param _linelen integer +---@return integer | nil +function M.prev_word_boundary(str, cursorcol, _linelen) + local len = vim.fn.strcharlen(str) + local offset = cursorcol - 1 + local char = vim.fn.strcharpart(str, offset - 1, 1) + local c_class = utils.char_class(char) + + if c_class == 0 then + while utils.char_class(char) == 0 and offset >= 0 do + offset = offset - 1 + char = vim.fn.strcharpart(str, offset - 1, 1) + end + c_class = utils.char_class(char) + end + + while utils.char_class(char) == c_class and offset >= 0 do + offset = offset - 1 + char = vim.fn.strcharpart(str, offset - 1, 1) + --if remaining string is whitespace, return nil_wrap + local remaining = string.sub(str, offset) + if remaining:match("^%s*$") and #remaining > 0 then + return nil + end + end + + if offset == nil or offset > len or offset < 0 then + return nil + end + return offset + 1 +end + +return M diff --git a/lua/precognition/init.lua b/lua/precognition/init.lua index e01b0e4..187960c 100644 --- a/lua/precognition/init.lua +++ b/lua/precognition/init.lua @@ -1,3 +1,6 @@ +local hm = require("precognition.horizontal_motions") +local vm = require("precognition.vertical_motions") + local M = {} ---@alias SupportedHints "'^'" | "'b'" | "'w'" | "'$'" @@ -25,8 +28,8 @@ local default = { ["$"] = { text = "$", prio = 1 }, ["w"] = { text = "w", prio = 10 }, -- ["W"] = "W", - ["b"] = { text = "b", prio = 10 }, - ["e"] = { text = "e", prio = 10 }, + ["b"] = { text = "b", prio = 9 }, + ["e"] = { text = "e", prio = 8 }, -- ["ge"] = "ge", -- should we support multi-char / multi-byte hints? }, gutterHints = { @@ -59,123 +62,6 @@ local ns = vim.api.nvim_create_namespace("precognition") ---@type string local gutter_group = "precognition_gutter" ----@param char string ----@return integer -local function char_class(char) - local byte = string.byte(char) - - if byte and byte < 0x100 then - if char == " " or char == "\t" or char == "\0" then - return 0 -- whitespace - end - if char == "_" or char:match("%w") then - return 2 -- word character - end - return 1 -- other - end - - return 1 -- scary unicode edge cases go here -end - ----@param str string ----@param start integer ----@return integer | nil -local function next_word_boundary(str, start) - local offset = start - 1 - local len = vim.fn.strcharlen(str) - local char = vim.fn.strcharpart(str, offset, 1) - local c_class = char_class(char) - - if c_class ~= 0 then - while char_class(char) == c_class and offset <= len do - offset = offset + 1 - char = vim.fn.strcharpart(str, offset, 1) - end - end - - while char_class(char) == 0 and offset <= len do - offset = offset + 1 - char = vim.fn.strcharpart(str, offset, 1) - end - if (offset + 1) > len then - return nil - end - - return offset + 1 -end - ----@param str string ----@param start integer ----@return integer | nil -local function end_of_word(str, start) - local len = vim.fn.strcharlen(str) - if start >= len then - return nil - end - local offset = start - 1 - local char = vim.fn.strcharpart(str, offset, 1) - local c_class = char_class(char) - local next_char_class = char_class(vim.fn.strcharpart(str, offset + 1, 1)) - local rev_offset - - if c_class ~= 0 and next_char_class ~= 0 then - while char_class(char) == c_class and offset <= len do - offset = offset + 1 - char = vim.fn.strcharpart(str, offset, 1) - end - end - - if c_class == 0 or next_char_class == 0 then - local next_word_start = next_word_boundary(str, offset) - if next_word_start then - rev_offset = end_of_word(str, next_word_start + 1) - end - end - - if rev_offset ~= nil and rev_offset <= 0 then - return nil - end - - if rev_offset ~= nil then - return rev_offset - end - return offset -end - ----@param str string ----@param start integer ----@return integer | nil -local function prev_word_boundary(str, start) - local len = vim.fn.strcharlen(str) - local offset = len - start + 1 - str = string.reverse(str) - local char = vim.fn.strcharpart(str, offset - 1, 1) - local c_class = char_class(char) - - if c_class == 0 then - while char_class(char) == 0 and offset <= len do - offset = offset + 1 - char = vim.fn.strcharpart(str, offset, 1) - end - end - - c_class = char_class(char) - while char_class(char) == c_class and offset <= len do - offset = offset + 1 - char = vim.fn.strcharpart(str, offset, 1) - --if remaining string is whitespace, return nil_wrap - local remaining = string.sub(str, offset) - if remaining:match("^%s*$") and #remaining > 0 then - return nil - end - end - - if offset == nil or (len - offset + 1) > len or (len - offset + 1) <= 0 then - return nil - end - return len - offset + 1 -end - ---@param marks Precognition.VirtLine ---@param line_len integer ---@return table @@ -192,11 +78,7 @@ local function build_virt_line(marks, line_len) if existing == " " and existing ~= hint then line = line:sub(1, col - 1) .. hint .. line:sub(col + 1) else -- if the character is not a space, then we need to check the prio - if - existing ~= "" - and config.hints[mark].prio - > config.hints[existing].prio - then + if existing ~= "" and config.hints[mark].prio > config.hints[existing].prio then line = line:sub(1, col - 1) .. hint .. line:sub(col + 1) end end @@ -207,56 +89,47 @@ local function build_virt_line(marks, line_len) return virt_line end ----@param buf integer == bufnr ---@return Precognition.GutterHints -local function build_gutter_hints(buf) +local function build_gutter_hints() local gutter_hints = { - ["G"] = vim.api.nvim_buf_line_count(buf), - ["gg"] = 1, - ["{"] = vim.fn.search("^\\s*$", "bn"), - ["}"] = vim.fn.search("^\\s*$", "n"), + ["G"] = vm.file_end(), + ["gg"] = vm.file_start(), + ["{"] = vm.prev_paragraph_line(), + ["}"] = vm.next_paragraph_line(), } return gutter_hints end ---@param gutter_hints Precognition.GutterHints ----@param buf integer == bufnr +---@param bufnr? integer -- buffer number ---@return nil -local function apply_gutter_hints(gutter_hints, buf) - if vim.api.nvim_get_option_value("buftype", { buf = buf }) ~= "" then +local function apply_gutter_hints(gutter_hints, bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + if vim.api.nvim_get_option_value("buftype", { buf = bufnr }) ~= "" then return end for hint, loc in pairs(gutter_hints) do - if config.gutterHints[hint] then + if config.gutterHints[hint] and loc ~= 0 and loc ~= nil then if gutter_signs_cache[hint] then - vim.fn.sign_unplace( - gutter_group, - { id = gutter_signs_cache[hint].id } - ) + vim.fn.sign_unplace(gutter_group, { id = gutter_signs_cache[hint].id }) gutter_signs_cache[hint] = nil end vim.fn.sign_define(gutter_name_prefix .. hint, { text = config.gutterHints[hint].text, texthl = "Comment", }) - local ok, res = pcall( - vim.fn.sign_place, - 0, - gutter_group, - gutter_name_prefix .. config.gutterHints[hint].text, - buf, - { + local ok, res = + pcall(vim.fn.sign_place, 0, gutter_group, gutter_name_prefix .. config.gutterHints[hint].text, bufnr, { lnum = loc, priority = 100, - } - ) + }) if ok then gutter_signs_cache[hint] = { line = loc, id = res } end - if not ok then + if not ok and loc ~= 0 then vim.notify_once( - "Failed to place sign: " .. config.gutterHints[hint].text, + "Failed to place sign: " .. config.gutterHints[hint].text .. " at line " .. loc, vim.log.levels.WARN ) end @@ -272,8 +145,7 @@ local function on_cursor_hold() end local tab_width = vim.bo.expandtab and vim.bo.shiftwidth or vim.bo.tabstop - local cur_line = - vim.api.nvim_get_current_line():gsub("\t", string.rep(" ", tab_width)) + local cur_line = vim.api.nvim_get_current_line():gsub("\t", string.rep(" ", tab_width)) local line_len = vim.fn.strcharlen(cur_line) -- local after_cursor = vim.fn.strcharpart(cur_line, cursorcol + 1) -- local before_cursor = vim.fn.strcharpart(cur_line, 0, cursorcol - 1) @@ -284,31 +156,23 @@ local function on_cursor_hold() -- get char offsets for more complex motions. local virt_line = build_virt_line({ - ["w"] = next_word_boundary(cur_line, cursorcol), - ["e"] = end_of_word(cur_line, cursorcol), - ["b"] = prev_word_boundary(cur_line, cursorcol), - ["^"] = cur_line:find("%S") or 0, - ["$"] = line_len, + ["w"] = hm.next_word_boundary(cur_line, cursorcol, line_len), + ["e"] = hm.end_of_word(cur_line, cursorcol, line_len), + ["b"] = hm.prev_word_boundary(cur_line, cursorcol, line_len), + ["^"] = hm.line_start_non_whitespace(cur_line, cursorcol, line_len), + ["$"] = hm.line_end(cur_line, cursorcol, line_len), }, line_len) -- TODO: can we add indent lines to the virt line to match indent-blankline or similar (if installed)? -- create (or overwrite) the extmark - if - vim.api.nvim_get_option_value( - "buftype", - { buf = vim.api.nvim_get_current_buf() } - ) == "" - then + if vim.api.nvim_get_option_value("buftype", { buf = vim.api.nvim_get_current_buf() }) == "" then extmark = vim.api.nvim_buf_set_extmark(0, ns, cursorline - 1, 0, { id = extmark, -- reuse the same extmark if it exists virt_lines = { virt_line }, }) end - apply_gutter_hints( - build_gutter_hints(vim.api.nvim_get_current_buf()), - vim.api.nvim_get_current_buf() - ) + apply_gutter_hints(build_gutter_hints()) dirty = false end @@ -335,10 +199,7 @@ local function on_insert_enter(ev) end local function on_buf_edit() - apply_gutter_hints( - build_gutter_hints(vim.api.nvim_get_current_buf()), - vim.api.nvim_get_current_buf() - ) + apply_gutter_hints(build_gutter_hints(), vim.api.nvim_get_current_buf()) end local function on_buf_leave(ev) @@ -441,18 +302,6 @@ end -- access these variables from outside the module -- but we don't want to expose them to the user local state = { - char_class = function() - return char_class - end, - next_word_boundary = function() - return next_word_boundary - end, - prev_word_boundary = function() - return prev_word_boundary - end, - end_of_word = function() - return end_of_word - end, build_virt_line = function() return build_virt_line end, diff --git a/lua/precognition/utils.lua b/lua/precognition/utils.lua new file mode 100644 index 0000000..018bb71 --- /dev/null +++ b/lua/precognition/utils.lua @@ -0,0 +1,21 @@ +local M = {} + +---@param char string +---@return integer +function M.char_class(char) + local byte = string.byte(char) + + if byte and byte < 0x100 then + if char == " " or char == "\t" or char == "\0" then + return 0 -- whitespace + end + if char == "_" or char:match("%w") then + return 2 -- word character + end + return 1 -- other + end + + return 1 -- scary unicode edge cases go here +end + +return M diff --git a/lua/precognition/vertical_motions.lua b/lua/precognition/vertical_motions.lua new file mode 100644 index 0000000..fd2d724 --- /dev/null +++ b/lua/precognition/vertical_motions.lua @@ -0,0 +1,77 @@ +local M = {} + +---@return integer +function M.file_start() + return 1 +end + +---@param bufnr? integer +---@return integer +function M.file_end(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + return vim.api.nvim_buf_line_count(bufnr) +end + +---@param bufnr? integer +---@return integer | nil +function M.next_paragraph_line(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + local loc + vim.api.nvim_buf_call(bufnr, function() + local found + local visibleline = vim.fn.line("w$") + local buffcontent = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local cursorline, _ = unpack(vim.api.nvim_win_get_cursor(0)) + while not found and cursorline < visibleline do + local cursorlinecontent = buffcontent[cursorline] + while cursorline < visibleline and cursorlinecontent:match("^%s*$") do + cursorline = cursorline + 1 + cursorlinecontent = buffcontent[cursorline] + end + -- find next blank line below + while cursorline < visibleline and not found do + cursorline = cursorline + 1 + cursorlinecontent = buffcontent[cursorline] + if cursorlinecontent:match("^%s*$") then + found = true + end + end + end + loc = cursorline + end) + return loc +end + +---@param bufnr? integer +---@return integer | nil +function M.prev_paragraph_line(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + local loc + vim.api.nvim_buf_call(bufnr, function() + local found + local visibleline = vim.fn.line("w0") + local buffcontent = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local cursorline, _ = unpack(vim.api.nvim_win_get_cursor(0)) + while not found and cursorline > visibleline do + local cursorlinecontent = buffcontent[cursorline] + while cursorline > visibleline and cursorlinecontent:match("^%s*$") do + cursorline = cursorline - 1 + cursorlinecontent = buffcontent[cursorline] + end + -- find next blank line above + while cursorline > visibleline and not found do + cursorline = cursorline - 1 + cursorlinecontent = buffcontent[cursorline] + if cursorlinecontent:match("^%s*$") then + found = true + end + end + end + loc = cursorline + end) + --check if line above is empty + --if so, return the line above that + return loc +end + +return M diff --git a/tests/precognition/char_spec.lua b/tests/precognition/char_spec.lua index 349e587..0fb3c2c 100644 --- a/tests/precognition/char_spec.lua +++ b/tests/precognition/char_spec.lua @@ -1,340 +1,24 @@ -local precognition = require("precognition") +local utils = require("precognition.utils") ---@diagnostic disable-next-line: undefined-field local eq = assert.are.same describe("char classing", function() it("white space is classeed", function() - eq(precognition.char_class(" "), 0) - eq(precognition.char_class("\t"), 0) - eq(precognition.char_class("\0"), 0) + eq(utils.char_class(" "), 0) + eq(utils.char_class("\t"), 0) + eq(utils.char_class("\0"), 0) end) it("word characters are classed", function() - eq(precognition.char_class("_"), 2) - eq(precognition.char_class("a"), 2) - eq(precognition.char_class("A"), 2) - eq(precognition.char_class("0"), 2) + eq(utils.char_class("_"), 2) + eq(utils.char_class("a"), 2) + eq(utils.char_class("A"), 2) + eq(utils.char_class("0"), 2) end) it("other characters are classed", function() - eq(precognition.char_class("!"), 1) - eq(precognition.char_class("@"), 1) - eq(precognition.char_class("."), 1) - end) -end) - -describe("boundaries", function() - it("finds the next word boundary", function() - eq(5, precognition.next_word_boundary("abc efg", 1)) - eq(5, precognition.next_word_boundary("abc efg", 2)) - eq(5, precognition.next_word_boundary("abc efg", 3)) - eq(5, precognition.next_word_boundary("abc efg", 4)) - eq(nil, precognition.next_word_boundary("abc efg", 5)) - eq(nil, precognition.next_word_boundary("abc efg", 6)) - eq(nil, precognition.next_word_boundary("abc efg", 7)) - - eq(9, precognition.next_word_boundary("slighly more complex test", 1)) - eq(9, precognition.next_word_boundary("slighly more complex test", 2)) - eq(14, precognition.next_word_boundary("slighly more complex test", 10)) - eq(14, precognition.next_word_boundary("slighly more complex test", 13)) - eq(22, precognition.next_word_boundary("slighly more complex test", 15)) - eq(22, precognition.next_word_boundary("slighly more complex test", 21)) - - eq( - 5, - precognition.next_word_boundary(" myFunction(example, stuff)", 1) - ) - eq( - 5, - precognition.next_word_boundary(" myFunction(example, stuff)", 2) - ) - eq( - 5, - precognition.next_word_boundary(" myFunction(example, stuff)", 3) - ) - eq( - 15, - precognition.next_word_boundary(" myFunction(example, stuff)", 5) - ) - eq( - 16, - precognition.next_word_boundary( - " myFunction(example, stuff)", - 15 - ) - ) - eq( - 23, - precognition.next_word_boundary( - " myFunction(example, stuff)", - 16 - ) - ) - eq( - 25, - precognition.next_word_boundary( - " myFunction(example, stuff)", - 23 - ) - ) - eq( - 25, - precognition.next_word_boundary( - " myFunction(example, stuff)", - 24 - ) - ) - eq( - 30, - precognition.next_word_boundary( - " myFunction(example, stuff)", - 25 - ) - ) - eq( - nil, - precognition.next_word_boundary( - " myFunction(example, stuff)", - 30 - ) - ) - end) - - it("can walk string with w", function() - local test_string = "abcdefg hijklmn opqrstu vwxyz" - local pos = precognition.next_word_boundary(test_string, 1) - eq("h", test_string:sub(pos, pos)) - pos = precognition.next_word_boundary(test_string, pos) - eq("o", test_string:sub(pos, pos)) - pos = precognition.next_word_boundary(test_string, pos) - eq("v", test_string:sub(pos, pos)) - pos = precognition.next_word_boundary(test_string, pos) - eq(nil, pos) - end) - - describe("previous word boundary", function() - it("finds the previous word boundary", function() - eq(nil, precognition.prev_word_boundary("abc efg", 1)) - eq(1, precognition.prev_word_boundary("abc efg", 2)) - eq(1, precognition.prev_word_boundary("abc efg", 3)) - eq(1, precognition.prev_word_boundary("abc efg", 4)) - eq(1, precognition.prev_word_boundary("abc efg", 5)) - eq(5, precognition.prev_word_boundary("abc efg", 6)) - eq(5, precognition.prev_word_boundary("abc efg", 7)) - - eq( - 9, - precognition.prev_word_boundary("slighly more complex test", 10) - ) - eq( - 9, - precognition.prev_word_boundary("slighly more complex test", 11) - ) - eq( - 14, - precognition.prev_word_boundary("slighly more complex test", 15) - ) - eq( - 14, - precognition.prev_word_boundary("slighly more complex test", 16) - ) - eq( - 22, - precognition.prev_word_boundary("slighly more complex test", 23) - ) - eq( - 22, - precognition.prev_word_boundary("slighly more complex test", 24) - ) - eq( - 22, - precognition.prev_word_boundary("slighly more complex test", 25) - ) - eq( - nil, - precognition.prev_word_boundary("slighly more complex test", 1) - ) - - eq( - nil, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 1 - ) - ) - eq( - nil, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 2 - ) - ) - eq( - nil, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 3 - ) - ) - eq( - nil, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 4 - ) - ) - eq( - nil, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 5 - ) - ) - eq( - 5, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 6 - ) - ) - eq( - 5, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 16 - ) - ) - eq( - 16, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 17 - ) - ) - eq( - 16, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 18 - ) - ) - eq( - 16, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 19 - ) - ) - eq( - 24, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 25 - ) - ) - eq( - 25, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 26 - ) - ) - eq( - 25, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 27 - ) - ) - eq( - 25, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 28 - ) - ) - eq( - 25, - precognition.prev_word_boundary( - " myFunction(example, stuff)", - 29 - ) - ) - --TODO: This isnt right, it should ne 25, but i dont know the rules - --there is something odd if there is only one class 2 under the cursor - -- eq(25, precognition.prev_word_boundary(" myFunction(example, stuff)", 30)) - end) - - it("can walk string with b", function() - local test_string = "abcdefg hijklmn opqrstu vwxyz" - local pos = precognition.prev_word_boundary(test_string, 29) - eq("v", test_string:sub(pos, pos)) - pos = precognition.prev_word_boundary(test_string, pos) - eq("o", test_string:sub(pos, pos)) - pos = precognition.prev_word_boundary(test_string, pos) - eq("h", test_string:sub(pos, pos)) - pos = precognition.prev_word_boundary(test_string, pos) - eq(1, pos) - end) - end) - - describe("end of current word", function() - it("finds the end of words", function() - eq(3, precognition.end_of_word("abc efg", 1)) - eq(3, precognition.end_of_word("abc efg", 2)) - eq(7, precognition.end_of_word("abc efg", 3)) - - eq(7, precognition.end_of_word("slighly more complex test", 1)) - eq(7, precognition.end_of_word("slighly more complex test", 2)) - eq(12, precognition.end_of_word("slighly more complex test", 10)) - eq(20, precognition.end_of_word("slighly more complex test", 13)) - eq(20, precognition.end_of_word("slighly more complex test", 15)) - eq(25, precognition.end_of_word("slighly more complex test", 21)) - - eq( - 14, - precognition.end_of_word(" myFunction(example, stuff)", 1) - ) - eq( - 14, - precognition.end_of_word(" myFunction(example, stuff)", 2) - ) - eq( - 14, - precognition.end_of_word(" myFunction(example, stuff)", 3) - ) - eq( - 14, - precognition.end_of_word(" myFunction(example, stuff)", 5) - ) - --TODO: These next two dont work either for the same reason as the previous - --something to do with the bracket being under the cursor - -- eq(15, precognition.end_of_word(" myFunction(example, stuff)", 14)) - -- eq(22, precognition.end_of_word(" myFunction(example, stuff)", 15)) - eq( - 22, - precognition.end_of_word(" myFunction(example, stuff)", 16) - ) - eq( - 29, - precognition.end_of_word(" myFunction(example, stuff)", 23) - ) - eq( - 29, - precognition.end_of_word(" myFunction(example, stuff)", 24) - ) - eq( - 29, - precognition.end_of_word(" myFunction(example, stuff)", 25) - ) - eq( - 29, - precognition.end_of_word(" myFunction(example, stuff)", 29) - ) - eq( - nil, - precognition.end_of_word(" myFunction(example, stuff)", 30) - ) - end) + eq(utils.char_class("!"), 1) + eq(utils.char_class("@"), 1) + eq(utils.char_class("."), 1) end) end) diff --git a/tests/precognition/gutter_hints_spec.lua b/tests/precognition/gutter_hints_spec.lua index f3518e6..0c88d59 100644 --- a/tests/precognition/gutter_hints_spec.lua +++ b/tests/precognition/gutter_hints_spec.lua @@ -4,17 +4,139 @@ local eq = assert.are.same describe("Gutter hints table", function() it("should return a table with the correct keys", function() - local testBuf = vim.api.nvim_create_buf(false, true) + local testBuf = vim.api.nvim_create_buf(true, true) vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { "ABC", "DEF", "", "GHI", + "", + "JKL", + "", + "MNO", }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + + local hints = precognition.build_gutter_hints(testBuf) + + eq({ + ["gg"] = 1, + ["{"] = 3, + ["}"] = 5, + ["G"] = 8, + }, hints) + end) + + it("should return a table with the correct keys when the buffer is empty", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, {}) + vim.api.nvim_set_current_buf(testBuf) local hints = precognition.build_gutter_hints(testBuf) - eq(4, hints["G"]) - eq(1, hints["gg"]) + eq({ + ["gg"] = 1, + ["{"] = 1, + ["}"] = 1, + ["G"] = 1, + }, hints) + end) + + it("should return a table with the correct keys when the buffer is a single line", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { "ABC" }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 1, 1 }) + + local hints = precognition.build_gutter_hints(testBuf) + eq({ + ["gg"] = 1, + ["{"] = 1, + ["}"] = 1, + ["G"] = 1, + }, hints) + end) + + it("moving the cursor will update the hints table", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { + "ABC", + "DEF", + "", + "GHI", + "", + "JKL", + "", + "MNO", + }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + + local hints = precognition.build_gutter_hints(testBuf) + + eq({ + ["gg"] = 1, + ["{"] = 3, + ["}"] = 5, + ["G"] = 8, + }, hints) + + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + hints = precognition.build_gutter_hints(testBuf) + eq({ + ["gg"] = 1, + ["{"] = 5, + ["}"] = 7, + ["G"] = 8, + }, hints) + end) + + it("adding a line will update the hints table", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { "ABC" }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 1, 1 }) + + local hints = precognition.build_gutter_hints(testBuf) + eq({ + ["gg"] = 1, + ["{"] = 1, + ["}"] = 1, + ["G"] = 1, + }, hints) + + vim.api.nvim_buf_set_lines(testBuf, 1, 1, false, { "DEF" }) + + hints = precognition.build_gutter_hints(testBuf) + eq({ + ["gg"] = 1, + ["{"] = 1, + ["}"] = 2, + ["G"] = 2, + }, hints) + + vim.api.nvim_buf_set_lines(testBuf, 2, 2, false, { "GHI" }) + + hints = precognition.build_gutter_hints(testBuf) + + eq({ + ["gg"] = 1, + ["{"] = 1, + ["}"] = 3, + ["G"] = 3, + }, hints) + + vim.api.nvim_buf_set_lines(testBuf, 3, 3, false, { "" }) + vim.api.nvim_buf_set_lines(testBuf, 4, 4, false, { "JKL" }) + + hints = precognition.build_gutter_hints(testBuf) + + eq({ + ["gg"] = 1, + ["{"] = 1, + ["}"] = 4, + ["G"] = 5, + }, hints) end) end) diff --git a/tests/precognition/horizontal_motions_spec.lua b/tests/precognition/horizontal_motions_spec.lua new file mode 100644 index 0000000..cd86e49 --- /dev/null +++ b/tests/precognition/horizontal_motions_spec.lua @@ -0,0 +1,173 @@ +local hm = require("precognition.horizontal_motions") +---@diagnostic disable-next-line: undefined-field +local eq = assert.are.same + +describe("boundaries", function() + it("finds the next word boundary", function() + eq(5, hm.next_word_boundary("abc efg", 1, 7)) + eq(5, hm.next_word_boundary("abc efg", 2, 7)) + eq(5, hm.next_word_boundary("abc efg", 3, 7)) + eq(5, hm.next_word_boundary("abc efg", 4, 7)) + eq(nil, hm.next_word_boundary("abc efg", 5, 7)) + eq(nil, hm.next_word_boundary("abc efg", 6, 7)) + eq(nil, hm.next_word_boundary("abc efg", 7, 7)) + + eq(9, hm.next_word_boundary("slighly more complex test", 1, 22)) + eq(9, hm.next_word_boundary("slighly more complex test", 2, 22)) + eq(14, hm.next_word_boundary("slighly more complex test", 10, 22)) + eq(14, hm.next_word_boundary("slighly more complex test", 13, 22)) + eq(22, hm.next_word_boundary("slighly more complex test", 15, 22)) + eq(22, hm.next_word_boundary("slighly more complex test", 21, 22)) + + eq(5, hm.next_word_boundary(" myFunction(example, stuff)", 1, 30)) + eq(5, hm.next_word_boundary(" myFunction(example, stuff)", 2, 30)) + eq(5, hm.next_word_boundary(" myFunction(example, stuff)", 3, 30)) + eq(15, hm.next_word_boundary(" myFunction(example, stuff)", 5, 30)) + eq(16, hm.next_word_boundary(" myFunction(example, stuff)", 15, 30)) + eq(23, hm.next_word_boundary(" myFunction(example, stuff)", 16, 30)) + eq(25, hm.next_word_boundary(" myFunction(example, stuff)", 23, 30)) + eq(25, hm.next_word_boundary(" myFunction(example, stuff)", 24, 30)) + eq(30, hm.next_word_boundary(" myFunction(example, stuff)", 25, 30)) + eq(nil, hm.next_word_boundary(" myFunction(example, stuff)", 30, 30)) + end) + + it("can walk string with w", function() + local test_string = "abcdefg hijklmn opqrstu vwxyz" + local pos = hm.next_word_boundary(test_string, 1, #test_string) + if pos == nil then + error("pos is nil") + end + eq("h", test_string:sub(pos, pos)) + if pos == nil then + error("pos is nil") + end + pos = hm.next_word_boundary(test_string, pos, #test_string) + if pos == nil then + error("pos is nil") + end + eq("o", test_string:sub(pos, pos)) + pos = hm.next_word_boundary(test_string, pos, #test_string) + if pos == nil then + error("pos is nil") + end + eq("v", test_string:sub(pos, pos)) + pos = hm.next_word_boundary(test_string, pos, #test_string) + eq(nil, pos) + end) + + describe("previous word boundary", function() + it("finds the previous word boundary", function() + eq(nil, hm.prev_word_boundary("abc efg", 1, 7)) + eq(1, hm.prev_word_boundary("abc efg", 2, 7)) + eq(1, hm.prev_word_boundary("abc efg", 3, 7)) + eq(1, hm.prev_word_boundary("abc efg", 4, 7)) + eq(1, hm.prev_word_boundary("abc efg", 5, 7)) + eq(5, hm.prev_word_boundary("abc efg", 6, 7)) + eq(5, hm.prev_word_boundary("abc efg", 7, 7)) + + eq(9, hm.prev_word_boundary("slighly more complex test", 10, 22)) + eq(9, hm.prev_word_boundary("slighly more complex test", 11, 22)) + eq(14, hm.prev_word_boundary("slighly more complex test", 15, 22)) + eq(14, hm.prev_word_boundary("slighly more complex test", 16, 22)) + eq(22, hm.prev_word_boundary("slighly more complex test", 23, 22)) + eq(22, hm.prev_word_boundary("slighly more complex test", 24, 22)) + eq(22, hm.prev_word_boundary("slighly more complex test", 25, 22)) + eq(nil, hm.prev_word_boundary("slighly more complex test", 1, 22)) + + eq(nil, hm.prev_word_boundary(" myFunction(example, stuff)", 1, 30)) + eq(nil, hm.prev_word_boundary(" myFunction(example, stuff)", 2, 30)) + eq(nil, hm.prev_word_boundary(" myFunction(example, stuff)", 3, 30)) + eq(nil, hm.prev_word_boundary(" myFunction(example, stuff)", 4, 30)) + eq(nil, hm.prev_word_boundary(" myFunction(example, stuff)", 5, 30)) + eq(5, hm.prev_word_boundary(" myFunction(example, stuff)", 6, 30)) + eq(5, hm.prev_word_boundary(" myFunction(example, stuff)", 15, 30)) + eq(15, hm.prev_word_boundary(" myFunction(example, stuff)", 16, 30)) + eq(16, hm.prev_word_boundary(" myFunction(example, stuff)", 17, 30)) + eq(16, hm.prev_word_boundary(" myFunction(example, stuff)", 18, 30)) + eq(16, hm.prev_word_boundary(" myFunction(example, stuff)", 19, 30)) + eq(23, hm.prev_word_boundary(" myFunction(example, stuff)", 25, 30)) + eq(25, hm.prev_word_boundary(" myFunction(example, stuff)", 26, 30)) + eq(25, hm.prev_word_boundary(" myFunction(example, stuff)", 27, 30)) + eq(25, hm.prev_word_boundary(" myFunction(example, stuff)", 28, 30)) + eq(25, hm.prev_word_boundary(" myFunction(example, stuff)", 29, 30)) + eq(25, hm.prev_word_boundary(" myFunction(example, stuff)", 30, 30)) + end) + + it("can walk string with b", function() + local test_string = "abcdefg hijklmn opqrstu vwxyz" + local pos = hm.prev_word_boundary(test_string, 29, #test_string) + if pos == nil then + error("pos is nil") + end + eq("v", test_string:sub(pos, pos)) + pos = hm.prev_word_boundary(test_string, pos, #test_string) + if pos == nil then + error("pos is nil") + end + eq("o", test_string:sub(pos, pos)) + pos = hm.prev_word_boundary(test_string, pos, #test_string) + if pos == nil then + error("pos is nil") + end + eq("h", test_string:sub(pos, pos)) + pos = hm.prev_word_boundary(test_string, pos, #test_string) + eq(1, pos) + end) + end) + + describe("end of current word", function() + it("finds the end of words", function() + eq(3, hm.end_of_word("abc efg", 1, 7)) + eq(3, hm.end_of_word("abc efg", 2, 7)) + eq(7, hm.end_of_word("abc efg", 3, 7)) + + eq(7, hm.end_of_word("slighly more complex test", 1, 22)) + eq(7, hm.end_of_word("slighly more complex test", 2, 22)) + eq(12, hm.end_of_word("slighly more complex test", 10, 22)) + eq(20, hm.end_of_word("slighly more complex test", 13, 22)) + eq(20, hm.end_of_word("slighly more complex test", 15, 22)) + eq(25, hm.end_of_word("slighly more complex test", 21, 22)) + + eq(14, hm.end_of_word(" myFunction(example, stuff)", 1, 30)) + eq(14, hm.end_of_word(" myFunction(example, stuff)", 2, 30)) + eq(14, hm.end_of_word(" myFunction(example, stuff)", 3, 30)) + eq(14, hm.end_of_word(" myFunction(example, stuff)", 5, 30)) + eq(15, hm.end_of_word(" myFunction(example, stuff)", 14, 30)) + eq(22, hm.end_of_word(" myFunction(example, stuff)", 15, 30)) + eq(22, hm.end_of_word(" myFunction(example, stuff)", 16, 30)) + eq(29, hm.end_of_word(" myFunction(example, stuff)", 23, 30)) + eq(29, hm.end_of_word(" myFunction(example, stuff)", 24, 30)) + eq(29, hm.end_of_word(" myFunction(example, stuff)", 25, 30)) + eq(30, hm.end_of_word(" myFunction(example, stuff)", 29, 30)) + eq(nil, hm.end_of_word(" myFunction(example, stuff)", 30, 30)) + end) + end) +end) + +describe("edge case", function() + it("can handle empty strings", function() + eq(nil, hm.next_word_boundary("", 1, 0)) + eq(nil, hm.prev_word_boundary("", 1, 0)) + eq(nil, hm.end_of_word("", 1, 0)) + end) + + it("can handle strings with only whitespace", function() + eq(nil, hm.next_word_boundary(" ", 1, 1)) + eq(nil, hm.prev_word_boundary(" ", 1, 1)) + eq(nil, hm.end_of_word(" ", 1, 1)) + end) + + it("can handle strings with special characters in the middle", function() + local str = "vim.keymap.set('n', 't;', ':Test')" + eq(5, hm.next_word_boundary(str, 4, #str)) + eq(1, hm.prev_word_boundary(str, 4, #str)) + eq(10, hm.end_of_word(str, 4, #str)) + end) + + it("can handle strings with multiple consecutive special characters", function() + local str = "this || that" + eq(9, hm.next_word_boundary(str, 6, #str)) + eq(1, hm.prev_word_boundary(str, 6, #str)) + eq(7, hm.end_of_word(str, 6, #str)) + end) +end) diff --git a/tests/precognition/vertical_motions_spec.lua b/tests/precognition/vertical_motions_spec.lua new file mode 100644 index 0000000..ac8e03f --- /dev/null +++ b/tests/precognition/vertical_motions_spec.lua @@ -0,0 +1,145 @@ +local vm = require("precognition.vertical_motions") +---@diagnostic disable-next-line: undefined-field +local eq = assert.are.same + +describe("gutter motion locations", function() + it("can find file start in a single line buffer", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { "ABC" }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 1, 1 }) + + local start = vm.file_start() + eq(1, start) + end) + + it("can find file start in a multi line buffer", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { + "ABC", + "DEF", + "", + "GHI", + "", + "JKL", + "", + "MNO", + }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + + local start = vm.file_start() + eq(1, start) + end) + + it("can find file end in a single line buffer", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { "ABC" }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 1, 1 }) + + local end_ = vm.file_end(testBuf) + eq(1, end_) + eq(vim.api.nvim_buf_line_count(testBuf), end_) + end) + + it("can find file end in a multi line buffer", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { + "ABC", + "DEF", + "", + "GHI", + "", + "JKL", + "", + "MNO", + }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + + local end_ = vm.file_end(testBuf) + eq(8, end_) + eq(vim.api.nvim_buf_line_count(testBuf), end_) + end) + + it("can find the next paragraph line", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { + "ABC", + "DEF", + "", + "GHI", + "", + "JKL", + "", + "MNO", + }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + + local next_line = vm.next_paragraph_line(testBuf) + eq(5, next_line) + end) + + it("can find the previous paragraph line", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { + "ABC", + "DEF", + "", + "GHI", + "", + "JKL", + "", + "MNO", + }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + + local prev_line = vm.prev_paragraph_line(testBuf) + eq(5, prev_line) + end) + + it("can find the prev paragraph in a file with multiple consecutive blank lines", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { + "ABC", + "DEF", + "", + "", + "GHI", + "", + "JKL", + "", + "", + "", + "MNO", + }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 10, 0 }) + + local prev_line = vm.prev_paragraph_line(testBuf) + eq(6, prev_line) + end) + + it("can find the next paragraph in a file with multiple consecutive blank lines", function() + local testBuf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(testBuf, 0, -1, false, { + "ABC", + "DEF", + "", + "", + "", + "", + "GHI", + "", + "JKL", + }) + vim.api.nvim_set_current_buf(testBuf) + vim.api.nvim_win_set_cursor(0, { 3, 0 }) + + local next_line = vm.next_paragraph_line(testBuf) + eq(8, next_line) + end) +end) diff --git a/tests/precognition/virtline_spec.lua b/tests/precognition/virtline_spec.lua index 4d76727..221c9ab 100644 --- a/tests/precognition/virtline_spec.lua +++ b/tests/precognition/virtline_spec.lua @@ -1,4 +1,5 @@ local precognition = require("precognition") +local hm = require("precognition.horizontal_motions") ---@diagnostic disable-next-line: undefined-field local eq = assert.are.same describe("Build Virtual Line", function() @@ -33,18 +34,15 @@ describe("Build Virtual Line", function() eq(10, #virtual_line[1][1]) end) - it( - "can build a virtual line with a single mark at the beginning", - function() - ---@type Precognition.VirtLine - local marks = { - ["^"] = 1, - } - local virtual_line = precognition.build_virt_line(marks, 10) - eq("^ ", virtual_line[1][1]) - eq(10, #virtual_line[1][1]) - end - ) + it("can build a virtual line with a single mark at the beginning", function() + ---@type Precognition.VirtLine + local marks = { + ["^"] = 1, + } + local virtual_line = precognition.build_virt_line(marks, 10) + eq("^ ", virtual_line[1][1]) + eq(10, #virtual_line[1][1]) + end) it("can build a complex virtual line", function() ---@type Precognition.VirtLine @@ -76,17 +74,16 @@ describe("Build Virtual Line", function() it("example virtual line", function() local line = "abcdef ghijkl mnopqr stuvwx yz" local cursorcol = 2 - local tab_width = vim.bo.expandtab and vim.bo.shiftwidth - or vim.bo.tabstop + local tab_width = vim.bo.expandtab and vim.bo.shiftwidth or vim.bo.tabstop local cur_line = line:gsub("\t", string.rep(" ", tab_width)) local line_len = vim.fn.strcharlen(cur_line) local virt_line = precognition.build_virt_line({ - ["w"] = precognition.next_word_boundary(cur_line, cursorcol), - ["e"] = precognition.end_of_word(cur_line, cursorcol), - ["b"] = precognition.prev_word_boundary(cur_line, cursorcol), - ["^"] = cur_line:find("%S") or 0, - ["$"] = line_len, + ["w"] = hm.next_word_boundary(cur_line, cursorcol, line_len), + ["e"] = hm.end_of_word(cur_line, cursorcol, line_len), + ["b"] = hm.prev_word_boundary(cur_line, cursorcol, line_len), + ["^"] = hm.line_start_non_whitespace(cur_line, cursorcol, line_len), + ["$"] = hm.line_end(cur_line, cursorcol, line_len), }, line_len) eq("b e w $", virt_line[1][1]) @@ -97,17 +94,16 @@ describe("Build Virtual Line", function() local line = " abc def" -- abc def local cursorcol = 5 - local tab_width = vim.bo.expandtab and vim.bo.shiftwidth - or vim.bo.tabstop + local tab_width = vim.bo.expandtab and vim.bo.shiftwidth or vim.bo.tabstop local cur_line = line:gsub("\t", string.rep(" ", tab_width)) local line_len = vim.fn.strcharlen(cur_line) local virt_line = precognition.build_virt_line({ - ["w"] = precognition.next_word_boundary(cur_line, cursorcol), - ["e"] = precognition.end_of_word(cur_line, cursorcol), - ["b"] = precognition.prev_word_boundary(cur_line, cursorcol), - ["^"] = cur_line:find("%S") or 0, - ["$"] = line_len, + ["w"] = hm.next_word_boundary(cur_line, cursorcol, line_len), + ["e"] = hm.end_of_word(cur_line, cursorcol, line_len), + ["b"] = hm.prev_word_boundary(cur_line, cursorcol, line_len), + ["^"] = hm.line_start_non_whitespace(cur_line, cursorcol, line_len), + ["$"] = hm.line_end(cur_line, cursorcol, line_len), }, line_len) eq(" ^ e w $", virt_line[1][1])