From 8032a4a9279db164ac8a972c71fb48fd06cc7ceb Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:09:18 +0800 Subject: [PATCH] feat: toggle-surround --- doc/nvim-surround.txt | 62 ++++++++++++++++++++--- lua/nvim-surround/annotations.lua | 2 + lua/nvim-surround/cache.lua | 2 + lua/nvim-surround/config.lua | 26 +++++++++- lua/nvim-surround/init.lua | 83 +++++++++++++++++++++++++++++++ plugin/nvim-surround.lua | 7 +++ tests/configuration_spec.lua | 23 +++++++++ tests/dot_repeat_spec.lua | 23 +++++++++ tests/jumps_spec.lua | 31 ++++++++++++ 9 files changed, 252 insertions(+), 7 deletions(-) diff --git a/doc/nvim-surround.txt b/doc/nvim-surround.txt index 30b1a9c..9199a10 100644 --- a/doc/nvim-surround.txt +++ b/doc/nvim-surround.txt @@ -21,9 +21,10 @@ CONTENTS *nvim-surround.contents* 3.3. Setup ......................................... |nvim-surround.setup| 3.3.1 Surrounds ...................... |nvim-surround.setup.surrounds| 3.3.2 Aliases .......................... |nvim-surround.setup.aliases| - 3.3.3 Highlights ..................... |nvim-surround.setup.highlight| - 3.3.4 Cursor ....................... |nvim-surround.setup.move_cursor| - 3.3.5 Indentation ................. |nvim-surround.setup.indent_lines| + 3.3.3 Cycles ........................... |nvim-surround.setup.cycles| + 3.3.4 Highlights ..................... |nvim-surround.setup.highlight| + 3.3.5 Cursor ....................... |nvim-surround.setup.move_cursor| + 3.3.6 Indentation ................. |nvim-surround.setup.indent_lines| 3.4. Helpers ..................................... |nvim-surround.helpers| 4. Migration Guides ................................ |nvim-surround.migrating| 4.1. Migrating v3 to v4 ............... |nvim-surround.migrating.v3_to_v4| @@ -62,6 +63,7 @@ For examples of custom surrounds, see: * Adding a delimiter pair to the buffer * Deleting a delimiter pair from the buffer * Changing a delimiter pair to a different pair +* Toggling a delimiter pair through a configured cycle -------------------------------------------------------------------------------- 2.1. The Basics *nvim-surround.basics* @@ -220,6 +222,18 @@ delimiter pair on new lines. args ) +Users can bind the |(nvim-surround-toggle)| mapping to create a toggle +operator, e.g. `ts`. When used, it toggles the nearest surrounding pair +through a configured cycle. + +For example, with a `q` cycle configured to use double quotes, single quotes, +and backticks: + + Old text Command New text ~ + "some *text" tsq 'some text' + 'some *text' tsq `some text` + `some *text` tsq "some text" + For the visual-mode `S` map, the type of visual mode determines how the delimiter pair is added * |charwise-visual|: Adds the pair around the selection @@ -410,6 +424,7 @@ Both functions take a table as an argument, full of configuration options. require("nvim-surround").setup({ surrounds = -- Defines surround keys and behavior aliases = -- Defines aliases + cycles = -- Defines toggle cycles for user-defined toggle mappings highlight = -- Defines highlight behavior move_cursor = -- Defines cursor behavior after a surround action indent_lines = -- Defines line indentation behavior @@ -599,7 +614,42 @@ Default value: }, < -------------------------------------------------------------------------------- -3.3.3. Highlights *nvim-surround.setup.highlight* +3.3.3. Cycles *nvim-surround.setup.cycles* + +The cycles table maps characters to ordered lists of surround keys. These +cycles can be used by a user-defined mapping to +|(nvim-surround-toggle)|, toggling the nearest matching surround to the +next entry in the list. + +For example, the following configuration defines a `q` cycle that rotates +between double quotes, single quotes, and backticks, and binds it to `ts`: +>lua + require("nvim-surround").setup({ + cycles = { + ["q"] = { '"', "'", "`" }, + }, + }) + vim.keymap.set("n", "ts", "(nvim-surround-toggle)") +< + +This means `tsq` will do the following: + + Old text Command New text ~ + "some *text" tsq 'some text' + 'some *text' tsq `some text` + `some *text` tsq "some text" + +To toggle only between single and double quotes, override the cycle: +>lua + require("nvim-surround").setup({ + cycles = { + ["q"] = { '"', "'" }, + }, + }) + vim.keymap.set("n", "ts", "(nvim-surround-toggle)") +< +-------------------------------------------------------------------------------- +3.3.4. Highlights *nvim-surround.setup.highlight* When adding a new delimiter pair to the buffer in normal mode, one can optionally highlight the selection to be surrounded. Similarly, when changing @@ -618,7 +668,7 @@ configured separately. The default highlight group used is `Visual`: highlight default link NvimSurroundHighlight Visual < -------------------------------------------------------------------------------- -3.3.4. Cursor *nvim-surround.setup.move_cursor* +3.3.5. Cursor *nvim-surround.setup.move_cursor* By default (or when `move_cursor = "begin"`), when a surround action is performed, the cursor moves to the beginning of the action. @@ -645,7 +695,7 @@ of how the buffer changes. (hello* world) csbffoo foo(he*llo world) -------------------------------------------------------------------------------- -3.3.5. Indentation *nvim-surround.setup.indent_lines* +3.3.6. Indentation *nvim-surround.setup.indent_lines* By default, when a surround action is performed, |nvim-surround| tries to indent things "appropriately", i.e. when it is enabled by a Vim option. The diff --git a/lua/nvim-surround/annotations.lua b/lua/nvim-surround/annotations.lua index 86aa088..c93ec99 100644 --- a/lua/nvim-surround/annotations.lua +++ b/lua/nvim-surround/annotations.lua @@ -33,6 +33,7 @@ ---@class options ---@field surrounds table ---@field aliases table +---@field cycles table ---@field highlight { duration: integer } ---@field move_cursor false|"begin"|"sticky" ---@field indent_lines function @@ -55,6 +56,7 @@ ---@class user_options ---@field surrounds? table ---@field aliases? table +---@field cycles? table ---@field highlight? { duration: false|integer } ---@field move_cursor? false|"begin"|"sticky" ---@field indent_lines? false|function diff --git a/lua/nvim-surround/cache.lua b/lua/nvim-surround/cache.lua index 772317e..c5f4d07 100644 --- a/lua/nvim-surround/cache.lua +++ b/lua/nvim-surround/cache.lua @@ -8,6 +8,8 @@ M.normal = {} M.delete = {} ---@type { del_char: string, add_delimiters: add_func, line_mode: boolean, count: integer } M.change = {} +---@type { char: string, line_mode: boolean, count: integer } +M.toggle = {} -- Sets the callback function for dot-repeating. ---@param func_name string A string representing the callback function's name. diff --git a/lua/nvim-surround/config.lua b/lua/nvim-surround/config.lua index efdb127..ab5a3ad 100644 --- a/lua/nvim-surround/config.lua +++ b/lua/nvim-surround/config.lua @@ -226,6 +226,7 @@ M.default_opts = { ["q"] = { '"', "'", "`" }, ["s"] = { "}", "]", ")", ">", '"', "'", "`" }, }, + cycles = {}, highlight = { duration = 0, }, @@ -533,6 +534,22 @@ M.translate_alias = function(user_alias) return user_alias end +-- Translates `cycle` into the internal form. +---@param user_cycle false|string[] The user-provided `cycle`. +---@return false|string[] @The translated `cycle`. +M.translate_cycle = function(user_cycle) + if not user_cycle then + return user_cycle + end + + local input = require("nvim-surround.input") + local cycle = {} + for _, char in ipairs(user_cycle) do + cycle[#cycle + 1] = input.replace_termcodes(char) + end + return cycle +end + -- Translates the user-provided configuration into the internal form. ---@param user_opts user_options The user-provided options. ---@return options @The translated options. @@ -540,7 +557,7 @@ M.translate_opts = function(user_opts) local input = require("nvim-surround.input") local opts = {} for key, value in pairs(user_opts) do - if key == "surrounds" or key == "aliases" then + if key == "surrounds" or key == "aliases" or key == "cycles" then elseif key == "indent_lines" then opts[key] = value or function() end else @@ -568,6 +585,13 @@ M.translate_opts = function(user_opts) opts.aliases[char] = M.translate_alias(user_alias) end end + if user_opts.cycles then + opts.cycles = {} + for char, user_cycle in pairs(user_opts.cycles) do + char = input.replace_termcodes(char) + opts.cycles[char] = M.translate_cycle(user_cycle) + end + end return opts end diff --git a/lua/nvim-surround/init.lua b/lua/nvim-surround/init.lua index f971f0b..f4936cf 100644 --- a/lua/nvim-surround/init.lua +++ b/lua/nvim-surround/init.lua @@ -269,6 +269,26 @@ M.change_surround = function(args) cache.set_callback("v:lua.require'nvim-surround'.change_callback") end +-- Toggle a surrounding delimiter pair to the next entry in a configured cycle. +---@param args { curpos: position, char: string, add_delimiters: add_func, line_mode: boolean } +---@return "g@l"|nil +M.toggle_surround = function(args) + local cache = require("nvim-surround.cache") + if not args.del_char or not args.add_delimiters then + cache.toggle = { line_mode = args.line_mode, count = vim.v.count1 } + + vim.go.operatorfunc = "v:lua.require'nvim-surround'.toggle_callback" + return "g@l" + end + + return M.change_surround({ + curpos = args.curpos, + del_char = args.del_char, + add_delimiters = args.add_delimiters, + line_mode = args.line_mode, + }) +end + --[====================================================================================================================[ Callback Functions --]====================================================================================================================] @@ -425,4 +445,67 @@ M.change_callback = function() end end +M.toggle_callback = function() + local config = require("nvim-surround.config") + local buffer = require("nvim-surround.buffer") + local cache = require("nvim-surround.cache") + local input = require("nvim-surround.input") + local utils = require("nvim-surround.utils") + + cache.toggle.char = cache.toggle.char or input.get_char() + local char = cache.toggle.char + if not char then + return + end + + local cycle = config.get_opts().cycles[char] + if not cycle or #cycle == 0 then + return + end + + for _ = 1, cache.toggle.count do + local selections = utils.get_nearest_selections(char, "change") + if not selections then + return + end + + local left_text = table.concat(buffer.get_text(selections.left)) + local current_index + for index, candidate in ipairs(cycle) do + if candidate == left_text then + current_index = index + break + end + end + if not current_index then + return + end + + local next_char = cycle[current_index % #cycle + 1] + local delimiters = config.get_delimiters(next_char, cache.toggle.line_mode) + if not delimiters then + return + end + + local add_delimiters = function() + return delimiters + end + cache.change = { + del_char = char, + add_delimiters = add_delimiters, + line_mode = cache.toggle.line_mode, + count = 1, + } + M.change_surround({ + del_char = char, + add_delimiters = add_delimiters, + line_mode = cache.toggle.line_mode, + count = 1, + curpos = buffer.get_curpos(), + }) + end + + cache.set_callback("v:lua.require'nvim-surround'.toggle_callback") +end + return M diff --git a/plugin/nvim-surround.lua b/plugin/nvim-surround.lua index 10b3b25..08f326d 100644 --- a/plugin/nvim-surround.lua +++ b/plugin/nvim-surround.lua @@ -154,3 +154,10 @@ end, { expr = true, silent = true, }) +vim.keymap.set("n", "(nvim-surround-toggle)", function() + return require("nvim-surround").toggle_surround({ line_mode = false }) +end, { + desc = "Toggle a surrounding pair through a configured cycle", + expr = true, + silent = true, +}) diff --git a/tests/configuration_spec.lua b/tests/configuration_spec.lua index 199940c..351f026 100644 --- a/tests/configuration_spec.lua +++ b/tests/configuration_spec.lua @@ -136,6 +136,29 @@ describe("configuration", function() check_lines({ "hey! hello world" }) end) + it("can configure custom toggle cycles", function() + require("nvim-surround").buffer_setup({ + cycles = { + ["q"] = { '"', "'" }, + }, + }) + vim.keymap.set("n", "ts", "(nvim-surround-toggle)") + + set_lines({ + [["hello"]], + [['world']], + }) + + set_curpos({ 1, 3 }) + vim.cmd("normal tsq") + set_curpos({ 2, 3 }) + vim.cmd("normal tsq") + check_lines({ + [['hello']], + [["world"]], + }) + end) + it("can use 'syntactic sugar' for add functions", function() require("nvim-surround").buffer_setup({ surrounds = { diff --git a/tests/dot_repeat_spec.lua b/tests/dot_repeat_spec.lua index e807d29..028de39 100644 --- a/tests/dot_repeat_spec.lua +++ b/tests/dot_repeat_spec.lua @@ -40,6 +40,29 @@ describe("dot-repeat", function() check_lines({ "<<>>" }) end) + it("can dot-repeat quote toggles", function() + require("nvim-surround").buffer_setup({ + cycles = { + ["q"] = { '"', "'" }, + }, + }) + vim.keymap.set("n", "ts", "(nvim-surround-toggle)") + + set_lines({ + [["one"]], + [["two"]], + [["three"]], + }) + set_curpos({ 1, 3 }) + vim.cmd("normal tsq") + vim.cmd("normal j.j.") + check_lines({ + [['one']], + [['two']], + [['three']], + }) + end) + it("can add non-static delimiter pairs based on user input", function() set_lines({ "here", "are", "some", "lines" }) vim.cmd("normal ysiwffunc_name" .. cr) diff --git a/tests/jumps_spec.lua b/tests/jumps_spec.lua index f739215..92161c0 100644 --- a/tests/jumps_spec.lua +++ b/tests/jumps_spec.lua @@ -71,6 +71,37 @@ describe("jumps", function() }) end) + it("can toggle quotes using a cycle", function() + require("nvim-surround").buffer_setup({ + cycles = { + ["q"] = { '"', "'", "`" }, + }, + }) + vim.keymap.set("n", "ts", "(nvim-surround-toggle)") + + set_lines({ + [["hello "world""]], + }) + + set_curpos({ 1, 10 }) + vim.cmd("normal tsq") + check_lines({ + [["hello 'world'"]], + }) + + set_curpos({ 1, 10 }) + vim.cmd("normal tsq") + check_lines({ + [["hello `world`"]], + }) + + set_curpos({ 1, 10 }) + vim.cmd("normal tsq") + check_lines({ + [["hello "world""]], + }) + end) + it("for quotes only target the current line", function() set_lines({ [[This 'line' has quotes]],