From 7bfedc1ced993bede2fb298cee655a1cbcfb9fe1 Mon Sep 17 00:00:00 2001 From: Tobias Laross Date: Tue, 4 Nov 2025 07:37:33 +0100 Subject: [PATCH 1/2] Implemented goto_next & goto_previous --- README.md | 3 + lua/marksman/init.lua | 81 ++++++++++++++++++++ tests/marksman_spec.lua | 158 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 240 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e0679e1..90ff499 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** — 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 @@ -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..a9e1771 100644 --- a/lua/marksman/init.lua +++ b/lua/marksman/init.lua @@ -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 diff --git a/tests/marksman_spec.lua b/tests/marksman_spec.lua index 7abf745..69b5e96 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,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") From 2a067eb9d5edcba58a3d79b75945810da3b346bd Mon Sep 17 00:00:00 2001 From: Tobias Laross Date: Tue, 4 Nov 2025 09:20:10 +0100 Subject: [PATCH 2/2] Updated fallback logic --- README.md | 2 +- lua/marksman/init.lua | 50 ++++++++++++++++++++++++----------------- tests/marksman_spec.lua | 24 ++++++++++++++++---- 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 90ff499..666e67a 100644 --- a/README.md +++ b/README.md @@ -20,7 +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 +- **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 diff --git a/lua/marksman/init.lua b/lua/marksman/init.lua index a9e1771..81c03b7 100644 --- a/lua/marksman/init.lua +++ b/lua/marksman/init.lua @@ -289,16 +289,15 @@ function M.goto_mark(name_or_index) end end --- Finds the mark index closest to the current cursor position, using 1 as fallback. +-- Finds the mark index closest to the current cursor position. -- 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 +-- 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 count = #mark_names - - if count == 0 then + local total_marks = #mark_names + if total_marks == 0 then return nil, nil, "No marks available" end @@ -306,31 +305,31 @@ local function get_current_mark_index(storage_module) local current_file = vim.fn.expand("%:p") local current_line = vim.fn.line(".") - local best_index = nil - local best_distance = nil + 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, count, nil + return index, total_marks, 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 + if not shortest_distance or distance < shortest_distance then + nearest_index = index + shortest_distance = distance end end end - return best_index or 1, count, nil + 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, fall back to first index 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() @@ -340,11 +339,15 @@ function M.goto_next() end local current_index, count, err = get_current_mark_index(storage_module) - if not current_index then + if err then return { success = false, message = err } end - - local next_index = (current_index % count) + 1 + 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 @@ -352,7 +355,7 @@ end ---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. +---• 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() @@ -362,11 +365,16 @@ function M.goto_previous() end local current_index, count, err = get_current_mark_index(storage_module) - if not current_index then + if err then return { success = false, message = err } end - local previous_index = ((current_index - 2) % count) + 1 + 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 diff --git a/tests/marksman_spec.lua b/tests/marksman_spec.lua index 69b5e96..9937ca5 100644 --- a/tests/marksman_spec.lua +++ b/tests/marksman_spec.lua @@ -207,7 +207,7 @@ describe("marksman.nvim", function() assert.equals(1, vim.fn.line("."), "Should move to b1") end) - it("jumps to second mark when current file has no marks", function() + 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) @@ -221,10 +221,8 @@ describe("marksman.nvim", function() 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") + assert.equals(1, vim.fn.line("."), "Should move to m1") end) it("jumps to first mark when only 1 mark exists", function() @@ -279,6 +277,24 @@ describe("marksman.nvim", function() 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")