Skip to content
Merged
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
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** — Jumps relative to the nearest mark in the current file. If the current file has no marks, next jumps to the first mark and previous jumps to the last.
- **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
89 changes: 89 additions & 0 deletions lua/marksman/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,95 @@ function M.goto_mark(name_or_index)
end
end

-- Finds the mark index closest to the current cursor position.
-- Returns:
-- current_index (number | nil): exact or closest index in current file, or nil if none in file
-- total_marks (number | nil): total number of marks, nil if no marks exist
-- error (string | nil): error message only when no marks exist at all
local function get_current_mark_index(storage_module)
local mark_names = storage_module.get_mark_names()
local total_marks = #mark_names
if total_marks == 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 nearest_index = nil
local shortest_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, total_marks, nil
end
local distance = math.abs(mark.line - current_line)
if not shortest_distance or distance < shortest_distance then
nearest_index = index
shortest_distance = distance
end
end
end

return nearest_index, total_marks, 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, jump to the first index.
---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 err then
return { success = false, message = err }
end
local next_index
if not current_index then
next_index = 1
else
next_index = (current_index % count) + 1
end
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, jump to the last index.
---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 err then
return { success = false, message = err }
end

local previous_index
if not current_index and count then
previous_index = count
else
previous_index = ((current_index - 2) % count) + 1
end
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
174 changes: 172 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,166 @@ 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 first 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)
assert.equals(test_file, vim.fn.expand("%:p"))
assert.equals(1, vim.fn.line("."), "Should move to m1")
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("jumps to last 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_previous()
assert.is_true(result.success)
assert.equals(test_file, vim.fn.expand("%:p"))
assert.equals(2, vim.fn.line("."), "Should move to m2")
end)

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