commit 6f904cfef15b06473710977d3656e67ef3a930d0
parent 7b9512e613e1cc01f5a3ec05c2309a1ad2fba890
Author: TheBlob42 <hessenmobbel@web.de>
Date: Wed, 13 Aug 2025 20:51:43 +0200
fix(snippet): adjacent tabstops without placeholders (#35167)
* fix(snippet): adjacent tabstops without placeholders
* test(snippet): add tests for directly adjacent tabstops
Diffstat:
2 files changed, 123 insertions(+), 33 deletions(-)
diff --git a/runtime/lua/vim/snippet.lua b/runtime/lua/vim/snippet.lua
@@ -105,6 +105,7 @@ end
--- @field extmark_id integer
--- @field bufnr integer
--- @field index integer
+--- @field placement integer
--- @field choices? string[]
local Tabstop = {}
@@ -113,10 +114,11 @@ local Tabstop = {}
--- @package
--- @param index integer
--- @param bufnr integer
+--- @param placement integer
--- @param range Range4
--- @param choices? string[]
--- @return vim.snippet.Tabstop
-function Tabstop.new(index, bufnr, range, choices)
+function Tabstop.new(index, bufnr, placement, range, choices)
local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, snippet_ns, range[1], range[2], {
right_gravity = true,
end_right_gravity = false,
@@ -125,10 +127,13 @@ function Tabstop.new(index, bufnr, range, choices)
hl_group = hl_group,
})
- local self = setmetatable(
- { extmark_id = extmark_id, bufnr = bufnr, index = index, choices = choices },
- { __index = Tabstop }
- )
+ local self = setmetatable({
+ extmark_id = extmark_id,
+ bufnr = bufnr,
+ index = index,
+ placement = placement,
+ choices = choices,
+ }, { __index = Tabstop })
return self
end
@@ -162,15 +167,31 @@ function Tabstop:set_text(text)
vim.api.nvim_buf_set_text(self.bufnr, range[1], range[2], range[3], range[4], text_to_lines(text))
end
+---@alias (private) vim.snippet.TabStopGravity
+--- | "expand" Expand the (usually current) tabstop on text insert
+--- | "lock" The tabstop should NOT move on text insert
+--- | "shift" The tabstop should move on text insert (default)
+
--- Sets the right gravity of the tabstop's extmark.
---
---- @package
---- @param right_gravity boolean
-function Tabstop:set_right_gravity(right_gravity)
+---@package
+---@param target vim.snippet.TabStopGravity
+function Tabstop:set_gravity(target)
+ local right_gravity = true
+ local end_right_gravity = true
+
+ if target == 'expand' then
+ right_gravity = false
+ end_right_gravity = true
+ elseif target == 'lock' then
+ right_gravity = false
+ end_right_gravity = false
+ end
+
local range = self:get_range()
self.extmark_id = vim.api.nvim_buf_set_extmark(self.bufnr, snippet_ns, range[1], range[2], {
right_gravity = right_gravity,
- end_right_gravity = not right_gravity,
+ end_right_gravity = end_right_gravity,
end_line = range[3],
end_col = range[4],
hl_group = hl_group,
@@ -181,6 +202,7 @@ end
--- @field bufnr integer
--- @field extmark_id integer
--- @field tabstops table<integer, vim.snippet.Tabstop[]>
+--- @field tabstop_placements integer[]
--- @field current_tabstop vim.snippet.Tabstop
--- @field tab_keymaps { i: table<string, any>?, s: table<string, any>? }
--- @field shift_tab_keymaps { i: table<string, any>?, s: table<string, any>? }
@@ -191,14 +213,15 @@ local Session = {}
--- @package
--- @param bufnr integer
--- @param snippet_extmark integer
---- @param tabstop_data table<integer, { range: Range4, choices?: string[] }[]>
+--- @param tabstop_data table<integer, { placement: integer, range: Range4, choices?: string[] }[]>
--- @return vim.snippet.Session
function Session.new(bufnr, snippet_extmark, tabstop_data)
local self = setmetatable({
bufnr = bufnr,
extmark_id = snippet_extmark,
tabstops = {},
- current_tabstop = Tabstop.new(0, bufnr, { 0, 0, 0, 0 }),
+ tabstop_placements = {},
+ current_tabstop = Tabstop.new(0, bufnr, 0, { 0, 0, 0, 0 }),
tab_keymaps = { i = nil, s = nil },
shift_tab_keymaps = { i = nil, s = nil },
}, { __index = Session })
@@ -207,7 +230,11 @@ function Session.new(bufnr, snippet_extmark, tabstop_data)
for index, ranges in pairs(tabstop_data) do
for _, data in ipairs(ranges) do
self.tabstops[index] = self.tabstops[index] or {}
- table.insert(self.tabstops[index], Tabstop.new(index, self.bufnr, data.range, data.choices))
+ table.insert(
+ self.tabstops[index],
+ Tabstop.new(index, self.bufnr, data.placement, data.range, data.choices)
+ )
+ table.insert(self.tabstop_placements, data.placement)
end
end
@@ -238,14 +265,38 @@ function Session:get_dest_index(direction)
end
end
---- Sets the right gravity of the tabstop group with the given index.
+--- Sets the right gravity for all the tabstops.
---
--- @package
---- @param index integer
---- @param right_gravity boolean
-function Session:set_group_gravity(index, right_gravity)
+function Session:set_gravity()
+ local index = self.current_tabstop.index
+ local all_tabstop_placements = self.tabstop_placements
+ local dest_tabstop_placements = {}
+
for _, tabstop in ipairs(self.tabstops[index]) do
- tabstop:set_right_gravity(right_gravity)
+ tabstop:set_gravity('expand')
+ table.insert(dest_tabstop_placements, tabstop.placement)
+ end
+
+ for i, tabstops in pairs(self.tabstops) do
+ if i ~= index then
+ for _, tabstop in ipairs(tabstops) do
+ local placement = tabstop.placement + 1
+ -- Check if there other tabstops directly adjacent
+ while
+ vim.list_contains(all_tabstop_placements, placement)
+ and not vim.list_contains(dest_tabstop_placements, placement)
+ do
+ placement = placement + 1
+ end
+
+ if vim.list_contains(dest_tabstop_placements, placement) then
+ tabstop:set_gravity('lock')
+ else
+ tabstop:set_gravity('shift')
+ end
+ end
+ end
end
end
@@ -443,16 +494,17 @@ function M.expand(input)
end
-- Keep track of tabstop nodes during expansion.
- --- @type table<integer, { range: Range4, choices?: string[] }[]>
+ --- @type table<integer, { placement: integer, range: Range4, choices?: string[] }[]>
local tabstop_data = {}
+ --- @param placement integer
--- @param index integer
--- @param placeholder? string
--- @param choices? string[]
- local function add_tabstop(index, placeholder, choices)
+ local function add_tabstop(placement, index, placeholder, choices)
tabstop_data[index] = tabstop_data[index] or {}
local range = compute_tabstop_range(snippet_text, placeholder)
- table.insert(tabstop_data[index], { range = range, choices = choices })
+ table.insert(tabstop_data[index], { placement = placement, range = range, choices = choices })
end
--- Appends the given text to the snippet, taking care of indentation.
@@ -479,23 +531,23 @@ function M.expand(input)
table.insert(snippet_text, table.concat(lines, '\n'))
end
- for _, child in ipairs(snippet.data.children) do
+ for index, child in ipairs(snippet.data.children) do
local type, data = child.type, child.data
if type == G.NodeType.Tabstop then
--- @cast data vim.snippet.TabstopData
local placeholder = placeholders[data.tabstop]
- add_tabstop(data.tabstop, placeholder)
+ add_tabstop(index, data.tabstop, placeholder)
if placeholder then
append_to_snippet(placeholder)
end
elseif type == G.NodeType.Placeholder then
--- @cast data vim.snippet.PlaceholderData
local value = placeholders[data.tabstop]
- add_tabstop(data.tabstop, value)
+ add_tabstop(index, data.tabstop, value)
append_to_snippet(value)
elseif type == G.NodeType.Choice then
--- @cast data vim.snippet.ChoiceData
- add_tabstop(data.tabstop, nil, data.values)
+ add_tabstop(index, data.tabstop, nil, data.values)
elseif type == G.NodeType.Variable then
--- @cast data vim.snippet.VariableData
-- Try to get the variable's value.
@@ -504,8 +556,9 @@ function M.expand(input)
-- Unknown variable, make this a tabstop and use the variable name as a placeholder.
value = data.name
local tabstop_indexes = vim.tbl_keys(tabstop_data)
- local index = math.max(unpack((#tabstop_indexes == 0 and { 0 }) or tabstop_indexes)) + 1
- add_tabstop(index, value)
+ local tabstop_index = math.max(unpack((#tabstop_indexes == 0 and { 0 }) or tabstop_indexes))
+ + 1
+ add_tabstop(index, tabstop_index, value)
end
append_to_snippet(value)
elseif type == G.NodeType.Text then
@@ -519,7 +572,7 @@ function M.expand(input)
if vim.tbl_contains(vim.tbl_keys(tabstop_data), 0) then
assert(#tabstop_data[0] == 1, 'Snippet has multiple $0 tabstops')
else
- add_tabstop(0)
+ add_tabstop(#snippet.data.children + 1, 0)
end
snippet_text = text_to_lines(snippet_text)
@@ -579,10 +632,8 @@ function M.jump(direction)
-- Clear the autocommands so that we can move the cursor freely while selecting the tabstop.
vim.api.nvim_clear_autocmds({ group = snippet_group, buffer = M._session.bufnr })
- -- Deactivate expansion of the current tabstop.
- M._session:set_group_gravity(M._session.current_tabstop.index, true)
-
M._session.current_tabstop = dest
+ M._session:set_gravity()
select_tabstop(dest)
-- The cursor is not on a tabstop so exit the session.
@@ -591,9 +642,6 @@ function M.jump(direction)
return
end
- -- Activate expansion of the destination tabstop.
- M._session:set_group_gravity(dest.index, false)
-
-- Restore the autocommands.
setup_autocmds(M._session.bufnr)
end
diff --git a/test/functional/lua/snippet_spec.lua b/test/functional/lua/snippet_spec.lua
@@ -163,6 +163,48 @@ describe('vim.snippet', function()
eq({ 'class Foo() {', ' // Inside', '}' }, buf_lines(0))
end)
+ it('handles directly adjacent tabstops (ascending order)', function()
+ test_expand_success({ '${1:one}${2:-two}${3:-three}' }, { 'one-two-three' })
+ feed('1')
+ feed('<Tab>')
+ poke_eventloop()
+ feed('2')
+ feed('<Tab>')
+ poke_eventloop()
+ feed('3')
+ feed('<Tab>')
+ poke_eventloop()
+ eq({ '123' }, buf_lines(0))
+ end)
+
+ it('handles directly adjacent tabstops (descending order)', function()
+ test_expand_success({ '${3:three}${2:-two}${1:-one}' }, { 'three-two-one' })
+ feed('1')
+ feed('<Tab>')
+ poke_eventloop()
+ feed('2')
+ feed('<Tab>')
+ poke_eventloop()
+ feed('3')
+ feed('<Tab>')
+ poke_eventloop()
+ eq({ '321' }, buf_lines(0))
+ end)
+
+ it('handles directly adjacent tabstops (mixed order)', function()
+ test_expand_success({ '${3:three}${1:-one}${2:-two}' }, { 'three-one-two' })
+ feed('1')
+ feed('<Tab>')
+ poke_eventloop()
+ feed('2')
+ feed('<Tab>')
+ poke_eventloop()
+ feed('3')
+ feed('<Tab>')
+ poke_eventloop()
+ eq({ '312' }, buf_lines(0))
+ end)
+
it('handles multiline placeholders', function()
test_expand_success(
{ 'public void foo() {', ' ${0:// TODO Auto-generated', ' throw;}', '}' },