From abd380e28d48dd155b1e29cd2453f13b28bf7e08 Mon Sep 17 00:00:00 2001 From: Steven Ward Date: Mon, 5 Jun 2023 22:05:51 -0500 Subject: [PATCH] fix(defaults): visual mode star (*,#) is fragile Problem: Visual mode "*", "#" mappings don't work on text with "/", "\", "?", and newlines. Solution: Get the visual selection and escape it as a search pattern. Add functions vim.get_visual_selection and _search_for_visual_selection. Fix #21676 --- runtime/doc/lua.txt | 19 +++++++++ runtime/doc/vim_diff.txt | 5 ++- runtime/lua/vim/_editor.lua | 84 ++++++++++++++++++++++++++++++++++--- 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 77a89a123d..9d3a3dec71 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -1410,6 +1410,25 @@ deprecate({name}, {alternative}, {version}, {plugin}, {backtrace}) Return: ~ Deprecated message, or nil if no message was shown. + *vim.get_visual_selection()* +get_visual_selection({list}, {append_empty}) + Gets the content of the visual selection. + + The result is either a string or, if {list} is `true`, a list of strings. + If not in any |visual-mode|, `nil` is returned. + + Parameters: ~ + • {list} boolean|nil Return a list of strings instead of a + string. See |getreg()|. Defaults to `false`. + • {append_empty} boolean|nil Append an empty string to the result when + in |linewise-visual| mode and {list} is `true`. This + will preserve the trailing newline of the selection + when the result is concatenated with `"\n"`. Defaults + to `false`. + + Return: ~ + string|table + inspect({object}, {options}) *vim.inspect()* Gets a human-readable representation of the given object. diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt index 686905537d..4abecfc321 100644 --- a/runtime/doc/vim_diff.txt +++ b/runtime/doc/vim_diff.txt @@ -119,10 +119,11 @@ of these in your config by simply removing the mapping, e.g. ":unmap Y". nnoremap nohlsearchdiffupdatenormal! inoremap u inoremap u - xnoremap * y/\V" - xnoremap # y?\V" nnoremap & :&& < +Default mappings composed of Lua code are not listed above. Use ":map" to see +the |map-listing|. + DEFAULT AUTOCOMMANDS *default-autocmds* Default autocommands exist in the following groups. Use ":autocmd! {group}" to diff --git a/runtime/lua/vim/_editor.lua b/runtime/lua/vim/_editor.lua index ab20c36b17..e00d512a2a 100644 --- a/runtime/lua/vim/_editor.lua +++ b/runtime/lua/vim/_editor.lua @@ -435,7 +435,7 @@ vim.cmd = setmetatable({}, { do local validate = vim.validate - --@private + ---@private local function make_dict_accessor(scope, handle) validate({ scope = { scope, 's' }, @@ -535,6 +535,55 @@ function vim.region(bufnr, pos1, pos2, regtype, inclusive) return region end +--- Gets the content of the visual selection. +--- +--- The result is either a string or, if {list} is `true`, a list of strings. +--- If not in any |visual-mode|, `nil` is returned. +--- +--- @param list boolean|nil +--- Return a list of strings instead of a string. See |getreg()|. +--- Defaults to `false`. +--- @param append_empty boolean|nil +--- Append an empty string to the result when in |linewise-visual| mode and {list} is `true`. +--- This will preserve the trailing newline of the selection when the result is concatenated with `"\n"`. +--- Defaults to `false`. +--- @return string|table +function vim.get_visual_selection(list, append_empty) + list = list or false + append_empty = append_empty or false + + local mode = vim.api.nvim_get_mode().mode + if mode ~= 'v' and mode ~= 'V' and mode:byte() ~= 22 then + return nil + end + + local reg_name_unnamed = '"' + local reg_name_yank = '0' + + local reg_info_unnamed = vim.fn.getreginfo(reg_name_unnamed) + local reg_info_yank = vim.fn.getreginfo(reg_name_yank) + local opt_clipboard = vim.o.clipboard + local opt_report = vim.o.report + + vim.o.clipboard = '' + vim.o.report = vim.v.maxcol + + vim.api.nvim_feedkeys('y', 'nx', false) + + local yanked = vim.fn.getreg(reg_name_yank, 1, list) + + vim.fn.setreg(reg_name_unnamed, reg_info_unnamed) + vim.fn.setreg(reg_name_yank, reg_info_yank) + vim.o.clipboard = opt_clipboard + vim.o.report = opt_report + + if list and append_empty and mode == 'V' then + table.insert(yanked, '') + end + + return yanked +end + --- Defers calling {fn} until {timeout} ms passes. --- --- Use to do a one-shot timer that calls {fn} @@ -985,7 +1034,7 @@ end --- Defaults to "Nvim". ---@param backtrace boolean|nil Prints backtrace. Defaults to true. --- ----@returns Deprecated message, or nil if no message was shown. +---@return Deprecated message, or nil if no message was shown. function vim.deprecate(name, alternative, version, plugin, backtrace) local msg = ('%s is deprecated'):format(name) plugin = plugin or 'Nvim' @@ -1010,9 +1059,28 @@ end function vim._init_default_mappings() -- mappings - --@private + ---@private + local function _search_for_visual_selection(search_prefix) + if search_prefix ~= '/' and search_prefix ~= '?' then + return + end + -- Escape these characters + local replacements = { + [search_prefix] = [[\]] .. search_prefix, + [ [[\]] ] = [[\\]], + ['\t'] = [[\t]], + ['\n'] = [[\n]], + } + local pattern = '[' .. table.concat(vim.tbl_keys(replacements), '') .. ']' + local visual_selection = vim.get_visual_selection(false) + local escaped_visual_selection = string.gsub(visual_selection, pattern, replacements) + local search_cmd = search_prefix .. [[\V]] .. escaped_visual_selection .. '\n' + vim.api.nvim_feedkeys(search_cmd, 'nx', true) + end + + ---@private local function map(mode, lhs, rhs) - vim.api.nvim_set_keymap(mode, lhs, rhs, { noremap = true, desc = 'Nvim builtin' }) + vim.keymap.set(mode, lhs, rhs, { noremap = true, desc = 'Nvim builtin' }) end map('n', 'Y', 'y$') @@ -1020,8 +1088,12 @@ function vim._init_default_mappings() map('n', '', 'nohlsearchdiffupdatenormal! ') map('i', '', 'u') map('i', '', 'u') - map('x', '*', 'y/\\V"') - map('x', '#', 'y?\\V"') + map('x', '*', function() + _search_for_visual_selection('/') + end) + map('x', '#', function() + _search_for_visual_selection('?') + end) -- Use : instead of so that ranges are supported. #19365 map('n', '&', ':&&')