Skip to content

Commit 7bfedc1

Browse files
committed
Implemented goto_next & goto_previous
1 parent f603aa4 commit 7bfedc1

File tree

3 files changed

+240
-2
lines changed

3 files changed

+240
-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** — Jump to the closest mark relative to your cursor, fallback to jump from mark 1 when no marks are in the current file
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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,87 @@ function M.goto_mark(name_or_index)
289289
end
290290
end
291291

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

tests/marksman_spec.lua

Lines changed: 156 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,150 @@ 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 second 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+
225+
-- Should jump to second mark because fallback picks first index
226+
assert.equals(test_file, vim.fn.expand("%:p"))
227+
assert.equals(2, vim.fn.line("."), "Should move to m2")
228+
end)
229+
230+
it("jumps to first mark when only 1 mark exists", function()
231+
-- file A with marks
232+
vim.cmd("edit " .. test_file)
233+
vim.fn.cursor(1, 1)
234+
marksman.add_mark("m1")
235+
236+
-- file B with zero marks
237+
vim.cmd("edit " .. test_file2)
238+
vim.fn.cursor(1, 1)
239+
240+
local result = marksman.goto_next()
241+
assert.is_true(result.success)
242+
243+
assert.equals(test_file, vim.fn.expand("%:p"))
244+
assert.equals(1, vim.fn.line("."), "Should move to m1")
245+
end)
246+
247+
it("returns error when no marks exist", function()
248+
local result = marksman.goto_next()
249+
assert.is_false(result.success)
250+
assert.is_string(result.message)
251+
end)
252+
253+
it("jumps to previous mark with wrap-around", function()
254+
vim.cmd("edit " .. test_file)
255+
256+
vim.fn.cursor(1, 1)
257+
marksman.add_mark("m1")
258+
259+
vim.fn.cursor(2, 1)
260+
marksman.add_mark("m2")
261+
262+
vim.fn.cursor(3, 1)
263+
marksman.add_mark("m3")
264+
265+
-- start at m1 -> previous should wrap to m3
266+
vim.fn.cursor(1, 1)
267+
local result = marksman.goto_previous()
268+
assert.is_true(result.success)
269+
assert.equals(3, vim.fn.line("."), "Should wrap from m1 to m3")
270+
271+
-- now at m3 -> previous should be m2
272+
result = marksman.goto_previous()
273+
assert.is_true(result.success)
274+
assert.equals(2, vim.fn.line("."), "Should jump from m3 to m2")
275+
276+
-- now at m2 -> previous should be m1
277+
result = marksman.goto_previous()
278+
assert.is_true(result.success)
279+
assert.equals(1, vim.fn.line("."), "Should jump from m2 to m1")
280+
end)
281+
128282
it("deletes marks", function()
129283
vim.cmd("edit " .. test_file)
130284
marksman.add_mark("delete_me")

0 commit comments

Comments
 (0)