Skip to content

Commit 37fd8f6

Browse files
authored
feat: goto_next & goto_previous (#21)
Implements goto_next() and goto_previous() navigation. These functions allow programmatic navigation between marks without relying on the UI picker or static keybindings. They use the closest mark within the current file as the current reference position and apply wrap-around navigation when reaching the start/end of the list. This enables plugin authors and advanced users to build custom navigation workflows and integrate marksman’s mark navigation into other tools.
1 parent f603aa4 commit 37fd8f6

File tree

3 files changed

+264
-2
lines changed

3 files changed

+264
-2
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Vim's built-in marks are great, but they're global and get messy fast. Marksman
2020
- **Persistent storage** - Your marks survive Neovim restarts with automatic backup
2121
- **Smart naming** - Context-aware auto-generation using Treesitter and pattern matching
2222
- **Quick access** - Jump to marks with single keys or interactive UI
23+
- **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.
2324
- **Enhanced search** - Find marks by name, file path, or content with real-time filtering
2425
- **Mark reordering** - Move marks up/down to organize them as needed
2526
- **Multiple integrations** - Works with Telescope, Snacks.nvim, and more
@@ -238,6 +239,8 @@ local marksman = require("marksman")
238239
-- Basic operations return { success, message, ... }
239240
local result = marksman.add_mark("my_mark")
240241
local result = marksman.goto_mark("my_mark") -- or goto_mark(1) for index
242+
local result = marksman.goto_next()
243+
local result = marksman.goto_previous()
241244
local result = marksman.delete_mark("my_mark")
242245
local result = marksman.rename_mark("old", "new")
243246

lua/marksman/init.lua

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,95 @@ function M.goto_mark(name_or_index)
289289
end
290290
end
291291

292+
-- Finds the mark index closest to the current cursor position.
293+
-- Returns:
294+
-- current_index (number | nil): exact or closest index in current file, or nil if none in file
295+
-- total_marks (number | nil): total number of marks, nil if no marks exist
296+
-- error (string | nil): error message only when no marks exist at all
297+
local function get_current_mark_index(storage_module)
298+
local mark_names = storage_module.get_mark_names()
299+
local total_marks = #mark_names
300+
if total_marks == 0 then
301+
return nil, nil, "No marks available"
302+
end
303+
304+
local marks = storage_module.get_marks()
305+
local current_file = vim.fn.expand("%:p")
306+
local current_line = vim.fn.line(".")
307+
308+
local nearest_index = nil
309+
local shortest_distance = nil
310+
311+
for index, mark_name in ipairs(mark_names) do
312+
local mark = marks[mark_name]
313+
if mark.file == current_file then
314+
if mark.line == current_line then
315+
return index, total_marks, nil
316+
end
317+
local distance = math.abs(mark.line - current_line)
318+
if not shortest_distance or distance < shortest_distance then
319+
nearest_index = index
320+
shortest_distance = distance
321+
end
322+
end
323+
end
324+
325+
return nearest_index, total_marks, nil
326+
end
327+
328+
---Jump to the next mark.
329+
---Navigation is context-aware:
330+
---• If the cursor is on a mark, jump relative to it.
331+
---• If the cursor is not on a mark, select the nearest mark in the same file before jumping.
332+
---• If the current file has no marks, jump to the first index.
333+
---Wraps when reaching the last mark.
334+
---@return table result Result with success and optional message
335+
function M.goto_next()
336+
local storage_module = get_storage()
337+
if not storage_module then
338+
return { success = false, message = "Failed to load storage module" }
339+
end
340+
341+
local current_index, count, err = get_current_mark_index(storage_module)
342+
if err then
343+
return { success = false, message = err }
344+
end
345+
local next_index
346+
if not current_index then
347+
next_index = 1
348+
else
349+
next_index = (current_index % count) + 1
350+
end
351+
return M.goto_mark(next_index)
352+
end
353+
354+
---Jump to the previous mark.
355+
---Navigation is context-aware:
356+
---• If the cursor is on a mark, jump relative to it.
357+
---• If the cursor is not on a mark, select the nearest mark in the same file before jumping.
358+
---• If the current file has no marks, jump to the last index.
359+
---Wraps when reaching the last mark.
360+
---@return table result Result with success and optional message
361+
function M.goto_previous()
362+
local storage_module = get_storage()
363+
if not storage_module then
364+
return { success = false, message = "Failed to load storage module" }
365+
end
366+
367+
local current_index, count, err = get_current_mark_index(storage_module)
368+
if err then
369+
return { success = false, message = err }
370+
end
371+
372+
local previous_index
373+
if not current_index and count then
374+
previous_index = count
375+
else
376+
previous_index = ((current_index - 2) % count) + 1
377+
end
378+
return M.goto_mark(previous_index)
379+
end
380+
292381
---Delete a mark by name
293382
---@param name string Mark name to delete
294383
---@return table result Result with success and message

tests/marksman_spec.lua

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,26 @@ describe("marksman.nvim", function()
5555
describe("mark operations", function()
5656
local marksman = require("marksman")
5757
local test_file
58+
local test_file2
5859

5960
before_each(function()
6061
clear_marks()
6162
local test_dir = vim.env.MARKSMAN_TEST_DIR or vim.fn.tempname()
6263
test_file = test_dir .. "/test.lua"
63-
vim.fn.mkdir(vim.fn.fnamemodify(test_file, ":h"), "p")
64+
test_file2 = test_dir .. "/test2.lua"
6465

6566
setup_buffer_with_file(test_file, {
6667
"local function test()",
67-
" return true",
68+
" local value = false",
69+
" if value then",
70+
" return true",
71+
" end",
72+
" return false",
73+
})
74+
75+
setup_buffer_with_file(test_file2, {
76+
"local function test2()",
77+
" return false",
6878
"end",
6979
})
7080
end)
@@ -125,6 +135,166 @@ describe("marksman.nvim", function()
125135
assert.equals(3, vim.fn.col("."))
126136
end)
127137

138+
it("jumps to next mark with wrap-around", function()
139+
-- open the test file and place marks on lines 1, 2, and 3
140+
vim.cmd("edit " .. test_file)
141+
142+
vim.fn.cursor(1, 1)
143+
marksman.add_mark("m1")
144+
145+
vim.fn.cursor(2, 1)
146+
marksman.add_mark("m2")
147+
148+
vim.fn.cursor(3, 1)
149+
marksman.add_mark("m3")
150+
151+
-- start at m1
152+
vim.fn.cursor(1, 1)
153+
local result = marksman.goto_next()
154+
assert.is_true(result.success)
155+
assert.equals(2, vim.fn.line("."), "Should jump from m1 to m2")
156+
157+
-- now at m2 -> next should be m3
158+
result = marksman.goto_next()
159+
assert.is_true(result.success)
160+
assert.equals(3, vim.fn.line("."), "Should jump from m2 to m3")
161+
162+
-- now at m3 -> next should wrap back to m1
163+
result = marksman.goto_next()
164+
assert.is_true(result.success)
165+
assert.equals(1, vim.fn.line("."), "Should wrap from m3 to m1")
166+
end)
167+
168+
it("jumps to next mark when cursor is between marks", function()
169+
vim.cmd("edit " .. test_file)
170+
171+
vim.fn.cursor(1, 1)
172+
marksman.add_mark("m1")
173+
174+
vim.fn.cursor(4, 1)
175+
marksman.add_mark("m2")
176+
177+
vim.fn.cursor(5, 1)
178+
marksman.add_mark("m3")
179+
180+
-- cursor on line 3 → distance to m1 = 2, m2 = 1 → choose m2 as current index
181+
vim.fn.cursor(3, 1)
182+
183+
local result = marksman.goto_next()
184+
assert.is_true(result.success)
185+
assert.equals(5, vim.fn.line("."), "Should jump from m2 to m3")
186+
end)
187+
188+
it("jumps to next in another file", function()
189+
-- file A
190+
vim.cmd("edit " .. test_file)
191+
vim.fn.cursor(1, 1)
192+
marksman.add_mark("a1")
193+
194+
-- file B
195+
vim.cmd("edit " .. test_file2)
196+
vim.fn.cursor(1, 1)
197+
marksman.add_mark("b1")
198+
vim.fn.cursor(3, 1)
199+
marksman.add_mark("b2")
200+
201+
vim.cmd("edit " .. test_file)
202+
vim.fn.cursor(1, 1) -- at a1
203+
local result = marksman.goto_next()
204+
205+
assert.is_true(result.success)
206+
assert.equals(test_file2, vim.fn.expand("%:p"), "Should move to next mark in file2")
207+
assert.equals(1, vim.fn.line("."), "Should move to b1")
208+
end)
209+
210+
it("jumps to first mark when current file has no marks", function()
211+
-- file A with marks
212+
vim.cmd("edit " .. test_file)
213+
vim.fn.cursor(1, 1)
214+
marksman.add_mark("m1")
215+
vim.fn.cursor(2, 1)
216+
marksman.add_mark("m2")
217+
218+
-- file B with zero marks
219+
vim.cmd("edit " .. test_file2)
220+
vim.fn.cursor(1, 1)
221+
222+
local result = marksman.goto_next()
223+
assert.is_true(result.success)
224+
assert.equals(test_file, vim.fn.expand("%:p"))
225+
assert.equals(1, vim.fn.line("."), "Should move to m1")
226+
end)
227+
228+
it("jumps to first mark when only 1 mark exists", function()
229+
-- file A with marks
230+
vim.cmd("edit " .. test_file)
231+
vim.fn.cursor(1, 1)
232+
marksman.add_mark("m1")
233+
234+
-- file B with zero marks
235+
vim.cmd("edit " .. test_file2)
236+
vim.fn.cursor(1, 1)
237+
238+
local result = marksman.goto_next()
239+
assert.is_true(result.success)
240+
241+
assert.equals(test_file, vim.fn.expand("%:p"))
242+
assert.equals(1, vim.fn.line("."), "Should move to m1")
243+
end)
244+
245+
it("returns error when no marks exist", function()
246+
local result = marksman.goto_next()
247+
assert.is_false(result.success)
248+
assert.is_string(result.message)
249+
end)
250+
251+
it("jumps to previous mark with wrap-around", function()
252+
vim.cmd("edit " .. test_file)
253+
254+
vim.fn.cursor(1, 1)
255+
marksman.add_mark("m1")
256+
257+
vim.fn.cursor(2, 1)
258+
marksman.add_mark("m2")
259+
260+
vim.fn.cursor(3, 1)
261+
marksman.add_mark("m3")
262+
263+
-- start at m1 -> previous should wrap to m3
264+
vim.fn.cursor(1, 1)
265+
local result = marksman.goto_previous()
266+
assert.is_true(result.success)
267+
assert.equals(3, vim.fn.line("."), "Should wrap from m1 to m3")
268+
269+
-- now at m3 -> previous should be m2
270+
result = marksman.goto_previous()
271+
assert.is_true(result.success)
272+
assert.equals(2, vim.fn.line("."), "Should jump from m3 to m2")
273+
274+
-- now at m2 -> previous should be m1
275+
result = marksman.goto_previous()
276+
assert.is_true(result.success)
277+
assert.equals(1, vim.fn.line("."), "Should jump from m2 to m1")
278+
end)
279+
280+
it("jumps to last mark when current file has no marks", function()
281+
-- file A with marks
282+
vim.cmd("edit " .. test_file)
283+
vim.fn.cursor(1, 1)
284+
marksman.add_mark("m1")
285+
vim.fn.cursor(2, 1)
286+
marksman.add_mark("m2")
287+
288+
-- file B with zero marks
289+
vim.cmd("edit " .. test_file2)
290+
vim.fn.cursor(1, 1)
291+
292+
local result = marksman.goto_previous()
293+
assert.is_true(result.success)
294+
assert.equals(test_file, vim.fn.expand("%:p"))
295+
assert.equals(2, vim.fn.line("."), "Should move to m2")
296+
end)
297+
128298
it("deletes marks", function()
129299
vim.cmd("edit " .. test_file)
130300
marksman.add_mark("delete_me")

0 commit comments

Comments
 (0)