Skip to content

Commit 4534f28

Browse files
authored
feat: Repeat delete/change operations using number prefix. (#373)
* feat: Repeat delete. * feat: Repeat change. * fix: Normalize delimiters in change operations.
1 parent 6c54643 commit 4534f28

File tree

6 files changed

+171
-74
lines changed

6 files changed

+171
-74
lines changed

lua/nvim-surround/cache.lua

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@ local M = {}
44

55
---@type { delimiters: string[][]|nil, line_mode: boolean, count: integer }
66
M.normal = {}
7-
---@type { char: string }
7+
---@type { char: string, count: integer }
88
M.delete = {}
9-
---@type { del_char: string, add_delimiters: add_func, line_mode: boolean }
9+
---@type { del_char: string, add_delimiters: add_func, line_mode: boolean, count: integer }
1010
M.change = {}
1111

1212
-- Sets the callback function for dot-repeating.
1313
---@param func_name string A string representing the callback function's name.
1414
M.set_callback = function(func_name)
1515
vim.go.operatorfunc = "v:lua.require'nvim-surround.utils'.NOOP"
16-
vim.cmd.normal({ "g@l", bang = true })
16+
vim.cmd.normal({ [1] = "g@l", bang = true })
1717
vim.go.operatorfunc = func_name
1818
end
1919

lua/nvim-surround/config.lua

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -355,18 +355,17 @@ end
355355
---@return delimiter_pair|nil @A pair of delimiters for the given input, or nil if not applicable.
356356
---@nodiscard
357357
M.get_delimiters = function(char, line_mode)
358+
local utils = require("nvim-surround.utils")
359+
358360
char = M.get_alias(char)
359361
-- Get the delimiters, using invalid_key_behavior if the add function is undefined for the character
360-
local delimiters = M.get_add(char)(char)
361-
if delimiters == nil then
362+
local raw_delimiters = M.get_add(char)(char)
363+
if raw_delimiters == nil then
362364
return nil
363365
end
364-
local lhs = type(delimiters[1]) == "string" and { delimiters[1] } or delimiters[1]
365-
local rhs = type(delimiters[2]) == "string" and { delimiters[2] } or delimiters[2]
366-
-- These casts are needed because LuaLS doesn't narrow types in ternaries properly
367-
-- https://github.com/LuaLS/lua-language-server/issues/2233
368-
---@cast lhs string[]
369-
---@cast rhs string[]
366+
local delimiters = utils.normalize_delimiters(raw_delimiters)
367+
local lhs = delimiters[1]
368+
local rhs = delimiters[2]
370369

371370
-- Add new lines if the addition is done line-wise
372371
if line_mode then

lua/nvim-surround/init.lua

Lines changed: 81 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ M.delete_surround = function(args)
176176
-- Call the operatorfunc if it has not been called yet
177177
if not args then
178178
-- Clear the delete cache (since it was user-called)
179-
cache.delete = {}
179+
cache.delete = { count = vim.v.count1 }
180180

181181
vim.go.operatorfunc = "v:lua.require'nvim-surround'.delete_callback"
182182
return "g@l"
@@ -213,7 +213,7 @@ M.change_surround = function(args)
213213
-- Call the operatorfunc if it has not been called yet
214214
if not args.del_char or not args.add_delimiters then
215215
-- Clear the change cache (since it was user-called)
216-
cache.change = { line_mode = args.line_mode }
216+
cache.change = { line_mode = args.line_mode, count = vim.v.count1 }
217217

218218
vim.go.operatorfunc = "v:lua.require'nvim-surround'.change_callback"
219219
return "g@l"
@@ -222,45 +222,48 @@ M.change_surround = function(args)
222222
buffer.set_curpos(args.curpos)
223223
-- Get the selections to change, as well as the delimiters to replace those selections
224224
local selections = utils.get_nearest_selections(args.del_char, "change")
225-
local delimiters = args.add_delimiters()
226-
if selections and delimiters then
227-
-- Avoid adding any, and remove any existing whitespace after the
228-
-- opening delimiter if only whitespace exists between it and the end
229-
-- of the line. Avoid adding or removing leading whitespace before the
230-
-- closing delimiter if only whitespace exists between it and the
231-
-- beginning of the line.
232-
233-
local space_begin, space_end = buffer.get_line(selections.left.last_pos[1]):find("%s*$")
234-
if space_begin - 1 <= selections.left.last_pos[2] then -- Whitespace is adjacent to opening delimiter
235-
-- Trim trailing whitespace from opening delimiter
236-
delimiters[1][#delimiters[1]] = delimiters[1][#delimiters[1]]:gsub("%s+$", "")
237-
-- Grow selection end to include trailing whitespace, so it gets removed
238-
selections.left.last_pos[2] = space_end
239-
end
225+
local raw_delimiters = args.add_delimiters()
226+
if not (selections and raw_delimiters) then
227+
cache.set_callback("v:lua.require'nvim-surround'.change_callback")
228+
return
229+
end
230+
local delimiters = utils.normalize_delimiters(raw_delimiters)
231+
-- Avoid adding any, and remove any existing whitespace after the
232+
-- opening delimiter if only whitespace exists between it and the end
233+
-- of the line. Avoid adding or removing leading whitespace before the
234+
-- closing delimiter if only whitespace exists between it and the
235+
-- beginning of the line.
236+
237+
local space_begin, space_end = buffer.get_line(selections.left.last_pos[1]):find("%s*$")
238+
if space_begin - 1 <= selections.left.last_pos[2] then -- Whitespace is adjacent to opening delimiter
239+
-- Trim trailing whitespace from opening delimiter
240+
delimiters[1][#delimiters[1]] = delimiters[1][#delimiters[1]]:gsub("%s+$", "")
241+
-- Grow selection end to include trailing whitespace, so it gets removed
242+
selections.left.last_pos[2] = space_end
243+
end
240244

241-
space_begin, space_end = buffer.get_line(selections.right.first_pos[1]):find("^%s*")
242-
if space_end + 1 >= selections.right.first_pos[2] then -- Whitespace is adjacent to closing delimiter
243-
-- Trim leading whitespace from closing delimiter
244-
delimiters[2][1] = delimiters[2][1]:gsub("^%s+", "")
245-
-- Shrink selection beginning to exclude leading whitespace, so it remains unchanged
246-
selections.right.first_pos[2] = space_end + 1
247-
end
245+
space_begin, space_end = buffer.get_line(selections.right.first_pos[1]):find("^%s*")
246+
if space_end + 1 >= selections.right.first_pos[2] then -- Whitespace is adjacent to closing delimiter
247+
-- Trim leading whitespace from closing delimiter
248+
delimiters[2][1] = delimiters[2][1]:gsub("^%s+", "")
249+
-- Shrink selection beginning to exclude leading whitespace, so it remains unchanged
250+
selections.right.first_pos[2] = space_end + 1
251+
end
248252

249-
local sticky_pos = buffer.with_extmark(args.curpos, function()
250-
buffer.change_selection(selections.right, delimiters[2])
251-
buffer.change_selection(selections.left, delimiters[1])
252-
end)
253-
buffer.restore_curpos({
254-
first_pos = selections.left.first_pos,
255-
sticky_pos = sticky_pos,
256-
old_pos = args.curpos,
257-
})
253+
local sticky_pos = buffer.with_extmark(args.curpos, function()
254+
buffer.change_selection(selections.right, delimiters[2])
255+
buffer.change_selection(selections.left, delimiters[1])
256+
end)
257+
buffer.restore_curpos({
258+
first_pos = selections.left.first_pos,
259+
sticky_pos = sticky_pos,
260+
old_pos = args.curpos,
261+
})
258262

259-
if args.line_mode then
260-
local first_line = selections.left.first_pos[1]
261-
local last_line = selections.right.last_pos[1]
262-
config.get_opts().indent_lines(first_line, last_line + #delimiters[1] + #delimiters[2] - 2)
263-
end
263+
if args.line_mode then
264+
local first_line = selections.left.first_pos[1]
265+
local last_line = selections.right.last_pos[1]
266+
config.get_opts().indent_lines(first_line, last_line + #delimiters[1] + #delimiters[2] - 2)
264267
end
265268

266269
cache.set_callback("v:lua.require'nvim-surround'.change_callback")
@@ -338,18 +341,18 @@ M.delete_callback = function()
338341
local buffer = require("nvim-surround.buffer")
339342
local cache = require("nvim-surround.cache")
340343
local input = require("nvim-surround.input")
341-
-- Save the current position of the cursor
342-
local curpos = buffer.get_curpos()
343344
-- Get a character input if not cached
344345
cache.delete.char = cache.delete.char or input.get_char()
345346
if not cache.delete.char then
346347
return
347348
end
348349

349-
M.delete_surround({
350-
del_char = cache.delete.char,
351-
curpos = curpos,
352-
})
350+
for _ = 1, cache.delete.count do
351+
M.delete_surround({
352+
del_char = cache.delete.char,
353+
curpos = buffer.get_curpos(),
354+
})
355+
end
353356
end
354357

355358
M.change_callback = function()
@@ -358,13 +361,18 @@ M.change_callback = function()
358361
local cache = require("nvim-surround.cache")
359362
local input = require("nvim-surround.input")
360363
local utils = require("nvim-surround.utils")
361-
-- Save the current position of the cursor
362-
local curpos = buffer.get_curpos()
363-
if not cache.change.del_char or not cache.change.add_delimiters then
364-
local del_char = config.get_alias(input.get_char())
365-
local change = config.get_change(del_char)
364+
365+
local del_char = cache.change.del_char or config.get_alias(input.get_char())
366+
local change = config.get_change(del_char)
367+
if not (del_char and change) then
368+
return
369+
end
370+
371+
-- To handle number prefixing properly, we just run the replacement algorithm multiple times
372+
for _ = 1, cache.change.count do
373+
-- If at any point we are unable to find a surrounding pair to change, early exit
366374
local selections = utils.get_nearest_selections(del_char, "change")
367-
if not (del_char and change and selections) then
375+
if not selections then
368376
return
369377
end
370378

@@ -378,13 +386,17 @@ M.change_callback = function()
378386
end
379387
end
380388

381-
-- Get the new surrounding pair, querying the user for more input if no replacement is provided
382-
local ins_char, delimiters
383-
if change and change.replacement then
384-
delimiters = change.replacement()
385-
else
386-
ins_char = input.get_char()
387-
delimiters = config.get_delimiters(ins_char, cache.change.line_mode)
389+
-- Get the new surrounding delimiter pair, prioritizing any delimiters in the cache
390+
-- NB: This must occur between drawing the highlights and clearing them, so the selections are properly
391+
-- highlighted if the user is providing (blocking) input
392+
local delimiters = cache.change.add_delimiters and cache.change.add_delimiters()
393+
if not delimiters then
394+
if change and change.replacement then
395+
delimiters = delimiters or change.replacement()
396+
else
397+
local ins_char = input.get_char()
398+
delimiters = delimiters or config.get_delimiters(ins_char, cache.change.line_mode)
399+
end
388400
end
389401

390402
-- Clear the highlights after getting the replacement surround
@@ -393,18 +405,24 @@ M.change_callback = function()
393405
return
394406
end
395407

408+
local add_delimiters = function()
409+
return delimiters
410+
end
396411
-- Set the cache
397412
cache.change = {
398413
del_char = del_char,
399-
add_delimiters = function()
400-
return delimiters
401-
end,
414+
add_delimiters = add_delimiters,
402415
line_mode = cache.change.line_mode,
416+
count = cache.change.count,
403417
}
418+
M.change_surround({
419+
del_char = del_char,
420+
add_delimiters = add_delimiters,
421+
line_mode = cache.change.line_mode,
422+
count = cache.change.count,
423+
curpos = buffer.get_curpos(),
424+
})
404425
end
405-
local args = vim.deepcopy(cache.change)
406-
args.curpos = curpos
407-
M.change_surround(args) ---@diagnostic disable-line: param-type-mismatch
408426
end
409427

410428
return M

lua/nvim-surround/utils.lua

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ M.repeat_delimiters = function(delimiters, n)
2323
return acc
2424
end
2525

26+
-- Normalizes a pair of delimiters to use a string[] for both the left and right delimiters
27+
---@param raw_delimiters (string|string[])[] The delimiters to be repeated.
28+
---@return delimiter_pair @The normalized delimiters.
29+
---@nodiscard
30+
M.normalize_delimiters = function(raw_delimiters)
31+
local lhs = raw_delimiters[1]
32+
local rhs = raw_delimiters[2]
33+
return {
34+
type(lhs) == "string" and { lhs } or lhs,
35+
type(rhs) == "string" and { rhs } or rhs,
36+
}
37+
end
38+
2639
-- Gets the nearest two selections for the left and right surrounding pair.
2740
---@param char string|nil A character representing what kind of surrounding pair is to be selected.
2841
---@param action "delete"|"change" A string representing what action is being performed.

tests/basics_spec.lua

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,4 +866,42 @@ describe("nvim-surround", function()
866866
"a sli<<<ghtly longer l>>>ine",
867867
})
868868
end)
869+
870+
it("can handle number prefixing for deleting surrounds", function()
871+
set_lines({ "some {{{{more placeholder}}}} text" })
872+
set_curpos({ 1, 6 })
873+
vim.cmd("normal 2dsB")
874+
check_lines({ "some {{more placeholder}} text" })
875+
vim.cmd("normal .")
876+
check_lines({ "some more placeholder text" })
877+
878+
set_lines({ "((foo) bar (baz))" })
879+
set_curpos({ 1, 9 })
880+
vim.cmd("normal 2dsb")
881+
check_lines({ "foo bar (baz)" })
882+
883+
set_lines({ "some ((more placeholder)) text" })
884+
set_curpos({ 1, 6 })
885+
vim.cmd("normal 3dsb")
886+
check_lines({ "some more placeholder text" })
887+
end)
888+
889+
it("can handle number prefixing for changing surrounds", function()
890+
set_lines({ "some {{{{more placeholder}}}} text" })
891+
set_curpos({ 1, 11 })
892+
vim.cmd("normal 2csBa")
893+
check_lines({ "some {{<<more placeholder>>}} text" })
894+
vim.cmd("normal .")
895+
check_lines({ "some <<<<more placeholder>>>> text" })
896+
897+
set_lines({ "((foo) bar (baz))" })
898+
set_curpos({ 1, 9 })
899+
vim.cmd("normal 2csbB")
900+
check_lines({ "{{foo} bar (baz)}" })
901+
902+
set_lines({ "some ((more placeholder)) text" })
903+
set_curpos({ 1, 6 })
904+
vim.cmd("normal 3csbr")
905+
check_lines({ "some [[more placeholder]] text" })
906+
end)
869907
end)

tests/configuration_spec.lua

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,4 +592,33 @@ describe("configuration", function()
592592
assert.are.same(get_curpos(), { 1, 1 })
593593
check_lines({ "print('foo')" })
594594
end)
595+
596+
it("will handle number prefixing as if the user used dot-repeat", function()
597+
require("nvim-surround").setup({ move_cursor = "sticky" })
598+
set_lines({ "foo bar baz" })
599+
set_curpos({ 1, 5 })
600+
vim.cmd("normal 3ysiwb")
601+
check_lines({ "foo (((bar))) baz" })
602+
check_curpos({ 1, 8 })
603+
vim.cmd("normal 2ySSa")
604+
check_lines({
605+
"<",
606+
"<",
607+
"foo (((bar))) baz",
608+
">",
609+
">",
610+
})
611+
612+
set_lines({ "((foo) bar (baz))" })
613+
set_curpos({ 1, 9 })
614+
vim.cmd("normal 2dsb")
615+
check_lines({ "(foo) bar baz" })
616+
check_curpos({ 1, 8 })
617+
618+
set_lines({ "((foo) bar (baz))" })
619+
set_curpos({ 1, 9 })
620+
vim.cmd("normal 2csbr")
621+
check_lines({ "[(foo) bar [baz]]" })
622+
check_curpos({ 1, 9 })
623+
end)
595624
end)

0 commit comments

Comments
 (0)