From cc5f482b14a04aefd2929cfcb184e3cdf51cf569 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:39:04 +0100 Subject: [PATCH 1/9] catch errors in set_curso_poistion so the plugin doesn crash --- lua/remember.lua | 92 ++++++++++++++++++++---------------------------- 1 file changed, 38 insertions(+), 54 deletions(-) diff --git a/lua/remember.lua b/lua/remember.lua index b440e59..b894958 100644 --- a/lua/remember.lua +++ b/lua/remember.lua @@ -7,31 +7,27 @@ -- This work is licensed under the terms of the MIT license. -- For a copy, see . -- - local g = vim.g local bo = vim.bo local fn = vim.fn local api = vim.api local cmd = vim.cmd - local M = {} - local config = { - ignore_filetype = { "gitcommit", "gitrebase", "svn", "hgcommit", "dap-repl" }, ignore_buftype = { "quickfix", "nofile", "help" }, + ignore_filetype = { "gitcommit", "gitrebase", "svn", "hgcommit", "dap-repl" }, open_folds = true, dont_center = false, } - function M.setup(options) - if options["ignore_filetype"] then - config["ignore_filetype"] = options["ignore_filetype"] - end - if options["ignore_buftype"] then config["ignore_buftype"] = options["ignore_buftype"] end + if options["ignore_filetype"] then + config["ignore_filetype"] = options["ignore_filetype"] + end + if options["open_folds"] then config["open_folds"] = options["open_folds"] end @@ -40,64 +36,52 @@ function M.setup(options) config["dont_center"] = options["dont_center"] end end - function set_cursor_position() - -- Return if we have a buffer or filetype we want to ignore - for _, k in pairs(config["ignore_buftype"]) do - if bo.buftype == k then - return + local success, err = pcall(function() + for _, k in pairs(config["ignore_buftype"]) do + if bo.buftype == k then + return + end end - end - for _, k in pairs(config["ignore_filetype"]) do - if bo.filetype == k then - return + for _, k in pairs(config["ignore_filetype"]) do + if bo.filetype == k then + return + end end - end - - -- Return if the file doesn't exist, like a new and unsaved file - if fn.empty(fn.glob(fn.expand("%"))) ~= 0 then - return - end - local cursor_position = api.nvim_buf_get_mark(0, '"') - local row = cursor_position[1] - local col = cursor_position[2] - - -- If the saved row is less than the number of rows in the buffer, - -- then continue - if row > 0 and row <= api.nvim_buf_line_count(0) then - -- If the last row is visible within this window, like in a very short - -- file, or user requested us not centering the screen, just set the cursor - -- position to the saved position - if api.nvim_buf_line_count(0) == fn.line("w$") or config["dont_center"] then - api.nvim_win_set_cursor(0, cursor_position) + if fn.empty(fn.glob(fn.expand("%"))) ~= 0 then + return + end - -- If we're in the middle of the file, set the cursor position and center - -- the screen - elseif api.nvim_buf_line_count(0) - row > ((fn.line("w$") - fn.line("w0")) / 2) - 1 then - api.nvim_win_set_cursor(0, cursor_position) - cmd("norm! zz") + local cursor_position = api.nvim_buf_get_mark(0, '"') + local row = cursor_position[1] + local col = cursor_position[2] + + if row > 0 and row <= api.nvim_buf_line_count(0) then + if api.nvim_buf_line_count(0) == fn.line("w$") or config["dont_center"] then + api.nvim_win_set_cursor(0, cursor_position) + elseif api.nvim_buf_line_count(0) - row > ((fn.line("w$") - fn.line("w0")) / 2) - 1 then + api.nvim_win_set_cursor(0, cursor_position) + cmd("norm! zz") + else + api.nvim_win_set_cursor(0, cursor_position) + api.nvim_feedkeys(api.nvim_replace_termcodes("", true, false, true), "n", false) + end + end - -- If we're at the end of the screen, set the cursor position and move - -- the window up by one with C-e. This is to show that we are at the end - -- of the file. If we did "zz" half the screen would be blank. - else - api.nvim_win_set_cursor(0, cursor_position) - api.nvim_feedkeys(api.nvim_replace_termcodes("", true, false, true), "n", false) + if api.nvim_eval("foldclosed('.')") ~= -1 and config["open_folds"] then + cmd("norm! zvzz") end - end + end) - -- If the row is within a fold, make the row visible and recenter the screen - if api.nvim_eval("foldclosed('.')") ~= -1 and config["open_folds"] then - cmd("norm! zvzz") + if not success then + vim.notify("Error setting cursor position: " .. tostring(err), vim.log.levels.ERROR) end end - api.nvim_create_autocmd({ "BufWinEnter" }, { callback = function() set_cursor_position() end, }) - -return M +return M \ No newline at end of file From 72f076097ea613ce58168db390e1525225f5ff55 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:44:09 +0100 Subject: [PATCH 2/9] add tests to verify errors are handled gracefully --- tests/remember_spec.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/remember_spec.lua b/tests/remember_spec.lua index 9c7bff8..6431f69 100644 --- a/tests/remember_spec.lua +++ b/tests/remember_spec.lua @@ -46,12 +46,20 @@ describe("remember.nvim", function() mock_g = {} + mock_notify = spy.new(function() end) + mock_vim = { api = mock_api, fn = mock_fn, cmd = mock_cmd, bo = mock_bo, g = mock_g, + notify = mock_notify, + log = { + levels = { + ERROR = 1, + }, + }, } -- Mock the global vim object From 525676e6169d2dc62fb1e11c0cd44cdc01767db7 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Fri, 29 May 2026 09:04:01 +0100 Subject: [PATCH 3/9] restore inline comments and blank-line separators lost during pcall restructure --- lua/remember.lua | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lua/remember.lua b/lua/remember.lua index b894958..8f23e0a 100644 --- a/lua/remember.lua +++ b/lua/remember.lua @@ -12,13 +12,16 @@ local bo = vim.bo local fn = vim.fn local api = vim.api local cmd = vim.cmd + local M = {} + local config = { ignore_buftype = { "quickfix", "nofile", "help" }, ignore_filetype = { "gitcommit", "gitrebase", "svn", "hgcommit", "dap-repl" }, open_folds = true, dont_center = false, } + function M.setup(options) if options["ignore_buftype"] then config["ignore_buftype"] = options["ignore_buftype"] @@ -36,8 +39,10 @@ function M.setup(options) config["dont_center"] = options["dont_center"] end end + function set_cursor_position() local success, err = pcall(function() + -- Return if we have a buffer or filetype we want to ignore for _, k in pairs(config["ignore_buftype"]) do if bo.buftype == k then return @@ -50,6 +55,7 @@ function set_cursor_position() end end + -- Return if the file doesn't exist, like a new and unsaved file if fn.empty(fn.glob(fn.expand("%"))) ~= 0 then return end @@ -58,18 +64,31 @@ function set_cursor_position() local row = cursor_position[1] local col = cursor_position[2] + -- If the saved row is less than the number of rows in the buffer, + -- then continue if row > 0 and row <= api.nvim_buf_line_count(0) then + -- If the last row is visible within this window, like in a very short + -- file, or user requested us not centering the screen, just set the cursor + -- position to the saved position if api.nvim_buf_line_count(0) == fn.line("w$") or config["dont_center"] then api.nvim_win_set_cursor(0, cursor_position) + + -- If we're in the middle of the file, set the cursor position and center + -- the screen elseif api.nvim_buf_line_count(0) - row > ((fn.line("w$") - fn.line("w0")) / 2) - 1 then api.nvim_win_set_cursor(0, cursor_position) cmd("norm! zz") + + -- If we're at the end of the screen, set the cursor position and move + -- the window up by one with C-e. This is to show that we are at the end + -- of the file. If we did "zz" half the screen would be blank. else api.nvim_win_set_cursor(0, cursor_position) api.nvim_feedkeys(api.nvim_replace_termcodes("", true, false, true), "n", false) end end + -- If the row is within a fold, make the row visible and recenter the screen if api.nvim_eval("foldclosed('.')") ~= -1 and config["open_folds"] then cmd("norm! zvzz") end @@ -79,9 +98,11 @@ function set_cursor_position() vim.notify("Error setting cursor position: " .. tostring(err), vim.log.levels.ERROR) end end + api.nvim_create_autocmd({ "BufWinEnter" }, { callback = function() set_cursor_position() end, }) + return M \ No newline at end of file From 7c77b2a6bf04224166aebc0db3c3a6f7efddb7f3 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Fri, 29 May 2026 09:44:29 +0100 Subject: [PATCH 4/9] chore: remove unnecessary formatting changes from diff Restore original field ordering (ignore_filetype before ignore_buftype) in config table and setup function. Restore blank line after license block and trailing newline. These were pure formatting noise unrelated to the pcall fix. --- lua/remember.lua | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lua/remember.lua b/lua/remember.lua index 8f23e0a..c0febd6 100644 --- a/lua/remember.lua +++ b/lua/remember.lua @@ -7,6 +7,7 @@ -- This work is licensed under the terms of the MIT license. -- For a copy, see . -- + local g = vim.g local bo = vim.bo local fn = vim.fn @@ -16,21 +17,21 @@ local cmd = vim.cmd local M = {} local config = { - ignore_buftype = { "quickfix", "nofile", "help" }, ignore_filetype = { "gitcommit", "gitrebase", "svn", "hgcommit", "dap-repl" }, + ignore_buftype = { "quickfix", "nofile", "help" }, open_folds = true, dont_center = false, } function M.setup(options) - if options["ignore_buftype"] then - config["ignore_buftype"] = options["ignore_buftype"] - end - if options["ignore_filetype"] then config["ignore_filetype"] = options["ignore_filetype"] end + if options["ignore_buftype"] then + config["ignore_buftype"] = options["ignore_buftype"] + end + if options["open_folds"] then config["open_folds"] = options["open_folds"] end @@ -105,4 +106,4 @@ api.nvim_create_autocmd({ "BufWinEnter" }, { end, }) -return M \ No newline at end of file +return M From a417d73a707d78d082c12521a999ee4462a02885 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:07:21 +0100 Subject: [PATCH 5/9] gitignore: add .DS_Store --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ffa8014..5c9d284 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Luarocks luarocks/ +# macOS +.DS_Store + # Test artifacts *.out *.log From 53ea0040fb38342331571bb805876459042e1a3d Mon Sep 17 00:00:00 2001 From: Vladislav Doster Date: Sat, 26 Sep 2026 18:15:46 -0500 Subject: [PATCH 6/9] fix(setup): accept false for open_folds and dont_center setup() tested each option for truthiness. A user could not turn a default off. Signed-off-by: Vladislav Doster --- lua/remember.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/remember.lua b/lua/remember.lua index c0febd6..383678e 100644 --- a/lua/remember.lua +++ b/lua/remember.lua @@ -32,11 +32,11 @@ function M.setup(options) config["ignore_buftype"] = options["ignore_buftype"] end - if options["open_folds"] then + if options["open_folds"] ~= nil then config["open_folds"] = options["open_folds"] end - if options["dont_center"] then + if options["dont_center"] ~= nil then config["dont_center"] = options["dont_center"] end end From 46e88734867e17a0ad053aca5508b877772cecd6 Mon Sep 17 00:00:00 2001 From: Vladislav Doster Date: Sat, 26 Sep 2026 18:15:52 -0500 Subject: [PATCH 7/9] fix(remember): report restore errors once, off the autocmd Test the buffer name with filereadable() instead of glob(). A name like "[Scratch-1]" is a glob pattern and raised E944. Defer the notify call with vim.schedule and report each distinct error once. Rethrow a keyboard interrupt so the triggering command still aborts. Fixes #10 Signed-off-by: Vladislav Doster --- lua/remember.lua | 135 +++++++++++++++---------- tests/remember_spec.lua | 211 ++++++++++++++++++---------------------- 2 files changed, 177 insertions(+), 169 deletions(-) diff --git a/lua/remember.lua b/lua/remember.lua index 383678e..6da8df0 100644 --- a/lua/remember.lua +++ b/lua/remember.lua @@ -8,7 +8,6 @@ -- For a copy, see . -- -local g = vim.g local bo = vim.bo local fn = vim.fn local api = vim.api @@ -23,6 +22,20 @@ local config = { dont_center = false, } +local ctrl_e = api.nvim_replace_termcodes("", true, false, true) + +-- Errors reported so far, so a persistent failure is shown once, not on every buffer +local reported = {} + +local function contains(list, value) + for _, item in pairs(list) do + if item == value then + return true + end + end + return false +end + function M.setup(options) if options["ignore_filetype"] then config["ignore_filetype"] = options["ignore_filetype"] @@ -41,68 +54,86 @@ function M.setup(options) end end -function set_cursor_position() - local success, err = pcall(function() - -- Return if we have a buffer or filetype we want to ignore - for _, k in pairs(config["ignore_buftype"]) do - if bo.buftype == k then - return - end - end +local function restore_cursor() + -- Return if we have a buffer or filetype we want to ignore + if contains(config["ignore_buftype"], bo.buftype) or contains(config["ignore_filetype"], bo.filetype) then + return + end - for _, k in pairs(config["ignore_filetype"]) do - if bo.filetype == k then - return - end - end + -- Return if the file doesn't exist, like a new and unsaved file. The name is + -- tested literally because glob() would parse characters like "[" as a pattern + if fn.filereadable(api.nvim_buf_get_name(0)) == 0 then + return + end - -- Return if the file doesn't exist, like a new and unsaved file - if fn.empty(fn.glob(fn.expand("%"))) ~= 0 then - return + local cursor_position = api.nvim_buf_get_mark(0, '"') + local row = cursor_position[1] + local line_count = api.nvim_buf_line_count(0) + + -- If the saved row is less than the number of rows in the buffer, + -- then continue + if row > 0 and row <= line_count then + local last_visible_row = fn.line("w$") + + -- If the last row is visible within this window, like in a very short + -- file, or user requested us not centering the screen, just set the cursor + -- position to the saved position + if line_count == last_visible_row or config["dont_center"] then + api.nvim_win_set_cursor(0, cursor_position) + + -- If we're in the middle of the file, set the cursor position and center + -- the screen + elseif line_count - row > ((last_visible_row - fn.line("w0")) / 2) - 1 then + api.nvim_win_set_cursor(0, cursor_position) + cmd("norm! zz") + + -- If we're at the end of the screen, set the cursor position and move + -- the window up by one with C-e. This is to show that we are at the end + -- of the file. If we did "zz" half the screen would be blank. + else + api.nvim_win_set_cursor(0, cursor_position) + cmd("norm! " .. ctrl_e) end + end - local cursor_position = api.nvim_buf_get_mark(0, '"') - local row = cursor_position[1] - local col = cursor_position[2] - - -- If the saved row is less than the number of rows in the buffer, - -- then continue - if row > 0 and row <= api.nvim_buf_line_count(0) then - -- If the last row is visible within this window, like in a very short - -- file, or user requested us not centering the screen, just set the cursor - -- position to the saved position - if api.nvim_buf_line_count(0) == fn.line("w$") or config["dont_center"] then - api.nvim_win_set_cursor(0, cursor_position) - - -- If we're in the middle of the file, set the cursor position and center - -- the screen - elseif api.nvim_buf_line_count(0) - row > ((fn.line("w$") - fn.line("w0")) / 2) - 1 then - api.nvim_win_set_cursor(0, cursor_position) - cmd("norm! zz") - - -- If we're at the end of the screen, set the cursor position and move - -- the window up by one with C-e. This is to show that we are at the end - -- of the file. If we did "zz" half the screen would be blank. - else - api.nvim_win_set_cursor(0, cursor_position) - api.nvim_feedkeys(api.nvim_replace_termcodes("", true, false, true), "n", false) - end - end + -- If the row is within a fold, make the row visible and recenter the screen + if config["open_folds"] and fn.foldclosed(".") ~= -1 then + cmd("norm! zvzz") + end +end - -- If the row is within a fold, make the row visible and recenter the screen - if api.nvim_eval("foldclosed('.')") ~= -1 and config["open_folds"] then - cmd("norm! zvzz") - end - end) +function M.set_cursor_position() + local success, err = pcall(restore_cursor) + if success then + return + end - if not success then - vim.notify("Error setting cursor position: " .. tostring(err), vim.log.levels.ERROR) + -- A Ctrl-C must still abort the command that triggered BufWinEnter + local reason = tostring(err) + if reason:find("Keyboard interrupt", 1, true) then + error(err, 0) end + + if reported[reason] then + return + end + reported[reason] = true + + local name = api.nvim_buf_get_name(0) + if name == "" then + name = "[No Name]" + end + local message = "remember.nvim: error setting cursor position in " .. name .. ": " .. reason + -- Deferred because an error echoed inside the autocmd is rethrown into the + -- command that triggered BufWinEnter, which breaks other plugins + vim.schedule(function() + vim.notify(message, vim.log.levels.ERROR) + end) end api.nvim_create_autocmd({ "BufWinEnter" }, { callback = function() - set_cursor_position() + M.set_cursor_position() end, }) diff --git a/tests/remember_spec.lua b/tests/remember_spec.lua index 6431f69..e1cd580 100644 --- a/tests/remember_spec.lua +++ b/tests/remember_spec.lua @@ -12,24 +12,30 @@ describe("remember.nvim", function() local mock_fn local mock_cmd local mock_bo - local mock_g + local mock_notify + local scheduled + + local function run_scheduled() + for _, callback in ipairs(scheduled) do + callback() + end + scheduled = {} + end before_each(function() -- Reset mocks before each test mock_api = { nvim_buf_get_mark = spy.new(function() return {10, 5} end), + nvim_buf_get_name = spy.new(function() return "file.txt" end), nvim_buf_line_count = spy.new(function() return 100 end), nvim_win_set_cursor = spy.new(function() end), - nvim_feedkeys = spy.new(function() end), nvim_replace_termcodes = spy.new(function(str) return str end), - nvim_eval = spy.new(function() return -1 end), nvim_create_autocmd = spy.new(function() end), } mock_fn = { - empty = spy.new(function() return 0 end), - glob = spy.new(function() return "file.txt" end), - expand = spy.new(function(arg) return arg end), + filereadable = spy.new(function() return 1 end), + foldclosed = spy.new(function() return -1 end), line = spy.new(function(arg) if arg == "w$" then return 30 end if arg == "w0" then return 1 end @@ -44,17 +50,19 @@ describe("remember.nvim", function() filetype = "" } - mock_g = {} - mock_notify = spy.new(function() end) + scheduled = {} + mock_vim = { api = mock_api, fn = mock_fn, cmd = mock_cmd, bo = mock_bo, - g = mock_g, notify = mock_notify, + schedule = function(callback) + scheduled[#scheduled + 1] = callback + end, log = { levels = { ERROR = 1, @@ -71,6 +79,13 @@ describe("remember.nvim", function() end) after_each(function() + -- Errors are swallowed and reported later, so a test that expects none + -- must fail if a report is still queued + if #scheduled > 0 then + run_scheduled() + error("unexpected deferred error report: " .. tostring(mock_notify.calls[1].vals[1])) + end + -- Clean up package.loaded['remember'] = nil end) @@ -91,7 +106,7 @@ describe("remember.nvim", function() -- We can't directly access the config, but we can test the behavior -- by checking if the set_cursor_position function respects it mock_bo.filetype = "markdown" - _G.set_cursor_position() + remember.set_cursor_position() -- Cursor should not be set for ignored filetype assert.spy(mock_api.nvim_win_set_cursor).was_not_called() @@ -104,7 +119,7 @@ describe("remember.nvim", function() }) mock_bo.buftype = "terminal" - _G.set_cursor_position() + remember.set_cursor_position() -- Cursor should not be set for ignored buftype assert.spy(mock_api.nvim_win_set_cursor).was_not_called() @@ -118,7 +133,7 @@ describe("remember.nvim", function() -- This affects fold behavior in set_cursor_position -- We test this indirectly through the function behavior assert.has_no.errors(function() - _G.set_cursor_position() + remember.set_cursor_position() end) end) @@ -129,7 +144,7 @@ describe("remember.nvim", function() -- This affects centering behavior in set_cursor_position assert.has_no.errors(function() - _G.set_cursor_position() + remember.set_cursor_position() end) end) @@ -142,7 +157,7 @@ describe("remember.nvim", function() }) assert.has_no.errors(function() - _G.set_cursor_position() + remember.set_cursor_position() end) end) end) @@ -156,7 +171,7 @@ describe("remember.nvim", function() mock_api.nvim_win_set_cursor:clear() mock_bo.buftype = buftype - _G.set_cursor_position() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_not_called() end @@ -170,81 +185,95 @@ describe("remember.nvim", function() mock_api.nvim_win_set_cursor:clear() mock_bo.filetype = filetype - mock_bo.buftype = "" - _G.set_cursor_position() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_not_called() end end) it("should skip non-existent files", function() - mock_bo.buftype = "" - mock_bo.filetype = "" - mock_fn.empty = spy.new(function() return 1 end) -- File doesn't exist - mock_vim.fn = mock_fn + mock_fn.filereadable = spy.new(function() return 0 end) -- File doesn't exist - _G.set_cursor_position() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_not_called() end) - it("should restore cursor position for valid files", function() - mock_bo.buftype = "" - mock_bo.filetype = "" - mock_fn.empty = spy.new(function() return 0 end) -- File exists - mock_api.nvim_buf_get_mark = spy.new(function() return {10, 5} end) - mock_api.nvim_buf_line_count = spy.new(function() return 100 end) - mock_vim.fn = mock_fn - mock_vim.api = mock_api + it("should skip scratch buffers named with glob metacharacters", function() + -- Issue #10: undotree opened a window named "[Scratch-1]", which glob() + -- parses as a character class and rejects with E944 + mock_api.nvim_buf_get_name = spy.new(function() return "[Scratch-1]" end) + mock_fn.filereadable = spy.new(function() return 0 end) + + remember.set_cursor_position() + + -- The name must reach the file test literally, not as a pattern + assert.spy(mock_fn.filereadable).was_called_with("[Scratch-1]") + assert.spy(mock_api.nvim_win_set_cursor).was_not_called() + end) + + it("should report an error raised while restoring without propagating it", function() + mock_api.nvim_buf_get_mark = spy.new(function() + error("Vim:E5555: API call failed") + end) + + assert.has_no.errors(function() + remember.set_cursor_position() + end) + + -- Reporting must be deferred: an error echoed inside the autocmd is + -- rethrown into whichever command triggered BufWinEnter + assert.spy(mock_notify).was_not_called() + run_scheduled() + + assert.spy(mock_notify).was_called_with( + match.all_of(match.matches("E5555", 1, true), match.matches("file.txt", 1, true)), + mock_vim.log.levels.ERROR + ) + + -- The same failure on the next buffer is not reported again + remember.set_cursor_position() + run_scheduled() + assert.spy(mock_notify).was_called(1) + end) + + it("should rethrow an interrupt so the triggering command is aborted", function() + mock_api.nvim_buf_get_mark = spy.new(function() + error("Keyboard interrupt") + end) + + assert.has_error(function() + remember.set_cursor_position() + end, "Keyboard interrupt") + end) - _G.set_cursor_position() + it("should restore cursor position for valid files", function() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_called() assert.spy(mock_api.nvim_win_set_cursor).was_called_with(0, {10, 5}) end) it("should not restore cursor if saved row is 0", function() - mock_bo.buftype = "" - mock_bo.filetype = "" - mock_fn.empty = spy.new(function() return 0 end) mock_api.nvim_buf_get_mark = spy.new(function() return {0, 0} end) -- Row is 0 - mock_vim.fn = mock_fn - mock_vim.api = mock_api - _G.set_cursor_position() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_not_called() end) it("should not restore cursor if saved row exceeds buffer line count", function() - mock_bo.buftype = "" - mock_bo.filetype = "" - mock_fn.empty = spy.new(function() return 0 end) mock_api.nvim_buf_get_mark = spy.new(function() return {150, 0} end) -- Row > line count - mock_api.nvim_buf_line_count = spy.new(function() return 100 end) - mock_vim.fn = mock_fn - mock_vim.api = mock_api - _G.set_cursor_position() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_not_called() end) it("should center screen when in middle of file", function() - mock_bo.buftype = "" - mock_bo.filetype = "" - mock_fn.empty = spy.new(function() return 0 end) mock_api.nvim_buf_get_mark = spy.new(function() return {50, 5} end) - mock_api.nvim_buf_line_count = spy.new(function() return 100 end) - mock_fn.line = spy.new(function(arg) - if arg == "w$" then return 30 end - if arg == "w0" then return 1 end - return 1 - end) - mock_vim.fn = mock_fn - mock_vim.api = mock_api - _G.set_cursor_position() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_called() assert.spy(mock_cmd).was_called_with("norm! zz") @@ -253,63 +282,28 @@ describe("remember.nvim", function() it("should not center screen when dont_center is true", function() remember.setup({ dont_center = true }) - mock_bo.buftype = "" - mock_bo.filetype = "" - mock_fn.empty = spy.new(function() return 0 end) mock_api.nvim_buf_get_mark = spy.new(function() return {50, 5} end) - mock_api.nvim_buf_line_count = spy.new(function() return 100 end) - mock_fn.line = spy.new(function(arg) - if arg == "w$" then return 30 end - if arg == "w0" then return 1 end - return 1 - end) - mock_vim.fn = mock_fn - mock_vim.api = mock_api - _G.set_cursor_position() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_called() assert.spy(mock_cmd).was_not_called_with("norm! zz") end) it("should handle cursor position at end of file", function() - mock_bo.buftype = "" - mock_bo.filetype = "" - mock_fn.empty = spy.new(function() return 0 end) mock_api.nvim_buf_get_mark = spy.new(function() return {95, 0} end) - mock_api.nvim_buf_line_count = spy.new(function() return 100 end) - mock_fn.line = spy.new(function(arg) - if arg == "w$" then return 30 end - if arg == "w0" then return 1 end - return 1 - end) - mock_vim.fn = mock_fn - mock_vim.api = mock_api - _G.set_cursor_position() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_called() - assert.spy(mock_api.nvim_feedkeys).was_called() + -- The mocked nvim_replace_termcodes returns its argument unchanged + assert.spy(mock_cmd).was_called_with("norm! ") end) it("should open folds when cursor is in folded area", function() - remember.setup({ open_folds = true }) - - mock_bo.buftype = "" - mock_bo.filetype = "" - mock_fn.empty = spy.new(function() return 0 end) - mock_api.nvim_buf_get_mark = spy.new(function() return {10, 5} end) - mock_api.nvim_buf_line_count = spy.new(function() return 100 end) - mock_api.nvim_eval = spy.new(function() return 5 end) -- Folded - mock_fn.line = spy.new(function(arg) - if arg == "w$" then return 100 end - if arg == "w0" then return 1 end - return 1 - end) - mock_vim.fn = mock_fn - mock_vim.api = mock_api + mock_fn.foldclosed = spy.new(function() return 5 end) -- Folded - _G.set_cursor_position() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_called() assert.spy(mock_cmd).was_called_with("norm! zvzz") @@ -318,29 +312,12 @@ describe("remember.nvim", function() it("should not open folds when open_folds is false", function() remember.setup({ open_folds = false }) - mock_bo.buftype = "" - mock_bo.filetype = "" - mock_fn.empty = spy.new(function() return 0 end) - mock_api.nvim_buf_get_mark = spy.new(function() return {10, 5} end) - mock_api.nvim_buf_line_count = spy.new(function() return 100 end) - mock_api.nvim_eval = spy.new(function() return 5 end) -- Folded - mock_fn.line = spy.new(function(arg) - if arg == "w$" then return 100 end - if arg == "w0" then return 1 end - return 1 - end) - - -- Create a fresh cmd spy to track only calls in this test - local fresh_cmd_spy = spy.new(function() end) - mock_vim.cmd = fresh_cmd_spy - mock_vim.fn = mock_fn - mock_vim.api = mock_api + mock_fn.foldclosed = spy.new(function() return 5 end) -- Folded - _G.set_cursor_position() + remember.set_cursor_position() assert.spy(mock_api.nvim_win_set_cursor).was_called() - -- Should not call the fold open command in this test - assert.spy(fresh_cmd_spy).was_not_called() + assert.spy(mock_cmd).was_not_called_with("norm! zvzz") end) end) From cef8d9a6bd5bc37dc061912531a6264e95c49d07 Mon Sep 17 00:00:00 2001 From: Vladislav Doster Date: Sat, 26 Sep 2026 18:15:57 -0500 Subject: [PATCH 8/9] test: add headless nvim integration spec Drive a real "nvim --headless" to cover mark restore, the [Scratch-1] window from issue #10, and deferred error reporting. The spec is pending when nvim is not on PATH. Signed-off-by: Vladislav Doster --- tests/README.md | 6 ++- tests/headless_spec.lua | 110 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 tests/headless_spec.lua diff --git a/tests/README.md b/tests/README.md index 9ac23f0..d2f1ba4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -72,11 +72,13 @@ The coverage configuration is in `.luacov` and is set to only include `lua/` dir - `setup()` function - configuration options - `set_cursor_position()` function - cursor restoration logic - autocmd registration +- `tests/headless_spec.lua` - Integration tests that run the plugin in a real headless `nvim`. + They are marked pending when `nvim` is not on `PATH`; CI installs Neovim so they always run there. ## Writing New Tests -Tests are written using the [busted](https://olivinelabs.com/busted/) testing framework. The test file mocks the Neovim -API to allow unit testing without running Neovim. +Tests are written using the [busted](https://olivinelabs.com/busted/) testing framework. The unit test file mocks the +Neovim API to allow testing without running Neovim; the headless spec drives a real `nvim` process instead. Example test structure: diff --git a/tests/headless_spec.lua b/tests/headless_spec.lua new file mode 100644 index 0000000..7931a5d --- /dev/null +++ b/tests/headless_spec.lua @@ -0,0 +1,110 @@ +-- +-- Integration tests that drive a real headless Neovim. +-- Marked pending when nvim is not on PATH; CI installs nvim so they always run there. +-- + +local function capture(command) + local handle = assert(io.popen(command)) + local output = handle:read("*a") + handle:close() + return output +end + +-- Shared by every script: loads the plugin from the repo root, which busted +-- already requires as the working directory, and adds two helpers. +-- drain() runs every callback queued so far, including a deferred error +-- report; try() runs a command and prints whether it survived. +local prelude = [[ +package.path = "./lua/?.lua;" .. package.path +require("remember") + +local function drain() + local drained = false + vim.schedule(function() drained = true end) + vim.wait(1000, function() return drained end) +end + +local function temp_file(lines) + local path = vim.fn.tempname() + vim.fn.writefile(lines, path) + return path +end + +local function try(cmdline) + local ok, err = pcall(vim.cmd, cmdline) + drain() + print(("ok=%s err=%s"):format(tostring(ok), tostring(err))) +end +]] + +local postlude = [[ +print("messages=" .. vim.fn.execute("messages")) +]] + +-- Runs the script in a fresh nvim with the plugin loaded and returns what it printed +local function run_headless(lua_script) + local script_path = os.tmpname() + local file = assert(io.open(script_path, "w")) + file:write(prelude, lua_script, postlude) + file:close() + + local output = capture(("nvim --headless --clean -c 'luafile %s' -c 'qa!' 2>&1"):format(script_path)) + os.remove(script_path) + return output +end + +local has_nvim = capture("nvim --version 2>/dev/null"):find("NVIM", 1, true) ~= nil +local it_with_nvim = has_nvim and it or pending + +describe("remember.nvim in headless nvim", function() + it_with_nvim("restores the last cursor position of a file", function() + local output = run_headless([[ + local lines = {} + for i = 1, 200 do + lines[i] = "line " .. i + end + local path = temp_file(lines) + + -- Leave the file at line 120, keep the " mark through shada and drop the + -- buffer, which is what happens between two Neovim sessions + vim.o.shadafile = vim.fn.tempname() + vim.o.shada = "'100" + vim.cmd("edit " .. vim.fn.fnameescape(path)) + vim.api.nvim_win_set_cursor(0, { 120, 0 }) + vim.cmd("enew") + vim.cmd("wshada") + vim.cmd("bwipeout! " .. vim.fn.bufnr(path)) + vim.cmd("rshada") + + vim.cmd("edit " .. vim.fn.fnameescape(path)) + print("cursor=" .. vim.api.nvim_win_get_cursor(0)[1]) + ]]) + + assert.matches("cursor=120", output, 1, true) + end) + + it_with_nvim("does not break a plugin opening a window named [Scratch-1] (issue #10)", function() + local output = run_headless([[ + try("silent keepalt topleft vertical 30 new [Scratch-1]") + print("windows=" .. vim.fn.winnr("$")) + ]]) + + assert.matches("ok=true", output, 1, true) + assert.matches("windows=2", output, 1, true) + assert.not_matches("E944", output, 1, true) + end) + + it_with_nvim("reports an unexpected error without aborting the triggering command", function() + local output = run_headless([[ + local path = temp_file({ "line 1", "line 2" }) + vim.api.nvim_buf_get_mark = function() + error("injected failure") + end + + try("edit " .. vim.fn.fnameescape(path)) + ]]) + + assert.matches("ok=true", output, 1, true) + assert.matches("injected failure", output, 1, true) + end) +end) From 48652e745a288469b4dd110c49d201bb2d9a51c4 Mon Sep 17 00:00:00 2001 From: Vladislav Doster Date: Sat, 26 Sep 2026 18:15:57 -0500 Subject: [PATCH 9/9] ci(workflows): install neovim for headless spec Signed-off-by: Vladislav Doster --- .github/workflows/test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b8f42e..cc77286 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,10 +20,11 @@ jobs: - name: Checkout ${{github.repository}} uses: actions/checkout@v4 - - name: Install Lua and LuaRocks + - name: Install Lua, LuaRocks and Neovim run: | sudo apt-get update - sudo apt-get install -y lua5.1 liblua5.1-0-dev luarocks + sudo apt-get install -y lua5.1 liblua5.1-0-dev luarocks neovim + nvim --version - name: Install test dependencies run: |