mirror of
https://github.com/neovim/neovim.git
synced 2024-09-17 20:58:20 -04:00
feat(lsp): add snippet API (#25301)
This commit is contained in:
parent
3304449946
commit
f1775da07f
@ -3670,4 +3670,63 @@ totable({f}, {...}) *vim.iter.totable()*
|
||||
Return: ~
|
||||
(table)
|
||||
|
||||
|
||||
==============================================================================
|
||||
Lua module: vim.snippet *vim.snippet*
|
||||
|
||||
active() *snippet.active()*
|
||||
Returns `true` if there's an active snippet in the current buffer.
|
||||
|
||||
Return: ~
|
||||
(boolean)
|
||||
|
||||
exit() *snippet.exit()*
|
||||
Exits the current snippet.
|
||||
|
||||
expand({input}) *snippet.expand()*
|
||||
Expands the given snippet text. Refer to https://microsoft.github.io/language-server-protocol/specification/#snippet_syntax for the specification of valid input.
|
||||
|
||||
Tabstops are highlighted with hl-SnippetTabstop.
|
||||
|
||||
Parameters: ~
|
||||
• {input} (string)
|
||||
|
||||
jump({direction}) *snippet.jump()*
|
||||
Jumps within the active snippet in the given direction. If the jump isn't
|
||||
possible, the function call does nothing.
|
||||
|
||||
You can use this function to navigate a snippet as follows: >lua
|
||||
vim.keymap.set({ 'i', 's' }, '<Tab>', function()
|
||||
if vim.snippet.jumpable(1) then
|
||||
return '<cmd>lua vim.snippet.jump(1)<cr>'
|
||||
else
|
||||
return '<Tab>'
|
||||
end
|
||||
end, { expr = true })
|
||||
<
|
||||
|
||||
Parameters: ~
|
||||
• {direction} (vim.snippet.Direction) Navigation direction. -1 for
|
||||
previous, 1 for next.
|
||||
|
||||
jumpable({direction}) *snippet.jumpable()*
|
||||
Returns `true` if there is an active snippet which can be jumped in the
|
||||
given direction. You can use this function to navigate a snippet as
|
||||
follows: >lua
|
||||
vim.keymap.set({ 'i', 's' }, '<Tab>', function()
|
||||
if vim.snippet.jumpable(1) then
|
||||
return '<cmd>lua vim.snippet.jump(1)<cr>'
|
||||
else
|
||||
return '<Tab>'
|
||||
end
|
||||
end, { expr = true })
|
||||
<
|
||||
|
||||
Parameters: ~
|
||||
• {direction} (vim.snippet.Direction) Navigation direction. -1 for
|
||||
previous, 1 for next.
|
||||
|
||||
Return: ~
|
||||
(boolean)
|
||||
|
||||
vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl:
|
||||
|
@ -193,6 +193,8 @@ The following new APIs and features were added.
|
||||
|
||||
• Added |:fclose| command.
|
||||
|
||||
• Added |vim.snippet| for snippet expansion support.
|
||||
|
||||
==============================================================================
|
||||
CHANGED FEATURES *news-changed*
|
||||
|
||||
|
@ -38,6 +38,7 @@ for k, v in pairs({
|
||||
ui = true,
|
||||
health = true,
|
||||
secure = true,
|
||||
snippet = true,
|
||||
_watch = true,
|
||||
}) do
|
||||
vim._submodules[k] = v
|
||||
|
@ -19,6 +19,7 @@ vim.loader = require('vim.loader')
|
||||
vim.lsp = require('vim.lsp')
|
||||
vim.re = require('vim.re')
|
||||
vim.secure = require('vim.secure')
|
||||
vim.snippet = require('vim.snippet')
|
||||
vim.treesitter = require('vim.treesitter')
|
||||
vim.ui = require('vim.ui')
|
||||
vim.version = require('vim.version')
|
||||
|
@ -81,14 +81,40 @@ local Type = {
|
||||
M.NodeType = Type
|
||||
|
||||
--- @class vim.snippet.Node<T>: { type: vim.snippet.Type, data: T }
|
||||
--- @class vim.snippet.TabstopData: { tabstop: number }
|
||||
--- @class vim.snippet.TabstopData: { tabstop: integer }
|
||||
--- @class vim.snippet.TextData: { text: string }
|
||||
--- @class vim.snippet.PlaceholderData: { tabstop: vim.snippet.TabstopData, value: vim.snippet.Node<any> }
|
||||
--- @class vim.snippet.ChoiceData: { tabstop: vim.snippet.TabstopData, values: string[] }
|
||||
--- @class vim.snippet.PlaceholderData: { tabstop: integer, value: vim.snippet.Node<any> }
|
||||
--- @class vim.snippet.ChoiceData: { tabstop: integer, values: string[] }
|
||||
--- @class vim.snippet.VariableData: { name: string, default?: vim.snippet.Node<any>, regex?: string, format?: vim.snippet.Node<vim.snippet.FormatData|vim.snippet.TextData>[], options?: string }
|
||||
--- @class vim.snippet.FormatData: { capture: number, modifier?: string, if_text?: string, else_text?: string }
|
||||
--- @class vim.snippet.SnippetData: { children: vim.snippet.Node<any>[] }
|
||||
|
||||
--- @type vim.snippet.Node<any>
|
||||
local Node = {}
|
||||
|
||||
--- @return string
|
||||
--- @diagnostic disable-next-line: inject-field
|
||||
function Node:__tostring()
|
||||
local node_text = {}
|
||||
local type, data = self.type, self.data
|
||||
if type == Type.Snippet then
|
||||
--- @cast data vim.snippet.SnippetData
|
||||
for _, child in ipairs(data.children) do
|
||||
table.insert(node_text, tostring(child))
|
||||
end
|
||||
elseif type == Type.Choice then
|
||||
--- @cast data vim.snippet.ChoiceData
|
||||
table.insert(node_text, data.values[1])
|
||||
elseif type == Type.Placeholder then
|
||||
--- @cast data vim.snippet.PlaceholderData
|
||||
table.insert(node_text, tostring(data.value))
|
||||
elseif type == Type.Text then
|
||||
--- @cast data vim.snippet.TextData
|
||||
table.insert(node_text, data.text)
|
||||
end
|
||||
return table.concat(node_text)
|
||||
end
|
||||
|
||||
--- Returns a function that constructs a snippet node of the given type.
|
||||
---
|
||||
--- @generic T
|
||||
@ -96,7 +122,7 @@ M.NodeType = Type
|
||||
--- @return fun(data: T): vim.snippet.Node<T>
|
||||
local function node(type)
|
||||
return function(data)
|
||||
return { type = type, data = data }
|
||||
return setmetatable({ type = type, data = data }, Node)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -742,7 +742,6 @@ function protocol.make_client_capabilities()
|
||||
-- this should be disabled out of the box.
|
||||
-- However, users can turn this back on if they have a snippet plugin.
|
||||
snippetSupport = false,
|
||||
|
||||
commitCharactersSupport = false,
|
||||
preselectSupport = false,
|
||||
deprecatedSupport = false,
|
||||
|
@ -616,35 +616,7 @@ function M.parse_snippet(input)
|
||||
return input
|
||||
end
|
||||
|
||||
--- @param node vim.snippet.Node<any>
|
||||
--- @return string
|
||||
local function node_to_string(node)
|
||||
local insert_text = {}
|
||||
if node.type == snippet.NodeType.Snippet then
|
||||
for _, child in
|
||||
ipairs((node.data --[[@as vim.snippet.SnippetData]]).children)
|
||||
do
|
||||
table.insert(insert_text, node_to_string(child))
|
||||
end
|
||||
elseif node.type == snippet.NodeType.Choice then
|
||||
table.insert(insert_text, (node.data --[[@as vim.snippet.ChoiceData]]).values[1])
|
||||
elseif node.type == snippet.NodeType.Placeholder then
|
||||
table.insert(
|
||||
insert_text,
|
||||
node_to_string((node.data --[[@as vim.snippet.PlaceholderData]]).value)
|
||||
)
|
||||
elseif node.type == snippet.NodeType.Text then
|
||||
table.insert(
|
||||
insert_text,
|
||||
node
|
||||
.data --[[@as vim.snippet.TextData]]
|
||||
.text
|
||||
)
|
||||
end
|
||||
return table.concat(insert_text)
|
||||
end
|
||||
|
||||
return node_to_string(parsed)
|
||||
return tostring(parsed)
|
||||
end
|
||||
|
||||
--- Sorts by CompletionItem.sortText.
|
||||
|
535
runtime/lua/vim/snippet.lua
Normal file
535
runtime/lua/vim/snippet.lua
Normal file
@ -0,0 +1,535 @@
|
||||
local G = require('vim.lsp._snippet_grammar')
|
||||
local snippet_group = vim.api.nvim_create_augroup('vim/snippet', {})
|
||||
local snippet_ns = vim.api.nvim_create_namespace('vim/snippet')
|
||||
|
||||
--- Returns the 0-based cursor position.
|
||||
---
|
||||
--- @return integer, integer
|
||||
local function cursor_pos()
|
||||
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||
return cursor[1] - 1, cursor[2]
|
||||
end
|
||||
|
||||
--- Resolves variables (like `$name` or `${name:default}`) as follows:
|
||||
--- - When a variable is unknown (i.e.: its name is not recognized in any of the cases below), return `nil`.
|
||||
--- - When a variable isn't set, return its default (if any) or an empty string.
|
||||
---
|
||||
--- Note that in some cases, the default is ignored since it's not clear how to distinguish an empty
|
||||
--- value from an unset value (e.g.: `TM_CURRENT_LINE`).
|
||||
---
|
||||
--- @param var string
|
||||
--- @param default string
|
||||
--- @return string?
|
||||
local function resolve_variable(var, default)
|
||||
--- @param str string
|
||||
--- @return string
|
||||
local function expand_or_default(str)
|
||||
local expansion = vim.fn.expand(str) --[[@as string]]
|
||||
return expansion == '' and default or expansion
|
||||
end
|
||||
|
||||
if var == 'TM_SELECTED_TEXT' then
|
||||
-- Snippets are expanded in insert mode only, so there's no selection.
|
||||
return default
|
||||
elseif var == 'TM_CURRENT_LINE' then
|
||||
return vim.api.nvim_get_current_line()
|
||||
elseif var == 'TM_CURRENT_WORD' then
|
||||
return expand_or_default('<cword>')
|
||||
elseif var == 'TM_LINE_INDEX' then
|
||||
return tostring(vim.fn.line('.') - 1)
|
||||
elseif var == 'TM_LINE_NUMBER' then
|
||||
return tostring(vim.fn.line('.'))
|
||||
elseif var == 'TM_FILENAME' then
|
||||
return expand_or_default('%:t')
|
||||
elseif var == 'TM_FILENAME_BASE' then
|
||||
-- Not using '%:t:r' since we want to remove all extensions.
|
||||
local filename_base = expand_or_default('%:t'):gsub('%.[^%.]*$', '')
|
||||
return filename_base
|
||||
elseif var == 'TM_DIRECTORY' then
|
||||
return expand_or_default('%:p:h:t')
|
||||
elseif var == 'TM_FILEPATH' then
|
||||
return expand_or_default('%:p')
|
||||
end
|
||||
|
||||
-- Unknown variable.
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Transforms the given text into an array of lines (so no line contains `\n`).
|
||||
---
|
||||
--- @param text string|string[]
|
||||
--- @return string[]
|
||||
local function text_to_lines(text)
|
||||
text = type(text) == 'string' and { text } or text
|
||||
--- @cast text string[]
|
||||
return vim.split(table.concat(text), '\n', { plain = true })
|
||||
end
|
||||
|
||||
--- Computes the 0-based position of a tabstop located at the end of `snippet` and spanning
|
||||
--- `placeholder` (if given).
|
||||
---
|
||||
--- @param snippet string[]
|
||||
--- @param placeholder string?
|
||||
--- @return Range4
|
||||
local function compute_tabstop_range(snippet, placeholder)
|
||||
local cursor_row, cursor_col = cursor_pos()
|
||||
local snippet_text = text_to_lines(snippet)
|
||||
local placeholder_text = text_to_lines(placeholder or '')
|
||||
local start_row = cursor_row + #snippet_text - 1
|
||||
local start_col = #(snippet_text[#snippet_text] or '')
|
||||
|
||||
-- Add the cursor's column offset to the first line.
|
||||
if start_row == cursor_row then
|
||||
start_col = start_col + cursor_col
|
||||
end
|
||||
|
||||
local end_row = start_row + #placeholder_text - 1
|
||||
local end_col = (start_row == end_row and start_col or 0)
|
||||
+ #(placeholder_text[#placeholder_text] or '')
|
||||
|
||||
return { start_row, start_col, end_row, end_col }
|
||||
end
|
||||
|
||||
--- @class vim.snippet.Tabstop
|
||||
--- @field extmark_id integer
|
||||
--- @field index integer
|
||||
--- @field bufnr integer
|
||||
local Tabstop = {}
|
||||
|
||||
--- Creates a new tabstop.
|
||||
---
|
||||
--- @package
|
||||
--- @param index integer
|
||||
--- @param bufnr integer
|
||||
--- @param range Range4
|
||||
--- @return vim.snippet.Tabstop
|
||||
function Tabstop.new(index, bufnr, range)
|
||||
local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, snippet_ns, range[1], range[2], {
|
||||
right_gravity = false,
|
||||
end_right_gravity = true,
|
||||
end_line = range[3],
|
||||
end_col = range[4],
|
||||
hl_group = 'SnippetTabstop',
|
||||
})
|
||||
|
||||
local self = setmetatable(
|
||||
{ index = index, bufnr = bufnr, extmark_id = extmark_id },
|
||||
{ __index = Tabstop }
|
||||
)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Returns the tabstop's range.
|
||||
---
|
||||
--- @package
|
||||
--- @return Range4
|
||||
function Tabstop:get_range()
|
||||
local mark =
|
||||
vim.api.nvim_buf_get_extmark_by_id(self.bufnr, snippet_ns, self.extmark_id, { details = true })
|
||||
|
||||
--- @diagnostic disable-next-line: undefined-field
|
||||
return { mark[1], mark[2], mark[3].end_row, mark[3].end_col }
|
||||
end
|
||||
|
||||
--- Returns the text spanned by the tabstop.
|
||||
---
|
||||
--- @package
|
||||
--- @return string
|
||||
function Tabstop:get_text()
|
||||
local range = self:get_range()
|
||||
return table.concat(
|
||||
vim.api.nvim_buf_get_text(self.bufnr, range[1], range[2], range[3], range[4], {}),
|
||||
'\n'
|
||||
)
|
||||
end
|
||||
|
||||
--- Sets the tabstop's text.
|
||||
---
|
||||
--- @package
|
||||
--- @param text string
|
||||
function Tabstop:set_text(text)
|
||||
local range = self:get_range()
|
||||
vim.api.nvim_buf_set_text(self.bufnr, range[1], range[2], range[3], range[4], text_to_lines(text))
|
||||
end
|
||||
|
||||
--- @class vim.snippet.Session
|
||||
--- @field bufnr integer
|
||||
--- @field tabstops table<integer, vim.snippet.Tabstop[]>
|
||||
--- @field current_tabstop vim.snippet.Tabstop
|
||||
local Session = {}
|
||||
|
||||
--- Creates a new snippet session in the current buffer.
|
||||
---
|
||||
--- @package
|
||||
--- @return vim.snippet.Session
|
||||
function Session.new()
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local self = setmetatable({
|
||||
bufnr = bufnr,
|
||||
tabstops = {},
|
||||
current_tabstop = Tabstop.new(0, bufnr, { 0, 0, 0, 0 }),
|
||||
}, { __index = Session })
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Creates the session tabstops.
|
||||
---
|
||||
--- @package
|
||||
--- @param tabstop_ranges table<integer, Range4[]>
|
||||
function Session:set_tabstops(tabstop_ranges)
|
||||
for index, ranges in pairs(tabstop_ranges) do
|
||||
for _, range in ipairs(ranges) do
|
||||
self.tabstops[index] = self.tabstops[index] or {}
|
||||
table.insert(self.tabstops[index], Tabstop.new(index, self.bufnr, range))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Returns the destination tabstop index when jumping in the given direction.
|
||||
---
|
||||
--- @package
|
||||
--- @param direction vim.snippet.Direction
|
||||
--- @return integer?
|
||||
function Session:get_dest_index(direction)
|
||||
local tabstop_indexes = vim.tbl_keys(self.tabstops) --- @type integer[]
|
||||
table.sort(tabstop_indexes)
|
||||
for i, index in ipairs(tabstop_indexes) do
|
||||
if index == self.current_tabstop.index then
|
||||
local dest_index = tabstop_indexes[i + direction] --- @type integer?
|
||||
-- When jumping forwards, $0 is the last tabstop.
|
||||
if not dest_index and direction == 1 then
|
||||
dest_index = 0
|
||||
end
|
||||
-- When jumping backwards, make sure we don't think that $0 is the first tabstop.
|
||||
if dest_index == 0 and direction == -1 then
|
||||
dest_index = nil
|
||||
end
|
||||
return dest_index
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- @class vim.snippet.Snippet
|
||||
--- @field private _session? vim.snippet.Session
|
||||
local M = { session = nil }
|
||||
|
||||
--- Select the given tabstop range.
|
||||
---
|
||||
--- @param tabstop vim.snippet.Tabstop
|
||||
local function select_tabstop(tabstop)
|
||||
--- @param keys string
|
||||
local function feedkeys(keys)
|
||||
keys = vim.api.nvim_replace_termcodes(keys, true, false, true)
|
||||
vim.api.nvim_feedkeys(keys, 'n', true)
|
||||
end
|
||||
|
||||
--- NOTE: We don't use `vim.api.nvim_win_set_cursor` here because it causes the cursor to end
|
||||
--- at the end of the selection instead of the start.
|
||||
---
|
||||
--- @param row integer
|
||||
--- @param col integer
|
||||
local function move_cursor_to(row, col)
|
||||
local line = vim.fn.getline(row) --[[ @as string ]]
|
||||
col = math.max(vim.fn.strchars(line:sub(1, col)) - 1, 0)
|
||||
feedkeys(string.format('%sG0%s', row, string.rep('<Right>', col)))
|
||||
end
|
||||
|
||||
local range = tabstop:get_range()
|
||||
local mode = vim.fn.mode()
|
||||
|
||||
-- Move the cursor to the start of the tabstop.
|
||||
vim.api.nvim_win_set_cursor(0, { range[1] + 1, range[2] })
|
||||
|
||||
-- For empty and the final tabstop, start insert mode at the end of the range.
|
||||
if tabstop.index == 0 or (range[1] == range[3] and range[2] == range[4]) then
|
||||
if mode ~= 'i' then
|
||||
if mode == 's' then
|
||||
feedkeys('<Esc>')
|
||||
end
|
||||
vim.cmd.startinsert({ bang = range[4] >= #vim.api.nvim_get_current_line() })
|
||||
end
|
||||
else
|
||||
-- Else, select the tabstop's text.
|
||||
if mode ~= 'n' then
|
||||
feedkeys('<Esc>')
|
||||
end
|
||||
move_cursor_to(range[1] + 1, range[2] + 1)
|
||||
feedkeys('v')
|
||||
move_cursor_to(range[3] + 1, range[4])
|
||||
feedkeys('o<c-g>')
|
||||
end
|
||||
end
|
||||
|
||||
--- Sets up the necessary autocommands for snippet expansion.
|
||||
---
|
||||
--- @param bufnr integer
|
||||
local function setup_autocmds(bufnr)
|
||||
vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, {
|
||||
group = snippet_group,
|
||||
desc = 'Update snippet state when the cursor moves',
|
||||
buffer = bufnr,
|
||||
callback = function()
|
||||
-- Just update the tabstop in insert and select modes.
|
||||
if not vim.fn.mode():match('^[isS]') then
|
||||
return
|
||||
end
|
||||
|
||||
-- Update the current tabstop to be the one containing the cursor.
|
||||
local cursor_row, cursor_col = cursor_pos()
|
||||
for tabstop_index, tabstops in pairs(M._session.tabstops) do
|
||||
for _, tabstop in ipairs(tabstops) do
|
||||
local range = tabstop:get_range()
|
||||
if
|
||||
(cursor_row > range[1] or (cursor_row == range[1] and cursor_col >= range[2]))
|
||||
and (cursor_row < range[3] or (cursor_row == range[3] and cursor_col <= range[4]))
|
||||
then
|
||||
M._session.current_tabstop = tabstop
|
||||
if tabstop_index ~= 0 then
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The cursor is either not on a tabstop or we reached the end, so exit the session.
|
||||
M.exit()
|
||||
return true
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
|
||||
group = snippet_group,
|
||||
desc = 'Update active tabstops when buffer text changes',
|
||||
buffer = bufnr,
|
||||
callback = function()
|
||||
if not M.active() then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Sync the tabstops in the current group.
|
||||
local current_tabstop = M._session.current_tabstop
|
||||
local current_text = current_tabstop:get_text()
|
||||
for _, tabstop in ipairs(M._session.tabstops[current_tabstop.index]) do
|
||||
if tabstop.extmark_id ~= current_tabstop.extmark_id then
|
||||
tabstop:set_text(current_text)
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
--- Expands the given snippet text.
|
||||
--- Refer to https://microsoft.github.io/language-server-protocol/specification/#snippet_syntax
|
||||
--- for the specification of valid input.
|
||||
---
|
||||
--- Tabstops are highlighted with hl-SnippetTabstop.
|
||||
---
|
||||
--- @param input string
|
||||
function M.expand(input)
|
||||
local snippet = G.parse(input)
|
||||
local snippet_text = {}
|
||||
|
||||
M._session = Session.new()
|
||||
|
||||
-- Get the placeholders we should use for each tabstop index.
|
||||
--- @type table<integer, string>
|
||||
local placeholders = {}
|
||||
for _, child in ipairs(snippet.data.children) do
|
||||
local type, data = child.type, child.data
|
||||
if type == G.NodeType.Placeholder then
|
||||
--- @cast data vim.snippet.PlaceholderData
|
||||
local tabstop, value = data.tabstop, tostring(data.value)
|
||||
if placeholders[tabstop] and placeholders[tabstop] ~= value then
|
||||
error('Snippet has multiple placeholders for tabstop $' .. tabstop)
|
||||
end
|
||||
placeholders[tabstop] = value
|
||||
end
|
||||
end
|
||||
|
||||
-- Keep track of tabstop nodes during expansion.
|
||||
--- @type table<integer, Range4[]>
|
||||
local tabstop_ranges = {}
|
||||
|
||||
--- @param index integer
|
||||
--- @param placeholder string?
|
||||
local function add_tabstop(index, placeholder)
|
||||
tabstop_ranges[index] = tabstop_ranges[index] or {}
|
||||
table.insert(tabstop_ranges[index], compute_tabstop_range(snippet_text, placeholder))
|
||||
end
|
||||
|
||||
--- Appends the given text to the snippet, taking care of indentation.
|
||||
---
|
||||
--- @param text string|string[]
|
||||
local function append_to_snippet(text)
|
||||
-- Get the base indentation based on the current line and the last line of the snippet.
|
||||
local base_indent = vim.api.nvim_get_current_line():match('^%s*') or ''
|
||||
if #snippet_text > 0 then
|
||||
base_indent = base_indent .. (snippet_text[#snippet_text]:match('^%s*') or '') --- @type string
|
||||
end
|
||||
|
||||
local lines = vim.iter.map(function(i, line)
|
||||
-- Replace tabs by spaces.
|
||||
if vim.o.expandtab then
|
||||
line = line:gsub('\t', (' '):rep(vim.fn.shiftwidth())) --- @type string
|
||||
end
|
||||
-- Add the base indentation.
|
||||
if i > 1 then
|
||||
line = base_indent .. line
|
||||
end
|
||||
return line
|
||||
end, ipairs(text_to_lines(text)))
|
||||
|
||||
table.insert(snippet_text, table.concat(lines, '\n'))
|
||||
end
|
||||
|
||||
for _, 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)
|
||||
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)
|
||||
append_to_snippet(value)
|
||||
elseif type == G.NodeType.Choice then
|
||||
--- @cast data vim.snippet.ChoiceData
|
||||
append_to_snippet(data.values[1])
|
||||
elseif type == G.NodeType.Variable then
|
||||
--- @cast data vim.snippet.VariableData
|
||||
-- Try to get the variable's value.
|
||||
local value = resolve_variable(data.name, data.default and tostring(data.default) or '')
|
||||
if not value then
|
||||
-- Unknown variable, make this a tabstop and use the variable name as a placeholder.
|
||||
value = data.name
|
||||
local tabstop_indexes = vim.tbl_keys(tabstop_ranges)
|
||||
local index = math.max(unpack((#tabstop_indexes == 0 and { 0 }) or tabstop_indexes)) + 1
|
||||
add_tabstop(index, value)
|
||||
end
|
||||
append_to_snippet(value)
|
||||
elseif type == G.NodeType.Text then
|
||||
--- @cast data vim.snippet.TextData
|
||||
append_to_snippet(data.text)
|
||||
end
|
||||
end
|
||||
|
||||
-- $0, which defaults to the end of the snippet, defines the final cursor position.
|
||||
-- Make sure the snippet has exactly one of these.
|
||||
if vim.tbl_contains(vim.tbl_keys(tabstop_ranges), 0) then
|
||||
assert(#tabstop_ranges[0] == 1, 'Snippet has multiple $0 tabstops')
|
||||
else
|
||||
add_tabstop(0)
|
||||
end
|
||||
|
||||
-- Insert the snippet text.
|
||||
local cursor_row, cursor_col = cursor_pos()
|
||||
vim.api.nvim_buf_set_text(
|
||||
M._session.bufnr,
|
||||
cursor_row,
|
||||
cursor_col,
|
||||
cursor_row,
|
||||
cursor_col,
|
||||
text_to_lines(snippet_text)
|
||||
)
|
||||
|
||||
-- Create the tabstops.
|
||||
M._session:set_tabstops(tabstop_ranges)
|
||||
|
||||
-- Jump to the first tabstop.
|
||||
M.jump(1)
|
||||
end
|
||||
|
||||
--- @alias vim.snippet.Direction -1 | 1
|
||||
|
||||
--- Returns `true` if there is an active snippet which can be jumped in the given direction.
|
||||
--- You can use this function to navigate a snippet as follows:
|
||||
---
|
||||
--- ```lua
|
||||
--- vim.keymap.set({ 'i', 's' }, '<Tab>', function()
|
||||
--- if vim.snippet.jumpable(1) then
|
||||
--- return '<cmd>lua vim.snippet.jump(1)<cr>'
|
||||
--- else
|
||||
--- return '<Tab>'
|
||||
--- end
|
||||
--- end, { expr = true })
|
||||
--- ```
|
||||
---
|
||||
--- @param direction (vim.snippet.Direction) Navigation direction. -1 for previous, 1 for next.
|
||||
--- @return boolean
|
||||
function M.jumpable(direction)
|
||||
if not M.active() then
|
||||
return false
|
||||
end
|
||||
|
||||
return M._session:get_dest_index(direction) ~= nil
|
||||
end
|
||||
|
||||
--- Jumps within the active snippet in the given direction.
|
||||
--- If the jump isn't possible, the function call does nothing.
|
||||
---
|
||||
--- You can use this function to navigate a snippet as follows:
|
||||
---
|
||||
--- ```lua
|
||||
--- vim.keymap.set({ 'i', 's' }, '<Tab>', function()
|
||||
--- if vim.snippet.jumpable(1) then
|
||||
--- return '<cmd>lua vim.snippet.jump(1)<cr>'
|
||||
--- else
|
||||
--- return '<Tab>'
|
||||
--- end
|
||||
--- end, { expr = true })
|
||||
--- ```
|
||||
---
|
||||
--- @param direction (vim.snippet.Direction) Navigation direction. -1 for previous, 1 for next.
|
||||
function M.jump(direction)
|
||||
-- Get the tabstop index to jump to.
|
||||
local dest_index = M._session and M._session:get_dest_index(direction)
|
||||
if not dest_index then
|
||||
return
|
||||
end
|
||||
|
||||
-- Find the tabstop with the lowest range.
|
||||
local tabstops = M._session.tabstops[dest_index]
|
||||
local dest = tabstops[1]
|
||||
for _, tabstop in ipairs(tabstops) do
|
||||
local dest_range, range = dest:get_range(), tabstop:get_range()
|
||||
if (range[1] < dest_range[1]) or (range[1] == dest_range[1] and range[2] < dest_range[2]) then
|
||||
dest = tabstop
|
||||
end
|
||||
end
|
||||
|
||||
-- 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 })
|
||||
|
||||
M._session.current_tabstop = dest
|
||||
select_tabstop(dest)
|
||||
|
||||
-- Restore the autocommands.
|
||||
setup_autocmds(M._session.bufnr)
|
||||
end
|
||||
|
||||
--- Returns `true` if there's an active snippet in the current buffer.
|
||||
---
|
||||
--- @return boolean
|
||||
function M.active()
|
||||
return M._session ~= nil and M._session.bufnr == vim.api.nvim_get_current_buf()
|
||||
end
|
||||
|
||||
--- Exits the current snippet.
|
||||
function M.exit()
|
||||
if not M.active() then
|
||||
return
|
||||
end
|
||||
|
||||
vim.api.nvim_clear_autocmds({ group = snippet_group, buffer = M._session.bufnr })
|
||||
vim.api.nvim_buf_clear_namespace(M._session.bufnr, snippet_ns, 0, -1)
|
||||
|
||||
M._session = nil
|
||||
end
|
||||
|
||||
return M
|
@ -165,6 +165,7 @@ CONFIG = {
|
||||
'secure.lua',
|
||||
'version.lua',
|
||||
'iter.lua',
|
||||
'snippet.lua',
|
||||
],
|
||||
'files': [
|
||||
'runtime/lua/vim/iter.lua',
|
||||
@ -181,6 +182,7 @@ CONFIG = {
|
||||
'runtime/lua/vim/secure.lua',
|
||||
'runtime/lua/vim/version.lua',
|
||||
'runtime/lua/vim/_inspector.lua',
|
||||
'runtime/lua/vim/snippet.lua',
|
||||
'runtime/lua/vim/_meta/builtin.lua',
|
||||
'runtime/lua/vim/_meta/diff.lua',
|
||||
'runtime/lua/vim/_meta/mpack.lua',
|
||||
|
@ -235,6 +235,7 @@ static const char *highlight_init_both[] = {
|
||||
"default DiagnosticDeprecated cterm=strikethrough gui=strikethrough guisp=Red",
|
||||
"default link DiagnosticUnnecessary Comment",
|
||||
"default link LspInlayHint NonText",
|
||||
"default link SnippetTabstop Visual",
|
||||
|
||||
// Text
|
||||
"default link @text.literal Comment",
|
||||
|
157
test/functional/lua/snippet_spec.lua
Normal file
157
test/functional/lua/snippet_spec.lua
Normal file
@ -0,0 +1,157 @@
|
||||
local helpers = require('test.functional.helpers')(after_each)
|
||||
|
||||
local clear = helpers.clear
|
||||
local eq = helpers.eq
|
||||
local exec_lua = helpers.exec_lua
|
||||
local feed = helpers.feed
|
||||
local matches = helpers.matches
|
||||
local pcall_err = helpers.pcall_err
|
||||
|
||||
describe('vim.snippet', function()
|
||||
before_each(function()
|
||||
clear()
|
||||
|
||||
exec_lua([[
|
||||
vim.keymap.set({ 'i', 's' }, '<Tab>', function() vim.snippet.jump(1) end, { buffer = true })
|
||||
vim.keymap.set({ 'i', 's' }, '<S-Tab>', function() vim.snippet.jump(-1) end, { buffer = true })
|
||||
]])
|
||||
end)
|
||||
after_each(clear)
|
||||
|
||||
--- @param snippet string[]
|
||||
--- @param expected string[]
|
||||
--- @param settings? string
|
||||
--- @param prefix? string
|
||||
local function test_success(snippet, expected, settings, prefix)
|
||||
if settings then
|
||||
exec_lua(settings)
|
||||
end
|
||||
if prefix then
|
||||
feed('i' .. prefix)
|
||||
end
|
||||
exec_lua('vim.snippet.expand(...)', table.concat(snippet, '\n'))
|
||||
eq(expected, helpers.buf_lines(0))
|
||||
end
|
||||
|
||||
--- @param snippet string
|
||||
--- @param err string
|
||||
local function test_fail(snippet, err)
|
||||
matches(err, pcall_err(exec_lua, string.format('vim.snippet.expand("%s")', snippet)))
|
||||
end
|
||||
|
||||
it('adds base indentation to inserted text', function()
|
||||
test_success(
|
||||
{ 'function $1($2)', ' $0', 'end' },
|
||||
{ ' function ()', ' ', ' end' },
|
||||
'',
|
||||
' '
|
||||
)
|
||||
end)
|
||||
|
||||
it('replaces tabs with spaces when expandtab is set', function()
|
||||
test_success(
|
||||
{ 'function $1($2)', '\t$0', 'end' },
|
||||
{ 'function ()', ' ', 'end' },
|
||||
[[
|
||||
vim.o.expandtab = true
|
||||
vim.o.shiftwidth = 2
|
||||
]]
|
||||
)
|
||||
end)
|
||||
|
||||
it('respects tabs when expandtab is not set', function()
|
||||
test_success(
|
||||
{ 'function $1($2)', '\t$0', 'end' },
|
||||
{ 'function ()', '\t', 'end' },
|
||||
'vim.o.expandtab = false'
|
||||
)
|
||||
end)
|
||||
|
||||
it('inserts known variable value', function()
|
||||
test_success({ '; print($TM_CURRENT_LINE)' }, { 'foo; print(foo)' }, nil, 'foo')
|
||||
end)
|
||||
|
||||
it('uses default when variable is not set', function()
|
||||
test_success({ 'print(${TM_CURRENT_WORD:foo})' }, { 'print(foo)' })
|
||||
end)
|
||||
|
||||
it('replaces unknown variables by placeholders', function()
|
||||
test_success({ 'print($UNKNOWN)' }, { 'print(UNKNOWN)' })
|
||||
end)
|
||||
|
||||
it('does not jump outside snippet range', function()
|
||||
test_success({ 'function $1($2)', ' $0', 'end' }, { 'function ()', ' ', 'end' })
|
||||
eq(false, exec_lua('return vim.snippet.jumpable(-1)'))
|
||||
feed('<Tab><Tab>i')
|
||||
eq(false, exec_lua('return vim.snippet.jumpable(1)'))
|
||||
end)
|
||||
|
||||
it('navigates backwards', function()
|
||||
test_success({ 'function $1($2) end' }, { 'function () end' })
|
||||
feed('<Tab><S-Tab>foo')
|
||||
eq({ 'function foo() end' }, helpers.buf_lines(0))
|
||||
end)
|
||||
|
||||
it('visits all tabstops', function()
|
||||
local function cursor()
|
||||
return exec_lua('return vim.api.nvim_win_get_cursor(0)')
|
||||
end
|
||||
|
||||
test_success({ 'function $1($2)', ' $0', 'end' }, { 'function ()', ' ', 'end' })
|
||||
eq({ 1, 9 }, cursor())
|
||||
feed('<Tab>')
|
||||
eq({ 1, 10 }, cursor())
|
||||
feed('<Tab>')
|
||||
eq({ 2, 2 }, cursor())
|
||||
end)
|
||||
|
||||
it('syncs text of tabstops with equal indexes', function()
|
||||
test_success({ 'var double = ${1:x} + ${1:x}' }, { 'var double = x + x' })
|
||||
feed('123')
|
||||
eq({ 'var double = 123 + 123' }, helpers.buf_lines(0))
|
||||
end)
|
||||
|
||||
it('cancels session with changes outside the snippet', function()
|
||||
test_success({ 'print($1)' }, { 'print()' })
|
||||
feed('<Esc>O-- A comment')
|
||||
eq(false, exec_lua('return vim.snippet.active()'))
|
||||
eq({ '-- A comment', 'print()' }, helpers.buf_lines(0))
|
||||
end)
|
||||
|
||||
it('handles non-consecutive tabstops', function()
|
||||
test_success({ 'class $1($3) {', ' $0', '}' }, { 'class () {', ' ', '}' })
|
||||
feed('Foo') -- First tabstop
|
||||
feed('<Tab><Tab>') -- Jump to $0
|
||||
feed('// Inside') -- Insert text
|
||||
eq({ 'class Foo() {', ' // Inside', '}' }, helpers.buf_lines(0))
|
||||
end)
|
||||
|
||||
it('handles multiline placeholders', function()
|
||||
test_success(
|
||||
{ 'public void foo() {', ' ${0:// TODO Auto-generated', ' throw;}', '}' },
|
||||
{ 'public void foo() {', ' // TODO Auto-generated', ' throw;', '}' }
|
||||
)
|
||||
end)
|
||||
|
||||
it('inserts placeholder in all tabstops when the first tabstop has the placeholder', function()
|
||||
test_success(
|
||||
{ 'for (${1:int} ${2:x} = ${3:0}; $2 < ${4:N}; $2++) {', ' $0', '}' },
|
||||
{ 'for (int x = 0; x < N; x++) {', ' ', '}' }
|
||||
)
|
||||
end)
|
||||
|
||||
it('inserts placeholder in all tabstops when a later tabstop has the placeholder', function()
|
||||
test_success(
|
||||
{ 'for (${1:int} $2 = ${3:0}; ${2:x} < ${4:N}; $2++) {', ' $0', '}' },
|
||||
{ 'for (int x = 0; x < N; x++) {', ' ', '}' }
|
||||
)
|
||||
end)
|
||||
|
||||
it('errors with multiple placeholders for the same index', function()
|
||||
test_fail('class ${1:Foo} { void ${1:foo}() {} }', 'multiple placeholders for tabstop $1')
|
||||
end)
|
||||
|
||||
it('errors with multiple $0 tabstops', function()
|
||||
test_fail('function $1() { $0 }$0', 'multiple $0 tabstops')
|
||||
end)
|
||||
end)
|
Loading…
Reference in New Issue
Block a user