aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua/vim
diff options
context:
space:
mode:
Diffstat (limited to 'runtime/lua/vim')
-rw-r--r--runtime/lua/vim/_defaults.lua16
-rw-r--r--runtime/lua/vim/_editor.lua3
-rw-r--r--runtime/lua/vim/diagnostic.lua224
-rw-r--r--runtime/lua/vim/lsp.lua5
-rw-r--r--runtime/lua/vim/lsp/_completion.lua276
-rw-r--r--runtime/lua/vim/lsp/buf.lua2
-rw-r--r--runtime/lua/vim/lsp/client.lua23
-rw-r--r--runtime/lua/vim/lsp/completion.lua734
-rw-r--r--runtime/lua/vim/lsp/handlers.lua2
-rw-r--r--runtime/lua/vim/lsp/protocol.lua10
-rw-r--r--runtime/lua/vim/shared.lua2
-rw-r--r--runtime/lua/vim/snippet.lua63
12 files changed, 1008 insertions, 352 deletions
diff --git a/runtime/lua/vim/_defaults.lua b/runtime/lua/vim/_defaults.lua
index 5b964b84a0..79fe5a8513 100644
--- a/runtime/lua/vim/_defaults.lua
+++ b/runtime/lua/vim/_defaults.lua
@@ -180,12 +180,20 @@ do
--- See |[d-default|, |]d-default|, and |CTRL-W_d-default|.
do
vim.keymap.set('n', ']d', function()
- vim.diagnostic.goto_next({ float = false })
- end, { desc = 'Jump to the next diagnostic' })
+ vim.diagnostic.jump({ count = vim.v.count1 })
+ end, { desc = 'Jump to the next diagnostic in the current buffer' })
vim.keymap.set('n', '[d', function()
- vim.diagnostic.goto_prev({ float = false })
- end, { desc = 'Jump to the previous diagnostic' })
+ vim.diagnostic.jump({ count = -vim.v.count1 })
+ end, { desc = 'Jump to the previous diagnostic in the current buffer' })
+
+ vim.keymap.set('n', ']D', function()
+ vim.diagnostic.jump({ count = math.huge, wrap = false })
+ end, { desc = 'Jump to the last diagnostic in the current buffer' })
+
+ vim.keymap.set('n', '[D', function()
+ vim.diagnostic.jump({ count = -math.huge, wrap = false })
+ end, { desc = 'Jump to the first diagnostic in the current buffer' })
vim.keymap.set('n', '<C-W>d', function()
vim.diagnostic.open_float()
diff --git a/runtime/lua/vim/_editor.lua b/runtime/lua/vim/_editor.lua
index 5e9be509c8..9f952db4fc 100644
--- a/runtime/lua/vim/_editor.lua
+++ b/runtime/lua/vim/_editor.lua
@@ -494,6 +494,7 @@ do
vim.t = make_dict_accessor('t')
end
+--- @deprecated
--- Gets a dict of line segment ("chunk") positions for the region from `pos1` to `pos2`.
---
--- Input and output positions are byte positions, (0,0)-indexed. "End of line" column
@@ -507,6 +508,8 @@ end
---@return table region Dict of the form `{linenr = {startcol,endcol}}`. `endcol` is exclusive, and
---whole lines are returned as `{startcol,endcol} = {0,-1}`.
function vim.region(bufnr, pos1, pos2, regtype, inclusive)
+ vim.deprecate('vim.region', 'vim.fn.getregionpos()', '0.13')
+
if not vim.api.nvim_buf_is_loaded(bufnr) then
vim.fn.bufload(bufnr)
end
diff --git a/runtime/lua/vim/diagnostic.lua b/runtime/lua/vim/diagnostic.lua
index 348204abb7..dca7698356 100644
--- a/runtime/lua/vim/diagnostic.lua
+++ b/runtime/lua/vim/diagnostic.lua
@@ -42,7 +42,7 @@ local M = {}
---
--- @field namespace? integer
---- Each of the configuration options below accepts one of the following:
+--- Many of the configuration options below accept one of the following:
--- - `false`: Disable this feature
--- - `true`: Enable this feature, use default settings.
--- - `table`: Enable this feature with overrides. Use an empty table to use default values.
@@ -78,6 +78,9 @@ local M = {}
--- - {reverse}? (boolean) Reverse sort order
--- (default: `false`)
--- @field severity_sort? boolean|{reverse?:boolean}
+---
+--- Default values for |vim.diagnostic.jump()|. See |vim.diagnostic.Opts.Jump|.
+--- @field jump? vim.diagnostic.Opts.Jump
--- @class (private) vim.diagnostic.OptsResolved
--- @field float vim.diagnostic.Opts.Float
@@ -241,6 +244,20 @@ local M = {}
--- whole line the sign is placed in.
--- @field linehl? table<vim.diagnostic.Severity,string>
+--- @class vim.diagnostic.Opts.Jump
+---
+--- Default value of the {float} parameter of |vim.diagnostic.jump()|.
+--- @field float? boolean|vim.diagnostic.Opts.Float
+---
+--- Default value of the {wrap} parameter of |vim.diagnostic.jump()|.
+--- @field wrap? boolean
+---
+--- Default value of the {severity} parameter of |vim.diagnostic.jump()|.
+--- @field severity? vim.diagnostic.SeverityFilter
+---
+--- Default value of the {_highest} parameter of |vim.diagnostic.jump()|.
+--- @field package _highest? boolean
+
-- TODO: inherit from `vim.diagnostic.Opts`, implement its fields.
--- Optional filters |kwargs|, or `nil` for all.
--- @class vim.diagnostic.Filter
@@ -284,6 +301,13 @@ local global_diagnostic_options = {
float = true,
update_in_insert = false,
severity_sort = false,
+ jump = {
+ -- Do not show floating window
+ float = false,
+
+ -- Wrap around buffer
+ wrap = true,
+ },
}
--- @class (private) vim.diagnostic.Handler
@@ -835,21 +859,36 @@ local function filter_highest(diagnostics)
end
end
---- @param position {[1]: integer, [2]: integer}
--- @param search_forward boolean
---- @param bufnr integer
---- @param opts vim.diagnostic.GotoOpts
---- @param namespace integer[]|integer
+--- @param opts vim.diagnostic.JumpOpts?
--- @return vim.Diagnostic?
-local function next_diagnostic(position, search_forward, bufnr, opts, namespace)
+local function next_diagnostic(search_forward, opts)
+ opts = opts or {}
+
+ -- Support deprecated win_id alias
+ if opts.win_id then
+ vim.deprecate('opts.win_id', 'opts.winid', '0.13')
+ opts.winid = opts.win_id
+ opts.win_id = nil
+ end
+
+ -- Support deprecated cursor_position alias
+ if opts.cursor_position then
+ vim.deprecate('opts.cursor_position', 'opts.pos', '0.13')
+ opts.pos = opts.cursor_position
+ opts.cursor_position = nil
+ end
+
+ local winid = opts.winid or api.nvim_get_current_win()
+ local bufnr = api.nvim_win_get_buf(winid)
+ local position = opts.pos or api.nvim_win_get_cursor(winid)
+
+ -- Adjust row to be 0-indexed
position[1] = position[1] - 1
- bufnr = get_bufnr(bufnr)
- local wrap = if_nil(opts.wrap, true)
- local get_opts = vim.deepcopy(opts)
- get_opts.namespace = get_opts.namespace or namespace
+ local wrap = if_nil(opts.wrap, true)
- local diagnostics = get_diagnostics(bufnr, get_opts, true)
+ local diagnostics = get_diagnostics(bufnr, opts, true)
if opts._highest then
filter_highest(diagnostics)
@@ -902,32 +941,41 @@ local function next_diagnostic(position, search_forward, bufnr, opts, namespace)
end
end
---- @param opts vim.diagnostic.GotoOpts?
---- @param pos {[1]:integer,[2]:integer}|false
-local function diagnostic_move_pos(opts, pos)
- opts = opts or {}
-
- local float = if_nil(opts.float, true)
- local win_id = opts.win_id or api.nvim_get_current_win()
-
- if not pos then
+--- Move the cursor to the given diagnostic.
+---
+--- @param diagnostic vim.Diagnostic?
+--- @param opts vim.diagnostic.JumpOpts?
+local function goto_diagnostic(diagnostic, opts)
+ if not diagnostic then
api.nvim_echo({ { 'No more valid diagnostics to move to', 'WarningMsg' } }, true, {})
return
end
- api.nvim_win_call(win_id, function()
+ opts = opts or {}
+
+ -- Support deprecated win_id alias
+ if opts.win_id then
+ vim.deprecate('opts.win_id', 'opts.winid', '0.13')
+ opts.winid = opts.win_id
+ opts.win_id = nil
+ end
+
+ local winid = opts.winid or api.nvim_get_current_win()
+
+ api.nvim_win_call(winid, function()
-- Save position in the window's jumplist
vim.cmd("normal! m'")
- api.nvim_win_set_cursor(win_id, { pos[1] + 1, pos[2] })
+ api.nvim_win_set_cursor(winid, { diagnostic.lnum + 1, diagnostic.col })
-- Open folds under the cursor
vim.cmd('normal! zv')
end)
+ local float = if_nil(opts.float, true)
if float then
local float_opts = type(float) == 'table' and float or {}
vim.schedule(function()
M.open_float(vim.tbl_extend('keep', float_opts, {
- bufnr = api.nvim_win_get_buf(win_id),
+ bufnr = api.nvim_win_get_buf(winid),
scope = 'cursor',
focus = false,
}))
@@ -1114,24 +1162,24 @@ end
--- Get the previous diagnostic closest to the cursor position.
---
----@param opts? vim.diagnostic.GotoOpts
+---@param opts? vim.diagnostic.JumpOpts
---@return vim.Diagnostic? : Previous diagnostic
function M.get_prev(opts)
- opts = opts or {}
-
- local win_id = opts.win_id or api.nvim_get_current_win()
- local bufnr = api.nvim_win_get_buf(win_id)
- local cursor_position = opts.cursor_position or api.nvim_win_get_cursor(win_id)
-
- return next_diagnostic(cursor_position, false, bufnr, opts, opts.namespace)
+ return next_diagnostic(false, opts)
end
--- Return the position of the previous diagnostic in the current buffer.
---
----@param opts? vim.diagnostic.GotoOpts
+---@param opts? vim.diagnostic.JumpOpts
---@return table|false: Previous diagnostic position as a `(row, col)` tuple
--- or `false` if there is no prior diagnostic.
+---@deprecated
function M.get_prev_pos(opts)
+ vim.deprecate(
+ 'vim.diagnostic.get_prev_pos()',
+ 'access the lnum and col fields from get_prev() instead',
+ '0.13'
+ )
local prev = M.get_prev(opts)
if not prev then
return false
@@ -1141,31 +1189,33 @@ function M.get_prev_pos(opts)
end
--- Move to the previous diagnostic in the current buffer.
----@param opts? vim.diagnostic.GotoOpts
+---@param opts? vim.diagnostic.JumpOpts
+---@deprecated
function M.goto_prev(opts)
- return diagnostic_move_pos(opts, M.get_prev_pos(opts))
+ vim.deprecate('vim.diagnostic.goto_prev()', 'vim.diagnostic.jump()', '0.13')
+ goto_diagnostic(M.get_prev(opts), opts)
end
--- Get the next diagnostic closest to the cursor position.
---
----@param opts? vim.diagnostic.GotoOpts
+---@param opts? vim.diagnostic.JumpOpts
---@return vim.Diagnostic? : Next diagnostic
function M.get_next(opts)
- opts = opts or {}
-
- local win_id = opts.win_id or api.nvim_get_current_win()
- local bufnr = api.nvim_win_get_buf(win_id)
- local cursor_position = opts.cursor_position or api.nvim_win_get_cursor(win_id)
-
- return next_diagnostic(cursor_position, true, bufnr, opts, opts.namespace)
+ return next_diagnostic(true, opts)
end
--- Return the position of the next diagnostic in the current buffer.
---
----@param opts? vim.diagnostic.GotoOpts
+---@param opts? vim.diagnostic.JumpOpts
---@return table|false : Next diagnostic position as a `(row, col)` tuple or false if no next
--- diagnostic.
+---@deprecated
function M.get_next_pos(opts)
+ vim.deprecate(
+ 'vim.diagnostic.get_next_pos()',
+ 'access the lnum and col fields from get_next() instead',
+ '0.13'
+ )
local next = M.get_next(opts)
if not next then
return false
@@ -1186,13 +1236,23 @@ end
--- See |diagnostic-severity|.
--- @field severity? vim.diagnostic.SeverityFilter
---- Configuration table with the following keys:
---- @class vim.diagnostic.GotoOpts : vim.diagnostic.GetOpts
+--- Configuration table with the keys listed below. Some parameters can have their default values
+--- changed with |vim.diagnostic.config()|.
+--- @class vim.diagnostic.JumpOpts : vim.diagnostic.GetOpts
---
---- Cursor position as a `(row, col)` tuple.
---- See |nvim_win_get_cursor()|.
---- (default: current cursor position)
---- @field cursor_position? {[1]:integer,[2]:integer}
+--- The diagnostic to jump to. Mutually exclusive with {count}, {namespace},
+--- and {severity}.
+--- @field diagnostic? vim.Diagnostic
+---
+--- The number of diagnostics to move by, starting from {pos}. A positive
+--- integer moves forward by {count} diagnostics, while a negative integer moves
+--- backward by {count} diagnostics. Mutually exclusive with {diagnostic}.
+--- @field count? integer
+---
+--- Cursor position as a `(row, col)` tuple. See |nvim_win_get_cursor()|. Used
+--- to find the nearest diagnostic when {count} is used. Only used when {count}
+--- is non-nil. Default is the current cursor position.
+--- @field pos? {[1]:integer,[2]:integer}
---
--- Whether to loop around file or not. Similar to 'wrapscan'.
--- (default: `true`)
@@ -1214,13 +1274,71 @@ end
---
--- Window ID
--- (default: `0`)
---- @field win_id? integer
+--- @field winid? integer
+
+--- Move to a diagnostic.
+---
+--- @param opts vim.diagnostic.JumpOpts
+--- @return vim.Diagnostic? # The diagnostic that was moved to.
+function M.jump(opts)
+ vim.validate('opts', opts, 'table')
+
+ -- One of "diagnostic" or "count" must be provided
+ assert(
+ opts.diagnostic or opts.count,
+ 'One of "diagnostic" or "count" must be specified in the options to vim.diagnostic.jump()'
+ )
+
+ -- Apply configuration options from vim.diagnostic.config()
+ opts = vim.tbl_deep_extend('keep', opts, global_diagnostic_options.jump)
+
+ if opts.diagnostic then
+ goto_diagnostic(opts.diagnostic, opts)
+ return opts.diagnostic
+ end
+
+ local count = opts.count
+ if count == 0 then
+ return nil
+ end
+
+ -- Support deprecated cursor_position alias
+ if opts.cursor_position then
+ vim.deprecate('opts.cursor_position', 'opts.pos', '0.13')
+ opts.pos = opts.cursor_position
+ opts.cursor_position = nil
+ end
+
+ local diag = nil
+ while count ~= 0 do
+ local next = next_diagnostic(count > 0, opts)
+ if not next then
+ break
+ end
+
+ -- Update cursor position
+ opts.pos = { next.lnum + 1, next.col }
+
+ if count > 0 then
+ count = count - 1
+ else
+ count = count + 1
+ end
+ diag = next
+ end
+
+ goto_diagnostic(diag, opts)
+
+ return diag
+end
--- Move to the next diagnostic.
---
----@param opts? vim.diagnostic.GotoOpts
+---@param opts? vim.diagnostic.JumpOpts
+---@deprecated
function M.goto_next(opts)
- diagnostic_move_pos(opts, M.get_next_pos(opts))
+ vim.deprecate('vim.diagnostic.goto_next()', 'vim.diagnostic.jump()', '0.13')
+ goto_diagnostic(M.get_next(opts), opts)
end
M.handlers.signs = {
@@ -1688,7 +1806,7 @@ end
---
---@param opts vim.diagnostic.Opts.Float?
---@return integer? float_bufnr
----@return integer? win_id
+---@return integer? winid
function M.open_float(opts, ...)
-- Support old (bufnr, opts) signature
local bufnr --- @type integer?
diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua
index 60b3f3e502..94c31359da 100644
--- a/runtime/lua/vim/lsp.lua
+++ b/runtime/lua/vim/lsp.lua
@@ -3,7 +3,6 @@ local validate = vim.validate
local lsp = vim._defer_require('vim.lsp', {
_changetracking = ..., --- @module 'vim.lsp._changetracking'
- _completion = ..., --- @module 'vim.lsp._completion'
_dynamic = ..., --- @module 'vim.lsp._dynamic'
_snippet_grammar = ..., --- @module 'vim.lsp._snippet_grammar'
_tagfunc = ..., --- @module 'vim.lsp._tagfunc'
@@ -11,6 +10,7 @@ local lsp = vim._defer_require('vim.lsp', {
buf = ..., --- @module 'vim.lsp.buf'
client = ..., --- @module 'vim.lsp.client'
codelens = ..., --- @module 'vim.lsp.codelens'
+ completion = ..., --- @module 'vim.lsp.completion'
diagnostic = ..., --- @module 'vim.lsp.diagnostic'
handlers = ..., --- @module 'vim.lsp.handlers'
inlay_hint = ..., --- @module 'vim.lsp.inlay_hint'
@@ -1003,8 +1003,7 @@ end
--- - findstart=0: column where the completion starts, or -2 or -3
--- - findstart=1: list of matches (actually just calls |complete()|)
function lsp.omnifunc(findstart, base)
- log.debug('omnifunc.findstart', { findstart = findstart, base = base })
- return vim.lsp._completion.omnifunc(findstart, base)
+ return vim.lsp.completion._omnifunc(findstart, base)
end
--- @class vim.lsp.formatexpr.Opts
diff --git a/runtime/lua/vim/lsp/_completion.lua b/runtime/lua/vim/lsp/_completion.lua
deleted file mode 100644
index a169f96565..0000000000
--- a/runtime/lua/vim/lsp/_completion.lua
+++ /dev/null
@@ -1,276 +0,0 @@
-local M = {}
-local api = vim.api
-local lsp = vim.lsp
-local protocol = lsp.protocol
-local ms = protocol.Methods
-
---- @alias vim.lsp.CompletionResult lsp.CompletionList | lsp.CompletionItem[]
-
--- TODO(mariasolos): Remove this declaration once we figure out a better way to handle
--- literal/anonymous types (see https://github.com/neovim/neovim/pull/27542/files#r1495259331).
---- @class lsp.ItemDefaults
---- @field editRange lsp.Range | { insert: lsp.Range, replace: lsp.Range } | nil
---- @field insertTextFormat lsp.InsertTextFormat?
---- @field insertTextMode lsp.InsertTextMode?
---- @field data any
-
----@param input string unparsed snippet
----@return string parsed snippet
-local function parse_snippet(input)
- local ok, parsed = pcall(function()
- return vim.lsp._snippet_grammar.parse(input)
- end)
- return ok and tostring(parsed) or input
-end
-
---- Returns text that should be inserted when selecting completion item. The
---- precedence is as follows: textEdit.newText > insertText > label
----
---- See https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
----
----@param item lsp.CompletionItem
----@return string
-local function get_completion_word(item)
- if item.textEdit ~= nil and item.textEdit.newText ~= nil and item.textEdit.newText ~= '' then
- if item.insertTextFormat == protocol.InsertTextFormat.PlainText then
- return item.textEdit.newText
- else
- return parse_snippet(item.textEdit.newText)
- end
- elseif item.insertText ~= nil and item.insertText ~= '' then
- if item.insertTextFormat == protocol.InsertTextFormat.PlainText then
- return item.insertText
- else
- return parse_snippet(item.insertText)
- end
- end
- return item.label
-end
-
---- Applies the given defaults to the completion item, modifying it in place.
----
---- @param item lsp.CompletionItem
---- @param defaults lsp.ItemDefaults?
-local function apply_defaults(item, defaults)
- if not defaults then
- return
- end
-
- item.insertTextFormat = item.insertTextFormat or defaults.insertTextFormat
- item.insertTextMode = item.insertTextMode or defaults.insertTextMode
- item.data = item.data or defaults.data
- if defaults.editRange then
- local textEdit = item.textEdit or {}
- item.textEdit = textEdit
- textEdit.newText = textEdit.newText or item.textEditText or item.insertText
- if defaults.editRange.start then
- textEdit.range = textEdit.range or defaults.editRange
- elseif defaults.editRange.insert then
- textEdit.insert = defaults.editRange.insert
- textEdit.replace = defaults.editRange.replace
- end
- end
-end
-
----@param result vim.lsp.CompletionResult
----@return lsp.CompletionItem[]
-local function get_items(result)
- if result.items then
- for _, item in ipairs(result.items) do
- ---@diagnostic disable-next-line: param-type-mismatch
- apply_defaults(item, result.itemDefaults)
- end
- return result.items
- else
- return result
- end
-end
-
---- Turns the result of a `textDocument/completion` request into vim-compatible
---- |complete-items|.
----
----@param result vim.lsp.CompletionResult Result of `textDocument/completion`
----@param prefix string prefix to filter the completion items
----@return table[]
----@see complete-items
-function M._lsp_to_complete_items(result, prefix)
- local items = get_items(result)
- if vim.tbl_isempty(items) then
- return {}
- end
-
- local function matches_prefix(item)
- return vim.startswith(get_completion_word(item), prefix)
- end
-
- items = vim.tbl_filter(matches_prefix, items) --[[@as lsp.CompletionItem[]|]]
- table.sort(items, function(a, b)
- return (a.sortText or a.label) < (b.sortText or b.label)
- end)
-
- local matches = {}
- for _, item in ipairs(items) do
- local info = ''
- local documentation = item.documentation
- if documentation then
- if type(documentation) == 'string' and documentation ~= '' then
- info = documentation
- elseif type(documentation) == 'table' and type(documentation.value) == 'string' then
- info = documentation.value
- else
- vim.notify(
- ('invalid documentation value %s'):format(vim.inspect(documentation)),
- vim.log.levels.WARN
- )
- end
- end
- local word = get_completion_word(item)
- table.insert(matches, {
- word = word,
- abbr = item.label,
- kind = protocol.CompletionItemKind[item.kind] or 'Unknown',
- menu = item.detail or '',
- info = #info > 0 and info or nil,
- icase = 1,
- dup = 1,
- empty = 1,
- user_data = {
- nvim = {
- lsp = {
- completion_item = item,
- },
- },
- },
- })
- end
- return matches
-end
-
----@param lnum integer 0-indexed
----@param items lsp.CompletionItem[]
-local function adjust_start_col(lnum, line, items, encoding)
- local min_start_char = nil
- for _, item in pairs(items) do
- if item.textEdit and item.textEdit.range.start.line == lnum then
- if min_start_char and min_start_char ~= item.textEdit.range.start.character then
- return nil
- end
- min_start_char = item.textEdit.range.start.character
- end
- end
- if min_start_char then
- return vim.lsp.util._str_byteindex_enc(line, min_start_char, encoding)
- else
- return nil
- end
-end
-
----@private
----@param line string line content
----@param lnum integer 0-indexed line number
----@param client_start_boundary integer 0-indexed word boundary
----@param server_start_boundary? integer 0-indexed word boundary, based on textEdit.range.start.character
----@param result vim.lsp.CompletionResult
----@param encoding string
----@return table[] matches
----@return integer? server_start_boundary
-function M._convert_results(
- line,
- lnum,
- cursor_col,
- client_start_boundary,
- server_start_boundary,
- result,
- encoding
-)
- -- Completion response items may be relative to a position different than `client_start_boundary`.
- -- Concrete example, with lua-language-server:
- --
- -- require('plenary.asy|
- -- ▲ ▲ ▲
- -- │ │ └── cursor_pos: 20
- -- │ └────── client_start_boundary: 17
- -- └────────────── textEdit.range.start.character: 9
- -- .newText = 'plenary.async'
- -- ^^^
- -- prefix (We'd remove everything not starting with `asy`,
- -- so we'd eliminate the `plenary.async` result
- --
- -- `adjust_start_col` is used to prefer the language server boundary.
- --
- local candidates = get_items(result)
- local curstartbyte = adjust_start_col(lnum, line, candidates, encoding)
- if server_start_boundary == nil then
- server_start_boundary = curstartbyte
- elseif curstartbyte ~= nil and curstartbyte ~= server_start_boundary then
- server_start_boundary = client_start_boundary
- end
- local prefix = line:sub((server_start_boundary or client_start_boundary) + 1, cursor_col)
- local matches = M._lsp_to_complete_items(result, prefix)
- return matches, server_start_boundary
-end
-
----@param findstart integer 0 or 1, decides behavior
----@param base integer findstart=0, text to match against
----@return integer|table Decided by {findstart}:
---- - findstart=0: column where the completion starts, or -2 or -3
---- - findstart=1: list of matches (actually just calls |complete()|)
-function M.omnifunc(findstart, base)
- assert(base) -- silence luals
- local bufnr = api.nvim_get_current_buf()
- local clients = lsp.get_clients({ bufnr = bufnr, method = ms.textDocument_completion })
- local remaining = #clients
- if remaining == 0 then
- return findstart == 1 and -1 or {}
- end
-
- local win = api.nvim_get_current_win()
- local cursor = api.nvim_win_get_cursor(win)
- local lnum = cursor[1] - 1
- local cursor_col = cursor[2]
- local line = api.nvim_get_current_line()
- local line_to_cursor = line:sub(1, cursor_col)
- local client_start_boundary = vim.fn.match(line_to_cursor, '\\k*$') --[[@as integer]]
- local server_start_boundary = nil
- local items = {}
-
- local function on_done()
- local mode = api.nvim_get_mode()['mode']
- if mode == 'i' or mode == 'ic' then
- vim.fn.complete((server_start_boundary or client_start_boundary) + 1, items)
- end
- end
-
- local util = vim.lsp.util
- for _, client in ipairs(clients) do
- local params = util.make_position_params(win, client.offset_encoding)
- client.request(ms.textDocument_completion, params, function(err, result)
- if err then
- vim.lsp.log.warn(err.message)
- end
- if result and vim.fn.mode() == 'i' then
- local matches
- matches, server_start_boundary = M._convert_results(
- line,
- lnum,
- cursor_col,
- client_start_boundary,
- server_start_boundary,
- result,
- client.offset_encoding
- )
- vim.list_extend(items, matches)
- end
- remaining = remaining - 1
- if remaining == 0 then
- vim.schedule(on_done)
- end
- end, bufnr)
- end
-
- -- Return -2 to signal that we should continue completion so that we can
- -- async complete.
- return -2
-end
-
-return M
diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua
index 49833eaeec..299b68e134 100644
--- a/runtime/lua/vim/lsp/buf.lua
+++ b/runtime/lua/vim/lsp/buf.lua
@@ -135,7 +135,7 @@ end
---@param mode "v"|"V"
---@return table {start={row,col}, end={row,col}} using (1, 0) indexing
local function range_from_selection(bufnr, mode)
- -- TODO: Use `vim.region()` instead https://github.com/neovim/neovim/pull/13896
+ -- TODO: Use `vim.fn.getregionpos()` instead.
-- [bufnum, lnum, col, off]; both row and column 1-indexed
local start = vim.fn.getpos('v')
diff --git a/runtime/lua/vim/lsp/client.lua b/runtime/lua/vim/lsp/client.lua
index 58ea7d02b3..327cd19125 100644
--- a/runtime/lua/vim/lsp/client.lua
+++ b/runtime/lua/vim/lsp/client.lua
@@ -868,7 +868,8 @@ end
--- @param command lsp.Command
--- @param context? {bufnr: integer}
--- @param handler? lsp.Handler only called if a server command
-function Client:_exec_cmd(command, context, handler)
+--- @param on_unsupported? function handler invoked when the command is not supported by the client.
+function Client:_exec_cmd(command, context, handler, on_unsupported)
context = vim.deepcopy(context or {}, true) --[[@as lsp.HandlerContext]]
context.bufnr = context.bufnr or api.nvim_get_current_buf()
context.client_id = self.id
@@ -882,14 +883,18 @@ function Client:_exec_cmd(command, context, handler)
local command_provider = self.server_capabilities.executeCommandProvider
local commands = type(command_provider) == 'table' and command_provider.commands or {}
if not vim.list_contains(commands, cmdname) then
- vim.notify_once(
- string.format(
- 'Language server `%s` does not support command `%s`. This command may require a client extension.',
- self.name,
- cmdname
- ),
- vim.log.levels.WARN
- )
+ if on_unsupported then
+ on_unsupported()
+ else
+ vim.notify_once(
+ string.format(
+ 'Language server `%s` does not support command `%s`. This command may require a client extension.',
+ self.name,
+ cmdname
+ ),
+ vim.log.levels.WARN
+ )
+ end
return
end
-- Not using command directly to exclude extra properties,
diff --git a/runtime/lua/vim/lsp/completion.lua b/runtime/lua/vim/lsp/completion.lua
new file mode 100644
index 0000000000..25b3d53c8c
--- /dev/null
+++ b/runtime/lua/vim/lsp/completion.lua
@@ -0,0 +1,734 @@
+local M = {}
+
+local api = vim.api
+local lsp = vim.lsp
+local protocol = lsp.protocol
+local ms = protocol.Methods
+
+local rtt_ms = 50
+local ns_to_ms = 0.000001
+
+--- @alias vim.lsp.CompletionResult lsp.CompletionList | lsp.CompletionItem[]
+
+-- TODO(mariasolos): Remove this declaration once we figure out a better way to handle
+-- literal/anonymous types (see https://github.com/neovim/neovim/pull/27542/files#r1495259331).
+--- @nodoc
+--- @class lsp.ItemDefaults
+--- @field editRange lsp.Range | { insert: lsp.Range, replace: lsp.Range } | nil
+--- @field insertTextFormat lsp.InsertTextFormat?
+--- @field insertTextMode lsp.InsertTextMode?
+--- @field data any
+
+--- @nodoc
+--- @class vim.lsp.completion.BufHandle
+--- @field clients table<integer, vim.lsp.Client>
+--- @field triggers table<string, vim.lsp.Client[]>
+
+--- @type table<integer, vim.lsp.completion.BufHandle>
+local buf_handles = {}
+
+--- @nodoc
+--- @class vim.lsp.completion.Context
+local Context = {
+ cursor = nil, --- @type { [1]: integer, [2]: integer }?
+ last_request_time = nil, --- @type integer?
+ pending_requests = {}, --- @type function[]
+ isIncomplete = false,
+}
+
+--- @nodoc
+function Context:cancel_pending()
+ for _, cancel in ipairs(self.pending_requests) do
+ cancel()
+ end
+
+ self.pending_requests = {}
+end
+
+--- @nodoc
+function Context:reset()
+ -- Note that the cursor isn't reset here, it needs to survive a `CompleteDone` event.
+ self.isIncomplete = false
+ self.last_request_time = nil
+ self:cancel_pending()
+end
+
+--- @type uv.uv_timer_t?
+local completion_timer = nil
+
+--- @return uv.uv_timer_t
+local function new_timer()
+ return assert(vim.uv.new_timer())
+end
+
+local function reset_timer()
+ if completion_timer then
+ completion_timer:stop()
+ completion_timer:close()
+ end
+
+ completion_timer = nil
+end
+
+--- @param window integer
+--- @param warmup integer
+--- @return fun(sample: number): number
+local function exp_avg(window, warmup)
+ local count = 0
+ local sum = 0
+ local value = 0
+
+ return function(sample)
+ if count < warmup then
+ count = count + 1
+ sum = sum + sample
+ value = sum / count
+ else
+ local factor = 2.0 / (window + 1)
+ value = value * (1 - factor) + sample * factor
+ end
+ return value
+ end
+end
+local compute_new_average = exp_avg(10, 10)
+
+--- @return number
+local function next_debounce()
+ if not Context.last_request_time then
+ return rtt_ms
+ end
+
+ local ms_since_request = (vim.uv.hrtime() - Context.last_request_time) * ns_to_ms
+ return math.max((ms_since_request - rtt_ms) * -1, 0)
+end
+
+--- @param input string Unparsed snippet
+--- @return string # Parsed snippet if successful, else returns its input
+local function parse_snippet(input)
+ local ok, parsed = pcall(function()
+ return lsp._snippet_grammar.parse(input)
+ end)
+ return ok and tostring(parsed) or input
+end
+
+--- @param item lsp.CompletionItem
+--- @param suffix? string
+local function apply_snippet(item, suffix)
+ if item.textEdit then
+ vim.snippet.expand(item.textEdit.newText .. suffix)
+ elseif item.insertText then
+ vim.snippet.expand(item.insertText .. suffix)
+ end
+end
+
+--- Returns text that should be inserted when a selecting completion item. The
+--- precedence is as follows: textEdit.newText > insertText > label
+---
+--- See https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
+---
+--- @param item lsp.CompletionItem
+--- @return string
+local function get_completion_word(item)
+ if item.textEdit ~= nil and item.textEdit.newText ~= nil and item.textEdit.newText ~= '' then
+ if item.insertTextFormat == protocol.InsertTextFormat.PlainText then
+ return item.textEdit.newText
+ else
+ return parse_snippet(item.textEdit.newText)
+ end
+ elseif item.insertText ~= nil and item.insertText ~= '' then
+ if item.insertTextFormat == protocol.InsertTextFormat.PlainText then
+ return item.insertText
+ else
+ return parse_snippet(item.insertText)
+ end
+ end
+ return item.label
+end
+
+--- Applies the given defaults to the completion item, modifying it in place.
+---
+--- @param item lsp.CompletionItem
+--- @param defaults lsp.ItemDefaults?
+local function apply_defaults(item, defaults)
+ if not defaults then
+ return
+ end
+
+ item.insertTextFormat = item.insertTextFormat or defaults.insertTextFormat
+ item.insertTextMode = item.insertTextMode or defaults.insertTextMode
+ item.data = item.data or defaults.data
+ if defaults.editRange then
+ local textEdit = item.textEdit or {}
+ item.textEdit = textEdit
+ textEdit.newText = textEdit.newText or item.textEditText or item.insertText
+ if defaults.editRange.start then
+ textEdit.range = textEdit.range or defaults.editRange
+ elseif defaults.editRange.insert then
+ textEdit.insert = defaults.editRange.insert
+ textEdit.replace = defaults.editRange.replace
+ end
+ end
+end
+
+--- @param result vim.lsp.CompletionResult
+--- @return lsp.CompletionItem[]
+local function get_items(result)
+ if result.items then
+ -- When we have a list, apply the defaults and return an array of items.
+ for _, item in ipairs(result.items) do
+ ---@diagnostic disable-next-line: param-type-mismatch
+ apply_defaults(item, result.itemDefaults)
+ end
+ return result.items
+ else
+ -- Else just return the items as they are.
+ return result
+ end
+end
+
+--- Turns the result of a `textDocument/completion` request into vim-compatible
+--- |complete-items|.
+---
+--- @private
+--- @param result vim.lsp.CompletionResult Result of `textDocument/completion`
+--- @param prefix string prefix to filter the completion items
+--- @param client_id integer? Client ID
+--- @return table[]
+--- @see complete-items
+function M._lsp_to_complete_items(result, prefix, client_id)
+ local items = get_items(result)
+ if vim.tbl_isempty(items) then
+ return {}
+ end
+
+ local function matches_prefix(item)
+ return vim.startswith(get_completion_word(item), prefix)
+ end
+
+ items = vim.tbl_filter(matches_prefix, items) --[[@as lsp.CompletionItem[]|]]
+ table.sort(items, function(a, b)
+ return (a.sortText or a.label) < (b.sortText or b.label)
+ end)
+
+ local matches = {}
+ for _, item in ipairs(items) do
+ local info = ''
+ local documentation = item.documentation
+ if documentation then
+ if type(documentation) == 'string' and documentation ~= '' then
+ info = documentation
+ elseif type(documentation) == 'table' and type(documentation.value) == 'string' then
+ info = documentation.value
+ else
+ vim.notify(
+ ('invalid documentation value %s'):format(vim.inspect(documentation)),
+ vim.log.levels.WARN
+ )
+ end
+ end
+ local word = get_completion_word(item)
+ table.insert(matches, {
+ word = word,
+ abbr = item.label,
+ kind = protocol.CompletionItemKind[item.kind] or 'Unknown',
+ menu = item.detail or '',
+ info = #info > 0 and info or '',
+ icase = 1,
+ dup = 1,
+ empty = 1,
+ user_data = {
+ nvim = {
+ lsp = {
+ completion_item = item,
+ client_id = client_id,
+ },
+ },
+ },
+ })
+ end
+ return matches
+end
+
+--- @param lnum integer 0-indexed
+--- @param line string
+--- @param items lsp.CompletionItem[]
+--- @param encoding string
+--- @return integer?
+local function adjust_start_col(lnum, line, items, encoding)
+ local min_start_char = nil
+ for _, item in pairs(items) do
+ if item.textEdit and item.textEdit.range.start.line == lnum then
+ if min_start_char and min_start_char ~= item.textEdit.range.start.character then
+ return nil
+ end
+ min_start_char = item.textEdit.range.start.character
+ end
+ end
+ if min_start_char then
+ return lsp.util._str_byteindex_enc(line, min_start_char, encoding)
+ else
+ return nil
+ end
+end
+
+--- @private
+--- @param line string line content
+--- @param lnum integer 0-indexed line number
+--- @param cursor_col integer
+--- @param client_id integer client ID
+--- @param client_start_boundary integer 0-indexed word boundary
+--- @param server_start_boundary? integer 0-indexed word boundary, based on textEdit.range.start.character
+--- @param result vim.lsp.CompletionResult
+--- @param encoding string
+--- @return table[] matches
+--- @return integer? server_start_boundary
+function M._convert_results(
+ line,
+ lnum,
+ cursor_col,
+ client_id,
+ client_start_boundary,
+ server_start_boundary,
+ result,
+ encoding
+)
+ -- Completion response items may be relative to a position different than `client_start_boundary`.
+ -- Concrete example, with lua-language-server:
+ --
+ -- require('plenary.asy|
+ -- ▲ ▲ ▲
+ -- │ │ └── cursor_pos: 20
+ -- │ └────── client_start_boundary: 17
+ -- └────────────── textEdit.range.start.character: 9
+ -- .newText = 'plenary.async'
+ -- ^^^
+ -- prefix (We'd remove everything not starting with `asy`,
+ -- so we'd eliminate the `plenary.async` result
+ --
+ -- `adjust_start_col` is used to prefer the language server boundary.
+ --
+ local candidates = get_items(result)
+ local curstartbyte = adjust_start_col(lnum, line, candidates, encoding)
+ if server_start_boundary == nil then
+ server_start_boundary = curstartbyte
+ elseif curstartbyte ~= nil and curstartbyte ~= server_start_boundary then
+ server_start_boundary = client_start_boundary
+ end
+ local prefix = line:sub((server_start_boundary or client_start_boundary) + 1, cursor_col)
+ local matches = M._lsp_to_complete_items(result, prefix, client_id)
+ return matches, server_start_boundary
+end
+
+--- Implements 'omnifunc' compatible LSP completion.
+---
+--- @see |complete-functions|
+--- @see |complete-items|
+--- @see |CompleteDone|
+---
+--- @param findstart integer 0 or 1, decides behavior
+--- @param base integer findstart=0, text to match against
+---
+--- @return integer|table Decided by {findstart}:
+--- - findstart=0: column where the completion starts, or -2 or -3
+--- - findstart=1: list of matches (actually just calls |complete()|)
+function M._omnifunc(findstart, base)
+ vim.lsp.log.debug('omnifunc.findstart', { findstart = findstart, base = base })
+ assert(base) -- silence luals
+ local bufnr = api.nvim_get_current_buf()
+ local clients = lsp.get_clients({ bufnr = bufnr, method = ms.textDocument_completion })
+ local remaining = #clients
+ if remaining == 0 then
+ return findstart == 1 and -1 or {}
+ end
+
+ local win = api.nvim_get_current_win()
+ local cursor = api.nvim_win_get_cursor(win)
+ local lnum = cursor[1] - 1
+ local cursor_col = cursor[2]
+ local line = api.nvim_get_current_line()
+ local line_to_cursor = line:sub(1, cursor_col)
+ local client_start_boundary = vim.fn.match(line_to_cursor, '\\k*$') --[[@as integer]]
+ local server_start_boundary = nil
+ local items = {}
+
+ local function on_done()
+ local mode = api.nvim_get_mode()['mode']
+ if mode == 'i' or mode == 'ic' then
+ vim.fn.complete((server_start_boundary or client_start_boundary) + 1, items)
+ end
+ end
+
+ local util = vim.lsp.util
+ for _, client in ipairs(clients) do
+ local params = util.make_position_params(win, client.offset_encoding)
+ client.request(ms.textDocument_completion, params, function(err, result)
+ if err then
+ lsp.log.warn(err.message)
+ end
+ if result and vim.fn.mode() == 'i' then
+ local matches
+ matches, server_start_boundary = M._convert_results(
+ line,
+ lnum,
+ cursor_col,
+ client.id,
+ client_start_boundary,
+ server_start_boundary,
+ result,
+ client.offset_encoding
+ )
+ vim.list_extend(items, matches)
+ end
+ remaining = remaining - 1
+ if remaining == 0 then
+ vim.schedule(on_done)
+ end
+ end, bufnr)
+ end
+
+ -- Return -2 to signal that we should continue completion so that we can
+ -- async complete.
+ return -2
+end
+
+--- @param clients table<integer, vim.lsp.Client>
+--- @param bufnr integer
+--- @param win integer
+--- @param callback fun(responses: table<integer, { err: lsp.ResponseError, result: vim.lsp.CompletionResult }>)
+--- @return function # Cancellation function
+local function request(clients, bufnr, win, callback)
+ local responses = {} --- @type table<integer, { err: lsp.ResponseError, result: any }>
+ local request_ids = {} --- @type table<integer, integer>
+ local remaining_requests = vim.tbl_count(clients)
+
+ for client_id, client in pairs(clients) do
+ local params = lsp.util.make_position_params(win, client.offset_encoding)
+ local ok, request_id = client.request(ms.textDocument_completion, params, function(err, result)
+ responses[client_id] = { err = err, result = result }
+ remaining_requests = remaining_requests - 1
+ if remaining_requests == 0 then
+ callback(responses)
+ end
+ end, bufnr)
+
+ if ok then
+ request_ids[client_id] = request_id
+ end
+ end
+
+ return function()
+ for client_id, request_id in pairs(request_ids) do
+ local client = lsp.get_client_by_id(client_id)
+ if client then
+ client.cancel_request(request_id)
+ end
+ end
+ end
+end
+
+--- @param handle vim.lsp.completion.BufHandle
+local function on_insert_char_pre(handle)
+ if tonumber(vim.fn.pumvisible()) == 1 then
+ if Context.isIncomplete then
+ reset_timer()
+
+ local debounce_ms = next_debounce()
+ if debounce_ms == 0 then
+ vim.schedule(M.trigger)
+ else
+ completion_timer = new_timer()
+ completion_timer:start(debounce_ms, 0, vim.schedule_wrap(M.trigger))
+ end
+ end
+
+ return
+ end
+
+ local char = api.nvim_get_vvar('char')
+ if not completion_timer and handle.triggers[char] then
+ completion_timer = assert(vim.uv.new_timer())
+ completion_timer:start(25, 0, function()
+ reset_timer()
+ vim.schedule(M.trigger)
+ end)
+ end
+end
+
+local function on_insert_leave()
+ reset_timer()
+ Context.cursor = nil
+ Context:reset()
+end
+
+local function on_complete_done()
+ local completed_item = api.nvim_get_vvar('completed_item')
+ if not completed_item or not completed_item.user_data or not completed_item.user_data.nvim then
+ Context:reset()
+ return
+ end
+
+ local cursor_row, cursor_col = unpack(api.nvim_win_get_cursor(0)) --- @type integer, integer
+ cursor_row = cursor_row - 1
+ local completion_item = completed_item.user_data.nvim.lsp.completion_item --- @type lsp.CompletionItem
+ local client_id = completed_item.user_data.nvim.lsp.client_id --- @type integer
+ if not completion_item or not client_id then
+ Context:reset()
+ return
+ end
+
+ local bufnr = api.nvim_get_current_buf()
+ local expand_snippet = completion_item.insertTextFormat == protocol.InsertTextFormat.Snippet
+ and (completion_item.textEdit ~= nil or completion_item.insertText ~= nil)
+
+ Context:reset()
+
+ local client = lsp.get_client_by_id(client_id)
+ if not client then
+ return
+ end
+
+ local offset_encoding = client.offset_encoding or 'utf-16'
+ local resolve_provider = (client.server_capabilities.completionProvider or {}).resolveProvider
+
+ local function clear_word()
+ if not expand_snippet then
+ return nil
+ end
+
+ -- Remove the already inserted word.
+ local start_char = cursor_col - #completed_item.word
+ local line = api.nvim_buf_get_lines(bufnr, cursor_row, cursor_row + 1, true)[1]
+ api.nvim_buf_set_text(bufnr, cursor_row, start_char, cursor_row, #line, { '' })
+ return line:sub(cursor_col + 1)
+ end
+
+ --- @param suffix? string
+ local function apply_snippet_and_command(suffix)
+ if expand_snippet then
+ apply_snippet(completion_item, suffix)
+ end
+
+ local command = completion_item.command
+ if command then
+ client:_exec_cmd(command, { bufnr = bufnr }, nil, function()
+ vim.lsp.log.warn(
+ string.format(
+ 'Language server `%s` does not support command `%s`. This command may require a client extension.',
+ client.name,
+ command.command
+ )
+ )
+ end)
+ end
+ end
+
+ if completion_item.additionalTextEdits and next(completion_item.additionalTextEdits) then
+ local suffix = clear_word()
+ lsp.util.apply_text_edits(completion_item.additionalTextEdits, bufnr, offset_encoding)
+ apply_snippet_and_command(suffix)
+ elseif resolve_provider and type(completion_item) == 'table' then
+ local changedtick = vim.b[bufnr].changedtick
+
+ --- @param result lsp.CompletionItem
+ client.request(ms.completionItem_resolve, completion_item, function(err, result)
+ if changedtick ~= vim.b[bufnr].changedtick then
+ return
+ end
+
+ local suffix = clear_word()
+ if err then
+ vim.notify_once(err.message, vim.log.levels.WARN)
+ elseif result and result.additionalTextEdits then
+ lsp.util.apply_text_edits(result.additionalTextEdits, bufnr, offset_encoding)
+ if result.command then
+ completion_item.command = result.command
+ end
+ end
+
+ apply_snippet_and_command(suffix)
+ end, bufnr)
+ else
+ local suffix = clear_word()
+ apply_snippet_and_command(suffix)
+ end
+end
+
+--- @class vim.lsp.completion.BufferOpts
+--- @field autotrigger? boolean Whether to trigger completion automatically. Default: false
+
+--- @param client_id integer
+---@param bufnr integer
+---@param opts vim.lsp.completion.BufferOpts
+local function enable_completions(client_id, bufnr, opts)
+ if not buf_handles[bufnr] then
+ buf_handles[bufnr] = { clients = {}, triggers = {} }
+
+ -- Attach to buffer events.
+ api.nvim_buf_attach(bufnr, false, {
+ on_detach = function(_, buf)
+ buf_handles[buf] = nil
+ end,
+ on_reload = function(_, buf)
+ M.enable(true, client_id, buf, opts)
+ end,
+ })
+
+ -- Set up autocommands.
+ local group =
+ api.nvim_create_augroup(string.format('vim/lsp/completion-%d', bufnr), { clear = true })
+ api.nvim_create_autocmd('CompleteDone', {
+ group = group,
+ buffer = bufnr,
+ callback = function()
+ local reason = api.nvim_get_vvar('event').reason --- @type string
+ if reason == 'accept' then
+ on_complete_done()
+ end
+ end,
+ })
+ if opts.autotrigger then
+ api.nvim_create_autocmd('InsertCharPre', {
+ group = group,
+ buffer = bufnr,
+ callback = function()
+ on_insert_char_pre(buf_handles[bufnr])
+ end,
+ })
+ api.nvim_create_autocmd('InsertLeave', {
+ group = group,
+ buffer = bufnr,
+ callback = on_insert_leave,
+ })
+ end
+ end
+
+ if not buf_handles[bufnr].clients[client_id] then
+ local client = lsp.get_client_by_id(client_id)
+ assert(client, 'invalid client ID')
+
+ -- Add the new client to the buffer's clients.
+ buf_handles[bufnr].clients[client_id] = client
+
+ -- Add the new client to the clients that should be triggered by its trigger characters.
+ --- @type string[]
+ local triggers = vim.tbl_get(
+ client.server_capabilities,
+ 'completionProvider',
+ 'triggerCharacters'
+ ) or {}
+ for _, char in ipairs(triggers) do
+ local clients_for_trigger = buf_handles[bufnr].triggers[char]
+ if not clients_for_trigger then
+ clients_for_trigger = {}
+ buf_handles[bufnr].triggers[char] = clients_for_trigger
+ end
+ local client_exists = vim.iter(clients_for_trigger):any(function(c)
+ return c.id == client_id
+ end)
+ if not client_exists then
+ table.insert(clients_for_trigger, client)
+ end
+ end
+ end
+end
+
+--- @param client_id integer
+--- @param bufnr integer
+local function disable_completions(client_id, bufnr)
+ local handle = buf_handles[bufnr]
+ if not handle then
+ return
+ end
+
+ handle.clients[client_id] = nil
+ if not next(handle.clients) then
+ buf_handles[bufnr] = nil
+ api.nvim_del_augroup_by_name(string.format('vim/lsp/completion-%d', bufnr))
+ else
+ for char, clients in pairs(handle.triggers) do
+ --- @param c vim.lsp.Client
+ handle.triggers[char] = vim.tbl_filter(function(c)
+ return c.id ~= client_id
+ end, clients)
+ end
+ end
+end
+
+--- Enables or disables completions from the given language client in the given buffer.
+---
+--- @param enable boolean True to enable, false to disable
+--- @param client_id integer Client ID
+--- @param bufnr integer Buffer handle, or 0 for the current buffer
+--- @param opts? vim.lsp.completion.BufferOpts
+function M.enable(enable, client_id, bufnr, opts)
+ bufnr = (bufnr == 0 and api.nvim_get_current_buf()) or bufnr
+
+ if enable then
+ enable_completions(client_id, bufnr, opts or {})
+ else
+ disable_completions(client_id, bufnr)
+ end
+end
+
+--- Trigger LSP completion in the current buffer.
+function M.trigger()
+ reset_timer()
+ Context:cancel_pending()
+
+ local win = api.nvim_get_current_win()
+ local bufnr = api.nvim_get_current_buf()
+ local cursor_row, cursor_col = unpack(api.nvim_win_get_cursor(win)) --- @type integer, integer
+ local line = api.nvim_get_current_line()
+ local line_to_cursor = line:sub(1, cursor_col)
+ local clients = (buf_handles[bufnr] or {}).clients or {}
+ local word_boundary = vim.fn.match(line_to_cursor, '\\k*$')
+ local start_time = vim.uv.hrtime()
+ Context.last_request_time = start_time
+
+ local cancel_request = request(clients, bufnr, win, function(responses)
+ local end_time = vim.uv.hrtime()
+ rtt_ms = compute_new_average((end_time - start_time) * ns_to_ms)
+
+ Context.pending_requests = {}
+ Context.isIncomplete = false
+
+ local row_changed = api.nvim_win_get_cursor(win)[1] ~= cursor_row
+ local mode = api.nvim_get_mode().mode
+ if row_changed or not (mode == 'i' or mode == 'ic') then
+ return
+ end
+
+ local matches = {}
+ local server_start_boundary --- @type integer?
+ for client_id, response in pairs(responses) do
+ if response.err then
+ vim.notify_once(response.err.message, vim.log.levels.warn)
+ end
+
+ local result = response.result
+ if result then
+ Context.isIncomplete = Context.isIncomplete or result.isIncomplete
+ local client = lsp.get_client_by_id(client_id)
+ local encoding = client and client.offset_encoding or 'utf-16'
+ local client_matches
+ client_matches, server_start_boundary = M._convert_results(
+ line,
+ cursor_row - 1,
+ cursor_col,
+ client_id,
+ word_boundary,
+ nil,
+ result,
+ encoding
+ )
+ vim.list_extend(matches, client_matches)
+ end
+ end
+ local start_col = (server_start_boundary or word_boundary) + 1
+ vim.fn.complete(start_col, matches)
+ end)
+
+ table.insert(Context.pending_requests, cancel_request)
+end
+
+return M
diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua
index f9d394642c..38c43893eb 100644
--- a/runtime/lua/vim/lsp/handlers.lua
+++ b/runtime/lua/vim/lsp/handlers.lua
@@ -3,7 +3,7 @@ local protocol = require('vim.lsp.protocol')
local ms = protocol.Methods
local util = require('vim.lsp.util')
local api = vim.api
-local completion = require('vim.lsp._completion')
+local completion = require('vim.lsp.completion')
--- @type table<string,lsp.Handler>
local M = {}
diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua
index 419c2ff644..8ac4cc794b 100644
--- a/runtime/lua/vim/lsp/protocol.lua
+++ b/runtime/lua/vim/lsp/protocol.lua
@@ -738,14 +738,16 @@ function protocol.make_client_capabilities()
completion = {
dynamicRegistration = false,
completionItem = {
- -- Until we can actually expand snippet, move cursor and allow for true snippet experience,
- -- this should be disabled out of the box.
- -- However, users can turn this back on if they have a snippet plugin.
- snippetSupport = false,
+ snippetSupport = true,
commitCharactersSupport = false,
preselectSupport = false,
deprecatedSupport = false,
documentationFormat = { constants.MarkupKind.Markdown, constants.MarkupKind.PlainText },
+ resolveSupport = {
+ properties = {
+ 'additionalTextEdits',
+ },
+ },
},
completionItemKind = {
valueSet = get_value_set(constants.CompletionItemKind),
diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua
index 2641d1feb0..0ec79e1dc7 100644
--- a/runtime/lua/vim/shared.lua
+++ b/runtime/lua/vim/shared.lua
@@ -379,7 +379,7 @@ local function tbl_extend(behavior, deep_extend, ...)
for i = 1, select('#', ...) do
local tbl = select(i, ...)
- vim.validate({ ['after the second argument'] = { tbl, 't' } })
+ vim.validate('after the second argument', tbl, 'table')
--- @cast tbl table<any,any>
if tbl then
for k, v in pairs(tbl) do
diff --git a/runtime/lua/vim/snippet.lua b/runtime/lua/vim/snippet.lua
index 8e384e0f97..8cd454b908 100644
--- a/runtime/lua/vim/snippet.lua
+++ b/runtime/lua/vim/snippet.lua
@@ -2,6 +2,8 @@ local G = vim.lsp._snippet_grammar
local snippet_group = vim.api.nvim_create_augroup('vim/snippet', {})
local snippet_ns = vim.api.nvim_create_namespace('vim/snippet')
local hl_group = 'SnippetTabstop'
+local jump_forward_key = '<tab>'
+local jump_backward_key = '<s-tab>'
--- Returns the 0-based cursor position.
---
@@ -182,6 +184,8 @@ end
--- @field extmark_id integer
--- @field tabstops table<integer, vim.snippet.Tabstop[]>
--- @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>? }
local Session = {}
--- Creates a new snippet session in the current buffer.
@@ -197,6 +201,8 @@ function Session.new(bufnr, snippet_extmark, tabstop_data)
extmark_id = snippet_extmark,
tabstops = {},
current_tabstop = Tabstop.new(0, bufnr, { 0, 0, 0, 0 }),
+ tab_keymaps = { i = nil, s = nil },
+ shift_tab_keymaps = { i = nil, s = nil },
}, { __index = Session })
-- Create the tabstops.
@@ -207,9 +213,64 @@ function Session.new(bufnr, snippet_extmark, tabstop_data)
end
end
+ self:set_keymaps()
+
return self
end
+--- Sets the snippet navigation keymaps.
+---
+--- @package
+function Session:set_keymaps()
+ local function maparg(key, mode)
+ local map = vim.fn.maparg(key, mode, false, true) --[[ @as table ]]
+ if not vim.tbl_isempty(map) and map.buffer == 1 then
+ return map
+ else
+ return nil
+ end
+ end
+
+ local function set(jump_key, direction)
+ vim.keymap.set({ 'i', 's' }, jump_key, function()
+ return vim.snippet.active({ direction = direction })
+ and '<cmd>lua vim.snippet.jump(' .. direction .. ')<cr>'
+ or jump_key
+ end, { expr = true, silent = true, buffer = self.bufnr })
+ end
+
+ self.tab_keymaps = {
+ i = maparg(jump_forward_key, 'i'),
+ s = maparg(jump_forward_key, 's'),
+ }
+ self.shift_tab_keymaps = {
+ i = maparg(jump_backward_key, 'i'),
+ s = maparg(jump_backward_key, 's'),
+ }
+ set(jump_forward_key, 1)
+ set(jump_backward_key, -1)
+end
+
+--- Restores/deletes the keymaps used for snippet navigation.
+---
+--- @package
+function Session:restore_keymaps()
+ local function restore(keymap, lhs, mode)
+ if keymap then
+ vim.api.nvim_buf_call(self.bufnr, function()
+ vim.fn.mapset(keymap)
+ end)
+ else
+ vim.api.nvim_buf_del_keymap(self.bufnr, mode, lhs)
+ end
+ end
+
+ restore(self.tab_keymaps.i, jump_forward_key, 'i')
+ restore(self.tab_keymaps.s, jump_forward_key, 's')
+ restore(self.shift_tab_keymaps.i, jump_backward_key, 'i')
+ restore(self.shift_tab_keymaps.s, jump_backward_key, 's')
+end
+
--- Returns the destination tabstop index when jumping in the given direction.
---
--- @package
@@ -628,6 +689,8 @@ function M.stop()
return
end
+ M._session:restore_keymaps()
+
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)