diff --git a/README.md b/README.md index e0679e1..666e67a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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") diff --git a/lua/marksman/init.lua b/lua/marksman/init.lua index 1ebb8f9..81c03b7 100644 --- a/lua/marksman/init.lua +++ b/lua/marksman/init.lua @@ -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 diff --git a/tests/marksman_spec.lua b/tests/marksman_spec.lua index 7abf745..9937ca5 100644 --- a/tests/marksman_spec.lua +++ b/tests/marksman_spec.lua @@ -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) @@ -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")