diff --git a/lua/u/codewriter.lua b/lua/u/codewriter.lua index d8e810b..8dfb2b7 100644 --- a/lua/u/codewriter.lua +++ b/lua/u/codewriter.lua @@ -32,7 +32,7 @@ end --- @param line string --- @param bufnr? number function CodeWriter.from_line(line, bufnr) - if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end + if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end local ws = line:match '^%s*' local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr }) diff --git a/lua/u/pos.lua b/lua/u/pos.lua index a3216d7..7b2b42b 100644 --- a/lua/u/pos.lua +++ b/lua/u/pos.lua @@ -67,6 +67,15 @@ function Pos.__sub(x, y) return x:next(-y) end +--- @param bufnr number +--- @param lnum number +function Pos.from_eol(bufnr, lnum) + if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end + local pos = Pos.new(bufnr, lnum, 0) + pos.col = pos:line():len() + return pos +end + --- @param name string --- @return u.Pos function Pos.from_pos(name) @@ -96,6 +105,8 @@ end function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end +function Pos:eol() return Pos.from_eol(self.bufnr, self.lnum) end + --- @param pos string function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end diff --git a/lua/u/range.lua b/lua/u/range.lua index 04d5eb7..e21cf03 100644 --- a/lua/u/range.lua +++ b/lua/u/range.lua @@ -96,20 +96,26 @@ function Range.from_extmark(bufnr, extmark) ---@type integer, integer, vim.api.keyset.extmark_details | nil local start_row0, start_col0, details = unpack(extmark) - local mode = 'v' local start = Pos.new(bufnr, start_row0 + 1, start_col0 + 1) local stop = details and Pos.new(bufnr, details.end_row + 1, details.end_col) - -- Check for invalid extmark range: - if stop and stop < start then return Range.new(stop) end + if stop ~= nil then + -- Check for invalid extmark range: + if stop < start then return Range.new(stop) end - if stop and stop.col == 0 then - mode = 'V' - stop = stop:must_next(-1) - stop.col = Pos.MAX_COL + -- Check for stop-mark past the end of the buffer: + local buf_max_lines = vim.api.nvim_buf_line_count(bufnr) + if stop.lnum > buf_max_lines then + stop.lnum = buf_max_lines + stop = stop:eol() + end + + -- A stop mark at position 0 means it is at the end of the last line. + -- Move it back. + if stop.col == 0 then stop = stop:must_next(-1) end end - return Range.new(start, stop, mode) + return Range.new(start, stop, 'v') end --- @param bufnr? number @@ -144,7 +150,7 @@ end function Range.from_motion(motion, opts) -- Options handling: opts = opts or {} - if opts.bufnr == nil then opts.bufnr = vim.api.nvim_get_current_buf() end + if opts.bufnr == nil or opts.bufnr == 0 then opts.bufnr = vim.api.nvim_get_current_buf() end if opts.contains_cursor == nil then opts.contains_cursor = false end if opts.user_defined == nil then opts.user_defined = false end diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua index 7af561f..0a98d61 100644 --- a/lua/u/renderer.lua +++ b/lua/u/renderer.lua @@ -66,7 +66,7 @@ function Renderer.is_tag_arr(x) end --- @param bufnr number|nil function Renderer.new(bufnr) -- {{{ - if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end + if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end if vim.b[bufnr]._renderer_ns == nil then vim.b[bufnr]._renderer_ns = vim.api.nvim_create_namespace('my.renderer:' .. tostring(bufnr)) @@ -327,8 +327,8 @@ function Renderer:_reconcile() -- {{{ -- is), we don't want the extmark to move to the right. right_gravity = false, -- If we change the text starting from the end (where the end extmark - -- is), we don't want the extmark to move to stay stationary: we want - -- it to move to the right. + -- is), we don't want the extmark to stay stationary: we want it to + -- move to the right. end_right_gravity = true, }, extmark.opts) ) @@ -344,7 +344,7 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{ -- find the tag with the smallest intersection that contains the cursor: local pos0 = vim.api.nvim_win_get_cursor(0) pos0[1] = pos0[1] - 1 -- make it actually 0-based - local pos_infos = self:get_pos_infos(pos0) + local pos_infos = self:get_tags_at(pos0) if #pos_infos == 0 then return lhs end @@ -380,7 +380,7 @@ function Renderer:_on_text_changed() --- @type integer, integer local l, c = unpack(vim.api.nvim_win_get_cursor(0)) l = l - 1 -- make it actually 0-based - local pos_infos = self:get_pos_infos { l, c } + local pos_infos = self:get_tags_at { l, c } for _, pos_info in ipairs(pos_infos) do local extmark_inf = pos_info.extmark local tag = pos_info.tag @@ -394,14 +394,35 @@ function Renderer:_on_text_changed() local start_row0, start_col0, details = unpack(extmark) local end_row0, end_col0 = details.end_row, details.end_col + local buf_max_line0 = math.max(1, vim.api.nvim_buf_line_count(self.bufnr) - 1) + if end_row0 > buf_max_line0 then + end_row0 = buf_max_line0 + local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1] + or '' + end_col0 = last_line:len() + end + if end_col0 == 0 then + end_row0 = end_row0 - 1 + local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1] + or '' + end_col0 = last_line:len() + end + if start_row0 == end_row0 and start_col0 == end_col0 then on_change '' else - local lines = vim.fn.getregion( - { self.bufnr, start_row0 + 1, start_col0 + 1 }, - { self.bufnr, end_row0 + 1, end_col0 }, - { type = 'v' } - ) + local pos1 = { self.bufnr, start_row0 + 1, start_col0 + 1 } + local pos2 = { self.bufnr, end_row0 + 1, end_col0 } + local ok, lines = pcall(vim.fn.getregion, pos1, pos2, { type = 'v' }) + if not ok then + vim.api.nvim_echo({ + { '(u.nvim:getregion:invalid-pos) ', 'ErrorMsg' }, + { + '{ start, end } = ' .. vim.inspect({ pos1, pos2 }, { newline = ' ', indent = '' }), + }, + }, true, {}) + error(lines) + end local text = table.concat(lines, '\n') on_change(text) end @@ -409,6 +430,67 @@ function Renderer:_on_text_changed() end end +function Renderer:_debug() + local prev_w = vim.api.nvim_get_current_win() + vim.cmd.vnew() + local info_bufnr = vim.api.nvim_get_current_buf() + vim.bo.bufhidden = 'delete' + vim.bo.buflisted = false + vim.bo.buftype = 'nowrite' + + local ids = {} + local function cleanup() + for _, id in ipairs(ids) do + vim.api.nvim_del_autocmd(id) + end + vim.api.nvim_buf_delete(info_bufnr, { force = true }) + end + + table.insert( + ids, + vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, { + callback = function() + local l, c = unpack(vim.api.nvim_win_get_cursor(0)) + l = l - 1 -- make it actually 0-based + + local info = { + cursor = { + pos = { l, c }, + tags = self:get_tags_at { l, c }, + extmarks = vim.api.nvim_buf_get_extmarks( + self.bufnr, + self.ns, + { l, c }, + { l, c }, + { details = true, overlap = true } + ), + }, + computed = { + extmarks = self.curr.extmarks, + }, + } + vim.api.nvim_buf_set_lines(info_bufnr, 0, -1, true, vim.split(vim.inspect(info), '\n')) + end, + }) + ) + table.insert( + ids, + vim.api.nvim_create_autocmd('WinClosed', { + pattern = tostring(vim.api.nvim_get_current_win()), + callback = cleanup, + }) + ) + table.insert( + ids, + vim.api.nvim_create_autocmd('WinClosed', { + pattern = tostring(prev_w), + callback = cleanup, + }) + ) + + vim.api.nvim_set_current_win(prev_w) +end + --- Returns pairs of extmarks and tags associate with said extmarks. The --- returned tags/extmarks are sorted smallest (innermost) to largest --- (outermost). @@ -417,7 +499,7 @@ end --- @param pos0 [number; number] --- @param mode string? --- @return { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag; }[] -function Renderer:get_pos_infos(pos0, mode) -- {{{ +function Renderer:get_tags_at(pos0, mode) -- {{{ local cursor_line0, cursor_col0 = pos0[1], pos0[2] if not mode then mode = vim.api.nvim_get_mode().mode end @@ -425,7 +507,7 @@ function Renderer:get_pos_infos(pos0, mode) -- {{{ -- edge, and one for it's right. We need to do our own intersection test, -- because the NeoVim API is over-inclusive in what it returns: --- @type u.renderer.RendererExtmark[] - local intersecting_extmarks = vim + local mapped_extmarks = vim .iter( vim.api.nvim_buf_get_extmarks( self.bufnr, @@ -446,26 +528,43 @@ function Renderer:get_pos_infos(pos0, mode) -- {{{ end return { id = id, start = start, stop = stop, opts = details } end) + :totable() + + local intersecting_extmarks = vim + .iter(mapped_extmarks) --- @param ext u.renderer.RendererExtmark :filter(function(ext) if ext.stop[1] ~= nil and ext.stop[2] ~= nil then -- If we've "ciw" and "collapsed" an extmark onto the cursor, -- the cursor pos will equal the exmark's start AND end. In this -- case, we want to include the extmark. - return ( + if cursor_line0 == ext.start[1] and cursor_col0 == ext.start[2] and cursor_line0 == ext.stop[1] and cursor_col0 == ext.stop[2] - ) - or ( - cursor_line0 >= ext.start[1] - and cursor_col0 >= ext.start[2] + then + return true + end + + return + -- START: line check + cursor_line0 >= ext.start[1] + -- START: column check + and (cursor_line0 ~= ext.start[1] or cursor_col0 >= ext.start[2]) + -- STOP: line check and cursor_line0 <= ext.stop[1] - -- In insert mode, the cursor is "thin", so <= to compensate: - -- In normal mode, the cursor is "wide", so < to compensate: - and (mode == 'i' and cursor_col0 <= ext.stop[2] or cursor_col0 < ext.stop[2]) - ) + -- STOP: column check + and ( + cursor_line0 ~= ext.stop[1] + or ( + mode == 'i' + -- In insert mode, the cursor is "thin", so <= to compensate: + and cursor_col0 <= ext.stop[2] + -- In normal mode, the cursor is "wide", so < to compensate: + or cursor_col0 < ext.stop[2] + ) + ) else return true end diff --git a/shell.nix b/shell.nix index e47a675..d172772 100644 --- a/shell.nix +++ b/shell.nix @@ -1,10 +1,10 @@ { pkgs ? import - # nixpkgs-unstable (neovim@0.11.2): + # nixos-25.05 (neovim@0.11.2): (fetchTarball { - url = "https://github.com/nixos/nixpkgs/archive/f72be405a10668b8b00937b452f2145244103ebc.tar.gz"; - sha256 = "0m1vnvngpxrawsgg306c9sdhbzsiigjgb03yfbdpa2fsb1fs0zm9"; + url = "https://github.com/nixos/nixpkgs/archive/32a4e87942101f1c9f9865e04dc3ddb175f5f32e.tar.gz"; + sha256 = "1jvflnbrxa8gjxkwjq6kdpdzgwp0hs59h9l3xjasksv0v7xlwykz"; }) { }, }: diff --git a/spec/range_spec.lua b/spec/range_spec.lua index e60f8a1..cbe7754 100644 --- a/spec/range_spec.lua +++ b/spec/range_spec.lua @@ -738,4 +738,36 @@ describe('Range', function() assert.are.same({ 3, 0 }, { details.end_row, details.end_col }) end) end) + + it('discerns range bounds from extmarks beyond the end of the buffer', function() + local Buffer = require 'u.buffer' + + vim.cmd.vnew() + local left = Buffer.current() + left:set_tmp_options() + vim.cmd.vnew() + local right = Buffer.current() + right:set_tmp_options() + + left:all():replace { + 'one', + 'two', + 'three', + } + local left_all_ext = left:all():save_to_extmark() + + right:all():replace { + 'foo', + 'bar', + } + + vim.api.nvim_set_current_buf(right.bufnr) + vim.cmd [[normal! ggyG]] + vim.api.nvim_set_current_buf(left.bufnr) + vim.cmd [[normal! ggVGp]] + + assert.are.same({ 'foo', 'bar' }, left_all_ext:range():lines()) + vim.api.nvim_buf_delete(left.bufnr, { force = true }) + vim.api.nvim_buf_delete(right.bufnr, { force = true }) + end) end) diff --git a/spec/renderer_spec.lua b/spec/renderer_spec.lua index dee5da6..60036db 100644 --- a/spec/renderer_spec.lua +++ b/spec/renderer_spec.lua @@ -83,13 +83,13 @@ describe('Renderer', function() end) -- - -- get_pos_infos + -- get_tags_at -- it('should return no extmarks for an empty buffer', function() withbuf({}, function() local r = R.Renderer.new(0) - local pos_infos = r:get_pos_infos { 0, 0 } + local pos_infos = r:get_tags_at { 0, 0 } assert.are.same(pos_infos, {}) end) end) @@ -102,25 +102,25 @@ describe('Renderer', function() R.h('text', { hl = 'HighlightGroup2' }, ' World'), } - local pos_infos = r:get_pos_infos { 0, 2 } + local pos_infos = r:get_tags_at { 0, 2 } assert.are.same(#pos_infos, 1) assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1') assert.are.same(pos_infos[1].extmark.start, { 0, 0 }) assert.are.same(pos_infos[1].extmark.stop, { 0, 5 }) - pos_infos = r:get_pos_infos { 0, 4 } + pos_infos = r:get_tags_at { 0, 4 } assert.are.same(#pos_infos, 1) assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1') assert.are.same(pos_infos[1].extmark.start, { 0, 0 }) assert.are.same(pos_infos[1].extmark.stop, { 0, 5 }) - pos_infos = r:get_pos_infos { 0, 5 } + pos_infos = r:get_tags_at { 0, 5 } assert.are.same(#pos_infos, 1) assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2') assert.are.same(pos_infos[1].extmark.start, { 0, 5 }) assert.are.same(pos_infos[1].extmark.stop, { 0, 11 }) -- In insert mode, bounds are eagerly included: - pos_infos = r:get_pos_infos({ 0, 5 }, 'i') + pos_infos = r:get_tags_at({ 0, 5 }, 'i') assert.are.same(#pos_infos, 2) assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1') assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup2') @@ -141,11 +141,66 @@ describe('Renderer', function() }), } - local pos_infos = r:get_pos_infos { 0, 5 } + local pos_infos = r:get_tags_at { 0, 5 } assert.are.same(#pos_infos, 2) assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2') assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1') end) end) + + it('repeated patch_lines calls should not change the buffer content', function() + local lines = { + [[{ {]], + [[ bounds = {]], + [[ start1 = { 1, 1 },]], + [[ stop1 = { 4, 1 }]], + [[ },]], + [[ end_right_gravity = true,]], + [[ id = 1,]], + [[ ns_id = 623,]], + [[ ns_name = "my.renderer:91",]], + [[ right_gravity = false]], + [[ } }]], + [[]], + } + withbuf(lines, function() + local Buffer = require 'u.buffer' + R.Renderer.patch_lines(0, nil, lines) + assert.are.same(Buffer.current():all():lines(), lines) + + R.Renderer.patch_lines(0, lines, lines) + assert.are.same(Buffer.current():all():lines(), lines) + + R.Renderer.patch_lines(0, lines, lines) + assert.are.same(Buffer.current():all():lines(), lines) + end) + end) + + it('should fire text-changed events', function() + withbuf({}, function() + local Buffer = require 'u.buffer' + local buf = Buffer.current() + local r = R.Renderer.new(0) + local captured_changed_text = '' + r:render { + R.h('text', { + on_change = function(txt) captured_changed_text = txt end, + }, { + 'one\n', + 'two\n', + 'three\n', + }), + } + + vim.fn.setreg('"', 'bleh') + vim.cmd [[normal! ggVGp]] + -- For some reason, the autocmd does not fire in the busted environment. + -- We'll call the handler ourselves: + r:_on_text_changed() + + assert.are.same(buf:all():text(), 'bleh') + assert.are.same(captured_changed_text, 'bleh') + end) + end) end)