From f42fa0089020c214d740af831135df353d3db753 Mon Sep 17 00:00:00 2001 From: PilgrimLyieu Date: Thu, 17 Sep 2026 20:40:03 +0800 Subject: [PATCH 1/5] fix: preserve characterwise selected text when dedenting Trim the indentation overlapping each selected chunk. Preserve interior whitespace and the bounds of single-line and partially indented selections. This fixes the characterwise part of #1452 without changing block handling or reorganizing the selection hooks. --- lua/luasnip/util/select.lua | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/lua/luasnip/util/select.lua b/lua/luasnip/util/select.lua index 70f643519..815916776 100644 --- a/lua/luasnip/util/select.lua +++ b/lua/luasnip/util/select.lua @@ -114,20 +114,12 @@ function M.post_yank(yank_register) -- behaviour, the last line of `chunks` is always empty. -- Keep this elseif mode == "v" then - -- if selection starts inside indent, remove indent. - if #min_indent > start_col then - select_dedent[1] = lines[1]:gsub(min_indent, "") - end - for i = 2, #select_dedent - 1 do - select_dedent[i] = select_dedent[i]:gsub(min_indent, "") - end - - -- remove as much indent from the last line as possible. - if #min_indent > end_col then - select_dedent[#select_dedent] = "" - else - select_dedent[#select_dedent] = - select_dedent[#select_dedent]:gsub("^" .. min_indent, "") + for i, line in ipairs(chunks) do + -- Only remove indentation that overlaps the selected text. The + -- first chunk may start within/after the indent, and the last + -- chunk may end within it, including single-line selections. + local offset = i == 1 and start_col - 1 or 0 + select_dedent[i] = line:sub(math.max(#min_indent - offset, 0) + 1) end else -- in block: if indent is in block, remove the part of it that is inside From 02dd31be41c9531f85494d75ce3b78132da18e93 Mon Sep 17 00:00:00 2001 From: PilgrimLyieu Date: Thu, 17 Sep 2026 20:42:50 +0800 Subject: [PATCH 2/5] fix: normalize block selection columns before dedenting Use the smaller recorded byte column for blockwise selections so the reported space-indented rectangle dedents consistently in every direction. Keep get_min_indent, its byte-length comparison, the strict indentation boundary, and the existing anchored prefix substitution unchanged. --- lua/luasnip/util/select.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/luasnip/util/select.lua b/lua/luasnip/util/select.lua index 815916776..177b131c7 100644 --- a/lua/luasnip/util/select.lua +++ b/lua/luasnip/util/select.lua @@ -92,6 +92,10 @@ function M.pre_yank(yank_register) -- +1: include final line. lines = vim.api.nvim_buf_get_lines(0, start_line, end_line + 1, true) mode = vim.fn.visualmode() + -- Block selection marks do not necessarily put the left edge first. + if mode == "\22" then + start_col = math.min(start_col, end_col) + end end function M.post_yank(yank_register) From 54e991629fa63f7fb9279720440caa8fb363aa99 Mon Sep 17 00:00:00 2001 From: PilgrimLyieu Date: Thu, 17 Sep 2026 20:42:50 +0800 Subject: [PATCH 3/5] refactor: isolate selection conversion and per-yank state Group register and selection metadata into a single pending_yank snapshot. Move text transformation into dedent_selection and leave post_yank to read, restore, transform, and publish. Release the complete snapshot after use. Precompute the literal prefix for linewise and blockwise selections while preserving the existing byte-based indentation rules. This optional cleanup follows both correctness fixes and is not needed to obtain their behavior. --- lua/luasnip/util/select.lua | 116 +++++++++++++++--------------------- 1 file changed, 47 insertions(+), 69 deletions(-) diff --git a/lua/luasnip/util/select.lua b/lua/luasnip/util/select.lua index 177b131c7..a8e114599 100644 --- a/lua/luasnip/util/select.lua +++ b/lua/luasnip/util/select.lua @@ -39,8 +39,25 @@ local function get_min_indent(lines) return min_indent end -local function store_registers(...) - local names = { ... } +local function dedent_selection(chunks, selection) + local dedented = {} + for i, line in ipairs(chunks) do + if selection.mode == "v" then + -- Only the first chunk can start partway through a source line. + local offset = i == 1 and selection.first_byte or 0 + dedented[i] = line:sub(math.max(#selection.indent - offset, 0) + 1) + else + dedented[i] = line:gsub("^" .. selection.indent, "") + end + end + return dedented +end + +local function store_registers(yank_register) + -- Cutting can affect the unnamed and numbered registers as well as the + -- named register used to capture the selection. + local names = + { "", "1", "2", "3", "4", "5", "6", "7", "8", "9", yank_register } local restore_data = {} for _, name in ipairs(names) do restore_data[name] = { @@ -57,92 +74,53 @@ local function restore_registers(restore_data) end end --- subtle: `:lua` exits VISUAL, which means that the '< '>-marks will be set correctly! --- Afterwards, we can just use lua, which does not change the mode. +-- Leave Visual mode first so '< and '> describe the current selection. M.cut_keys = [[lua require("luasnip.util.select").pre_yank("z")gv"zslua require('luasnip.util.select').post_yank("z")]] -local saved_registers -local lines -local start_line, start_col, end_line, end_col -local mode +-- State shared by the two halves of the selection mapping. +local pending_yank function M.pre_yank(yank_register) - -- store registers so we don't change any of them. - -- "" is affected since we perform a cut (s), 1-9 also (although :h - -- quote_number seems to state otherwise for cuts to specific registers..?). - saved_registers = store_registers( - "", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - yank_register - ) - - -- store data needed for de-indenting lines. - start_line = vim.fn.line("'<") - 1 - start_col = vim.fn.col("'<") - end_line = vim.fn.line("'>") - 1 - end_col = vim.fn.col("'>") - -- +1: include final line. - lines = vim.api.nvim_buf_get_lines(0, start_line, end_line + 1, true) - mode = vim.fn.visualmode() - -- Block selection marks do not necessarily put the left edge first. - if mode == "\22" then - start_col = math.min(start_col, end_col) + local start_pos, end_pos = vim.fn.getpos("'<"), vim.fn.getpos("'>") + local lines = + vim.api.nvim_buf_get_lines(0, start_pos[2] - 1, end_pos[2], true) + local mode = vim.fn.visualmode() + local min_indent = get_min_indent(lines) or "" + local selection = { mode = mode, indent = min_indent } + if mode == "v" then + selection.first_byte = start_pos[3] - 1 + elseif mode == "\22" then + local start_col = math.min(start_pos[3], end_pos[3]) + -- Preserve the original byte-based prefix matching for blocks. + selection.indent = #min_indent > start_col and min_indent:sub(start_col) + or "" end + pending_yank = { + registers = store_registers(yank_register), + selection = selection, + } end function M.post_yank(yank_register) + local yank = pending_yank + pending_yank = nil -- remove trailing newline. local chunks = vim.split(vim.fn.getreg(yank_register):gsub("\n$", ""), "\n") -- make sure to restore the registers to the state they were before cutting. - restore_registers(saved_registers) - - local tm_select, select_dedent = vim.deepcopy(chunks), vim.deepcopy(chunks) + restore_registers(yank.registers) - local min_indent = get_min_indent(lines) or "" - if mode == "V" then + local selection = yank.selection + local select_dedent = dedent_selection(chunks, selection) + local tm_select = vim.list_extend({}, chunks) + if selection.mode == "V" then + -- VSCode removes all leading whitespace from the first selected line. tm_select[1] = tm_select[1]:gsub("^%s+", "") - -- remove indent from all lines: - for i = 1, #select_dedent do - select_dedent[i] = select_dedent[i]:gsub("^" .. min_indent, "") - end - -- due to the trailing newline of the last line, and vim.split's - -- behaviour, the last line of `chunks` is always empty. - -- Keep this - elseif mode == "v" then - for i, line in ipairs(chunks) do - -- Only remove indentation that overlaps the selected text. The - -- first chunk may start within/after the indent, and the last - -- chunk may end within it, including single-line selections. - local offset = i == 1 and start_col - 1 or 0 - select_dedent[i] = line:sub(math.max(#min_indent - offset, 0) + 1) - end - else - -- in block: if indent is in block, remove the part of it that is inside - -- it for select_dedent. - if #min_indent > start_col then - local indent_in_block = min_indent:sub(start_col, #min_indent) - for i, line in ipairs(chunks) do - select_dedent[i] = line:gsub("^" .. indent_in_block, "") - end - end end vim.api.nvim_buf_set_var(0, SELECT_RAW, chunks) vim.api.nvim_buf_set_var(0, SELECT_DEDENT, select_dedent) vim.api.nvim_buf_set_var(0, TM_SELECT, tm_select) - - lines = nil - start_line, start_col, end_line, end_col = nil, nil, nil, nil - mode = nil end return M From 0d502818838afc60af45b1779f367732139861fb Mon Sep 17 00:00:00 2001 From: PilgrimLyieu Date: Thu, 17 Sep 2026 20:42:50 +0800 Subject: [PATCH 4/5] test: cover characterwise selection dedenting Add four focused regressions for interior whitespace, single-line bounds, selected indentation, and selected interior spaces. --- tests/integration/select_spec.lua | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/integration/select_spec.lua b/tests/integration/select_spec.lua index 9511b576c..091d465e6 100644 --- a/tests/integration/select_spec.lua +++ b/tests/integration/select_spec.lua @@ -242,5 +242,30 @@ describe("Selection", function() }) end) + -- Name, selection keys, expected dedented text. + for _, case in ipairs({ + { + "preserves interior whitespace in characterwise selections", + "gg^vjj$", + { "first gap", " keep gap", "last gap" }, + }, + { "respects single-line selection bounds", "gg01lv6l", { "firs" } }, + { "dedents selected indentation", "gg0v2l", { "" } }, + { "preserves selected interior spaces", "gg09lv3l", { " " } }, + }) do + it(case[1], function() + exec_lua([[ + vim.api.nvim_buf_set_lines(0, 0, -1, true, { + " first gap", " keep gap", " last gap", + }) + ]]) + feed(case[2] .. "") + ls_helpers.assert.eq( + case[3], + exec_lua("return vim.b.LUASNIP_SELECT_DEDENT") + ) + end) + end + it("does not destroy any registers.", function() end) end) From 15fa3e8969b6332b2469e80c0eafffdeb8feb6d8 Mon Sep 17 00:00:00 2001 From: PilgrimLyieu Date: Thu, 17 Sep 2026 20:42:50 +0800 Subject: [PATCH 5/5] test: cover block directions and preserve indentation rules Add regression exercising the same rectangle in all four directions. --- tests/integration/select_spec.lua | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/integration/select_spec.lua b/tests/integration/select_spec.lua index 091d465e6..6a2963c52 100644 --- a/tests/integration/select_spec.lua +++ b/tests/integration/select_spec.lua @@ -123,6 +123,26 @@ describe("Selection", function() }) end) + it("dedents the same block in all four directions", function() + for _, keys in ipairs({ + "gg01l2j6l", + "gg07l2j6h", + "G07l2k6h", + "G01l2k6l", + }) do + exec_lua([[ + vim.api.nvim_buf_set_lines(0, 0, -1, true, { + " first", " keep gap", " last", + }) + ]]) + feed(keys .. "") + ls_helpers.assert.eq( + { "firs", " ke", "last" }, + exec_lua("return vim.b.LUASNIP_SELECT_DEDENT") + ) + end + end) + it("works with block-select.", function() feed("iasdf qwerasdf qwerasdf qwer") screen:expect({