fix(snippet): adjacent tabstops without placeholders (#35167)

* fix(snippet): adjacent tabstops without placeholders

* test(snippet): add tests for directly adjacent tabstops
This commit is contained in:
TheBlob42
2025-08-13 20:51:43 +02:00
committed by GitHub
parent 7b9512e613
commit 6f904cfef1
2 changed files with 123 additions and 33 deletions

View File

@@ -105,6 +105,7 @@ end
--- @field extmark_id integer --- @field extmark_id integer
--- @field bufnr integer --- @field bufnr integer
--- @field index integer --- @field index integer
--- @field placement integer
--- @field choices? string[] --- @field choices? string[]
local Tabstop = {} local Tabstop = {}
@@ -113,10 +114,11 @@ local Tabstop = {}
--- @package --- @package
--- @param index integer --- @param index integer
--- @param bufnr integer --- @param bufnr integer
--- @param placement integer
--- @param range Range4 --- @param range Range4
--- @param choices? string[] --- @param choices? string[]
--- @return vim.snippet.Tabstop --- @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], { local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, snippet_ns, range[1], range[2], {
right_gravity = true, right_gravity = true,
end_right_gravity = false, end_right_gravity = false,
@@ -125,10 +127,13 @@ function Tabstop.new(index, bufnr, range, choices)
hl_group = hl_group, hl_group = hl_group,
}) })
local self = setmetatable( local self = setmetatable({
{ extmark_id = extmark_id, bufnr = bufnr, index = index, choices = choices }, extmark_id = extmark_id,
{ __index = Tabstop } bufnr = bufnr,
) index = index,
placement = placement,
choices = choices,
}, { __index = Tabstop })
return self return self
end 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)) vim.api.nvim_buf_set_text(self.bufnr, range[1], range[2], range[3], range[4], text_to_lines(text))
end 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. --- Sets the right gravity of the tabstop's extmark.
--- ---
--- @package ---@package
--- @param right_gravity boolean ---@param target vim.snippet.TabStopGravity
function Tabstop:set_right_gravity(right_gravity) 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() local range = self:get_range()
self.extmark_id = vim.api.nvim_buf_set_extmark(self.bufnr, snippet_ns, range[1], range[2], { self.extmark_id = vim.api.nvim_buf_set_extmark(self.bufnr, snippet_ns, range[1], range[2], {
right_gravity = right_gravity, right_gravity = right_gravity,
end_right_gravity = not right_gravity, end_right_gravity = end_right_gravity,
end_line = range[3], end_line = range[3],
end_col = range[4], end_col = range[4],
hl_group = hl_group, hl_group = hl_group,
@@ -181,6 +202,7 @@ end
--- @field bufnr integer --- @field bufnr integer
--- @field extmark_id integer --- @field extmark_id integer
--- @field tabstops table<integer, vim.snippet.Tabstop[]> --- @field tabstops table<integer, vim.snippet.Tabstop[]>
--- @field tabstop_placements integer[]
--- @field current_tabstop vim.snippet.Tabstop --- @field current_tabstop vim.snippet.Tabstop
--- @field tab_keymaps { i: table<string, any>?, s: table<string, any>? } --- @field tab_keymaps { i: table<string, any>?, s: table<string, any>? }
--- @field shift_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 --- @package
--- @param bufnr integer --- @param bufnr integer
--- @param snippet_extmark 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 --- @return vim.snippet.Session
function Session.new(bufnr, snippet_extmark, tabstop_data) function Session.new(bufnr, snippet_extmark, tabstop_data)
local self = setmetatable({ local self = setmetatable({
bufnr = bufnr, bufnr = bufnr,
extmark_id = snippet_extmark, extmark_id = snippet_extmark,
tabstops = {}, 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 }, tab_keymaps = { i = nil, s = nil },
shift_tab_keymaps = { i = nil, s = nil }, shift_tab_keymaps = { i = nil, s = nil },
}, { __index = Session }) }, { __index = Session })
@@ -207,7 +230,11 @@ function Session.new(bufnr, snippet_extmark, tabstop_data)
for index, ranges in pairs(tabstop_data) do for index, ranges in pairs(tabstop_data) do
for _, data in ipairs(ranges) do for _, data in ipairs(ranges) do
self.tabstops[index] = self.tabstops[index] or {} 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
end end
@@ -238,14 +265,38 @@ function Session:get_dest_index(direction)
end end
end end
--- Sets the right gravity of the tabstop group with the given index. --- Sets the right gravity for all the tabstops.
--- ---
--- @package --- @package
--- @param index integer function Session:set_gravity()
--- @param right_gravity boolean local index = self.current_tabstop.index
function Session:set_group_gravity(index, right_gravity) local all_tabstop_placements = self.tabstop_placements
local dest_tabstop_placements = {}
for _, tabstop in ipairs(self.tabstops[index]) do 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
end end
@@ -443,16 +494,17 @@ function M.expand(input)
end end
-- Keep track of tabstop nodes during expansion. -- 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 = {} local tabstop_data = {}
--- @param placement integer
--- @param index integer --- @param index integer
--- @param placeholder? string --- @param placeholder? string
--- @param choices? 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 {} tabstop_data[index] = tabstop_data[index] or {}
local range = compute_tabstop_range(snippet_text, placeholder) 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 end
--- Appends the given text to the snippet, taking care of indentation. --- 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')) table.insert(snippet_text, table.concat(lines, '\n'))
end 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 local type, data = child.type, child.data
if type == G.NodeType.Tabstop then if type == G.NodeType.Tabstop then
--- @cast data vim.snippet.TabstopData --- @cast data vim.snippet.TabstopData
local placeholder = placeholders[data.tabstop] local placeholder = placeholders[data.tabstop]
add_tabstop(data.tabstop, placeholder) add_tabstop(index, data.tabstop, placeholder)
if placeholder then if placeholder then
append_to_snippet(placeholder) append_to_snippet(placeholder)
end end
elseif type == G.NodeType.Placeholder then elseif type == G.NodeType.Placeholder then
--- @cast data vim.snippet.PlaceholderData --- @cast data vim.snippet.PlaceholderData
local value = placeholders[data.tabstop] local value = placeholders[data.tabstop]
add_tabstop(data.tabstop, value) add_tabstop(index, data.tabstop, value)
append_to_snippet(value) append_to_snippet(value)
elseif type == G.NodeType.Choice then elseif type == G.NodeType.Choice then
--- @cast data vim.snippet.ChoiceData --- @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 elseif type == G.NodeType.Variable then
--- @cast data vim.snippet.VariableData --- @cast data vim.snippet.VariableData
-- Try to get the variable's value. -- 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. -- Unknown variable, make this a tabstop and use the variable name as a placeholder.
value = data.name value = data.name
local tabstop_indexes = vim.tbl_keys(tabstop_data) local tabstop_indexes = vim.tbl_keys(tabstop_data)
local index = math.max(unpack((#tabstop_indexes == 0 and { 0 }) or tabstop_indexes)) + 1 local tabstop_index = math.max(unpack((#tabstop_indexes == 0 and { 0 }) or tabstop_indexes))
add_tabstop(index, value) + 1
add_tabstop(index, tabstop_index, value)
end end
append_to_snippet(value) append_to_snippet(value)
elseif type == G.NodeType.Text then 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 if vim.tbl_contains(vim.tbl_keys(tabstop_data), 0) then
assert(#tabstop_data[0] == 1, 'Snippet has multiple $0 tabstops') assert(#tabstop_data[0] == 1, 'Snippet has multiple $0 tabstops')
else else
add_tabstop(0) add_tabstop(#snippet.data.children + 1, 0)
end end
snippet_text = text_to_lines(snippet_text) 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. -- 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 }) 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.current_tabstop = dest
M._session:set_gravity()
select_tabstop(dest) select_tabstop(dest)
-- The cursor is not on a tabstop so exit the session. -- The cursor is not on a tabstop so exit the session.
@@ -591,9 +642,6 @@ function M.jump(direction)
return return
end end
-- Activate expansion of the destination tabstop.
M._session:set_group_gravity(dest.index, false)
-- Restore the autocommands. -- Restore the autocommands.
setup_autocmds(M._session.bufnr) setup_autocmds(M._session.bufnr)
end end

View File

@@ -163,6 +163,48 @@ describe('vim.snippet', function()
eq({ 'class Foo() {', ' // Inside', '}' }, buf_lines(0)) eq({ 'class Foo() {', ' // Inside', '}' }, buf_lines(0))
end) 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() it('handles multiline placeholders', function()
test_expand_success( test_expand_success(
{ 'public void foo() {', ' ${0:// TODO Auto-generated', ' throw;}', '}' }, { 'public void foo() {', ' ${0:// TODO Auto-generated', ' throw;}', '}' },