Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Luarocks
luarocks/

# macOS
.DS_Store

# Test artifacts
*.out
*.log
Expand Down
83 changes: 60 additions & 23 deletions lua/remember.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
-- For a copy, see <https://opensource.org/licenses/MIT>.
--

local g = vim.g
local bo = vim.bo
local fn = vim.fn
local api = vim.api
Expand All @@ -23,6 +22,20 @@ local config = {
dont_center = false,
}

local ctrl_e = api.nvim_replace_termcodes("<c-e>", 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"]
Expand All @@ -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")

Expand All @@ -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("<c-e>", 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,
})

Expand Down
6 changes: 4 additions & 2 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
110 changes: 110 additions & 0 deletions tests/headless_spec.lua
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading