Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Vim's built-in marks are great, but they're global and get messy fast. Marksman
- **Persistent storage** - Your marks survive Neovim restarts with automatic backup
- **Smart naming** - Context-aware auto-generation using Treesitter and pattern matching
- **Quick access** - Jump to marks with single keys or interactive UI
- **Sequential navigation** — Jump to the closest mark relative to your cursor, fallback to jump from mark 1 when no marks are in the current file
- **Enhanced search** - Find marks by name, file path, or content with real-time filtering
- **Mark reordering** - Move marks up/down to organize them as needed
- **Multiple integrations** - Works with Telescope, Snacks.nvim, and more
Expand Down Expand Up @@ -238,6 +239,8 @@ local marksman = require("marksman")
-- Basic operations return { success, message, ... }
local result = marksman.add_mark("my_mark")
local result = marksman.goto_mark("my_mark") -- or goto_mark(1) for index
local result = marksman.goto_next()
local result = marksman.goto_previous()
local result = marksman.delete_mark("my_mark")
local result = marksman.rename_mark("old", "new")

Expand Down
81 changes: 81 additions & 0 deletions lua/marksman/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,87 @@ function M.goto_mark(name_or_index)
end
end

-- Finds the mark index closest to the current cursor position, using 1 as fallback.
-- Returns:
-- index (number | nil): the resolved mark index, or nil if no marks exist
-- total (number | nil): total number of marks when successful, nil on failure
-- error (string | nil): error message when no marks are available
local function get_current_mark_index(storage_module)
local mark_names = storage_module.get_mark_names()
local count = #mark_names

if count == 0 then
return nil, nil, "No marks available"
end

local marks = storage_module.get_marks()
local current_file = vim.fn.expand("%:p")
local current_line = vim.fn.line(".")

local best_index = nil
local best_distance = nil

for index, mark_name in ipairs(mark_names) do
local mark = marks[mark_name]
if mark.file == current_file then
if mark.line == current_line then
return index, count, nil
end
local distance = math.abs(mark.line - current_line)
if not best_distance or distance < best_distance then
best_index = index
best_distance = distance
end
end
end

return best_index or 1, count, nil
end

---Jump to the next mark.
---Navigation is context-aware:
---• If the cursor is on a mark, jump relative to it.
---• If the cursor is not on a mark, select the nearest mark in the same file before jumping.
---• If the current file has no marks, fall back to first index before jumping.
---Wraps when reaching the last mark.
---@return table result Result with success and optional message
function M.goto_next()
local storage_module = get_storage()
if not storage_module then
return { success = false, message = "Failed to load storage module" }
end

local current_index, count, err = get_current_mark_index(storage_module)
if not current_index then
return { success = false, message = err }
end

local next_index = (current_index % count) + 1
return M.goto_mark(next_index)
end

---Jump to the previous mark.
---Navigation is context-aware:
---• If the cursor is on a mark, jump relative to it.
---• If the cursor is not on a mark, select the nearest mark in the same file before jumping.
---• If the current file has no marks, fall back to first index before jumping.
---Wraps when reaching the last mark.
---@return table result Result with success and optional message
function M.goto_previous()
local storage_module = get_storage()
if not storage_module then
return { success = false, message = "Failed to load storage module" }
end

local current_index, count, err = get_current_mark_index(storage_module)
if not current_index then
return { success = false, message = err }
end

local previous_index = ((current_index - 2) % count) + 1
return M.goto_mark(previous_index)
end

