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: | 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 diff --git a/lua/remember.lua b/lua/remember.lua index b440e59..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"] @@ -32,50 +45,45 @@ 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 -function set_cursor_position() +local function restore_cursor() -- 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 - - for _, k in pairs(config["ignore_filetype"]) do - if bo.filetype == k then - return - end + if contains(config["ignore_buftype"], bo.buftype) or contains(config["ignore_filetype"], bo.filetype) 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 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 local cursor_position = api.nvim_buf_get_mark(0, '"') local row = cursor_position[1] - local col = cursor_position[2] + 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 <= api.nvim_buf_line_count(0) then + 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 api.nvim_buf_line_count(0) == fn.line("w$") or config["dont_center"] then + 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 api.nvim_buf_line_count(0) - row > ((fn.line("w$") - fn.line("w0")) / 2) - 1 then + elseif line_count - row > ((last_visible_row - fn.line("w0")) / 2) - 1 then api.nvim_win_set_cursor(0, cursor_position) cmd("norm! zz") @@ -84,19 +92,48 @@ function set_cursor_position() -- 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) + cmd("norm! " .. ctrl_e) 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 + if config["open_folds"] and fn.foldclosed(".") ~= -1 then cmd("norm! zvzz") end end +function M.set_cursor_position() + local success, err = pcall(restore_cursor) + if success then + return + end + + -- 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/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) diff --git a/tests/remember_spec.lua b/tests/remember_spec.lua index 9c7bff8..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,14 +50,24 @@ 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, + }, + }, } -- Mock the global vim object @@ -63,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) @@ -83,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() @@ -96,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() @@ -110,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) @@ -121,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) @@ -134,7 +157,7 @@ describe("remember.nvim", function() }) assert.has_no.errors(function() - _G.set_cursor_position() + remember.set_cursor_position() end) end) end) @@ -148,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 @@ -162,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) - _G.set_cursor_position() + 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) + + 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") @@ -245,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") @@ -310,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)