---Delete a mark by name
---@param name string Mark name to delete
---@return table result Result with success and message
Expand Down
158 changes: 156 additions & 2 deletions tests/marksman_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,26 @@ describe("marksman.nvim", function()
describe("mark operations", function()
local marksman = require("marksman")
local test_file
local test_file2

before_each(function()
clear_marks()
local test_dir = vim.env.MARKSMAN_TEST_DIR or vim.fn.tempname()
test_file = test_dir .. "/test.lua"
vim.fn.mkdir(vim.fn.fnamemodify(test_file, ":h"), "p")
test_file2 = test_dir .. "/test2.lua"

setup_buffer_with_file(test_file, {
"local function test()",
" return true",
" local value = false",
" if value then",
" return true",
" end",
" return false",
})

setup_buffer_with_file(test_file2, {
"local function test2()",
" return false",
"end",
})
end)
Expand Down Expand Up @@ -125,6 +135,150 @@ describe("marksman.nvim", function()
assert.equals(3, vim.fn.col("."))
end)

it("jumps to next mark with wrap-around", function()
-- open the test file and place marks on lines 1, 2, and 3
vim.cmd("edit " .. test_file)

vim.fn.cursor(1, 1)
marksman.add_mark("m1")

vim.fn.cursor(2, 1)
marksman.add_mark("m2")

vim.fn.cursor(3, 1)
marksman.add_mark("m3")

-- start at m1
vim.fn.cursor(1, 1)
local result = marksman.goto_next()
assert.is_true(result.success)
assert.equals(2, vim.fn.line("."), "Should jump from m1 to m2")

-- now at m2 -> next should be m3
result = marksman.goto_next()
assert.is_true(result.success)
assert.equals(3, vim.fn.line("."), "Should jump from m2 to m3")

-- now at m3 -> next should wrap back to m1
result = marksman.goto_next()
assert.is_true(result.success)
assert.equals(1, vim.fn.line("."), "Should wrap from m3 to m1")
end)

it("jumps to next mark when cursor is between marks", function()
vim.cmd("edit " .. test_file)

vim.fn.cursor(1, 1)
marksman.add_mark("m1")

vim.fn.cursor(4, 1)
marksman.add_mark("m2")

vim.fn.cursor(5, 1)
marksman.add_mark("m3")

-- cursor on line 3 → distance to m1 = 2, m2 = 1 → choose m2 as current index
vim.fn.cursor(3, 1)

local result = marksman.goto_next()
assert.is_true(result.success)
assert.equals(5, vim.fn.line("."), "Should jump from m2 to m3")
end)

it("jumps to next in another file", function()
-- file A
vim.cmd("edit " .. test_file)
vim.fn.cursor(1, 1)
marksman.add_mark("a1")

-- file B
vim.cmd("edit " .. test_file2)
vim.fn.cursor(1, 1)
marksman.add_mark("b1")
vim.fn.cursor(3, 1)
marksman.add_mark("b2")

vim.cmd("edit " .. test_file)
vim.fn.cursor(1, 1) -- at a1
local result = marksman.goto_next()

assert.is_true(result.success)
assert.equals(test_file2, vim.fn.expand("%:p"), "Should move to next mark in file2")
assert.equals(1, vim.fn.line("."), "Should move to b1")
end)

it("jumps to second mark when current file has no marks", function()
-- file A with marks
vim.cmd("edit " .. test_file)
vim.fn.cursor(1, 1)
marksman.add_mark("m1")
vim.fn.cursor(2, 1)
marksman.add_mark("m2")

-- file B with zero marks
vim.cmd("edit " .. test_file2)
vim.fn.cursor(1, 1)

local result = marksman.goto_next()
assert.is_true(result.success)

-- Should jump to second mark because fallback picks first index
assert.equals(test_file, vim.fn.expand("%:p"))
assert.equals(2, vim.fn.line("."), "Should move to m2")
end)

it("jumps to first mark when only 1 mark exists", function()
-- file A with marks
vim.cmd("edit " .. test_file)
vim.fn.cursor(1, 1)
marksman.add_mark("m1")

-- file B with zero marks
vim.cmd("edit " .. test_file2)
vim.fn.cursor(1, 1)

local result = marksman.goto_next()
assert.is_true(result.success)

assert.equals(test_file, vim.fn.expand("%:p"))
assert.equals(1, vim.fn.line("."), "Should move to m1")
end)

it("returns error when no marks exist", function()
local result = marksman.goto_next()
assert.is_false(result.success)
assert.is_string(result.message)
end)

it("jumps to previous mark with wrap-around", function()
vim.cmd("edit " .. test_file)

vim.fn.cursor(1, 1)
marksman.add_mark("m1")

vim.fn.cursor(2, 1)
marksman.add_mark("m2")

vim.fn.cursor(3, 1)
marksman.add_mark("m3")

-- start at m1 -> previous should wrap to m3
vim.fn.cursor(1, 1)
local result = marksman.goto_previous()
assert.is_true(result.success)
assert.equals(3, vim.fn.line("."), "Should wrap from m1 to m3")

-- now at m3 -> previous should be m2
result = marksman.goto_previous()
assert.is_true(result.success)
assert.equals(2, vim.fn.line("."), "Should jump from m3 to m2")

-- now at m2 -> previous should be m1
result = marksman.goto_previous()
assert.is_true(result.success)
assert.equals(1, vim.fn.line("."), "Should jump from m2 to m1")
end)

it("deletes marks", function()
vim.cmd("edit " .. test_file)
marksman.add_mark("delete_me")
Expand Down