mirror of
https://github.com/neovim/neovim.git
synced 2026-09-08 23:18:59 +00:00
Problem: LuaLS struggles with the generics used in Nvim's runtime, requiring broad diagnostic suppressions. Indexing is also slow. Solution: Use EmmyLua for type checks in the build and CI. It offers more sophisticated type checking, substantially better support for generics, and much better flow analysis. Correct the affected annotations. Use `@internal`, supported directly by EmmyLua, instead of `@nodoc` for shared internal declarations, and support it in the help parser. AI-assisted
2003 lines
67 KiB
Lua
2003 lines
67 KiB
Lua
local protocol = require('vim.lsp.protocol')
|
|
local validate = vim.validate
|
|
local api = vim.api
|
|
local nvim_on = require('vim._core.util').nvim_on
|
|
local list_extend = vim.list_extend
|
|
local uv = vim.uv
|
|
|
|
local M = {}
|
|
|
|
--- @param border string|(string|[string,string])[]
|
|
--- @return never
|
|
local function border_error(border)
|
|
error(
|
|
string.format(
|
|
'invalid floating preview border: %s. :help vim.api.nvim_open_win()',
|
|
vim.inspect(border)
|
|
),
|
|
2
|
|
)
|
|
end
|
|
|
|
local border_size = {
|
|
none = { 0, 0 },
|
|
single = { 2, 2 },
|
|
double = { 2, 2 },
|
|
rounded = { 2, 2 },
|
|
solid = { 2, 2 },
|
|
shadow = { 1, 1 },
|
|
bold = { 2, 2 },
|
|
}
|
|
|
|
--- Check the border given by opts or the default border for the additional
|
|
--- size it adds to a float.
|
|
--- @param opts? {border:string|(string|[string,string])[]}
|
|
--- @return integer height
|
|
--- @return integer width
|
|
local function get_border_size(opts)
|
|
local border = opts and opts.border or vim.o.winborder
|
|
|
|
if border == '' then
|
|
border = 'none'
|
|
end
|
|
|
|
-- Convert winborder string option with custom characters into a table
|
|
if type(border) == 'string' and border:find(',') then
|
|
border = vim.split(border, ',')
|
|
end
|
|
|
|
if type(border) == 'string' then
|
|
if not border_size[border] then
|
|
border_error(border)
|
|
end
|
|
local r = border_size[border]
|
|
return r[1], r[2]
|
|
end
|
|
|
|
if 8 % #border ~= 0 then
|
|
border_error(border)
|
|
end
|
|
|
|
--- @param id integer
|
|
--- @return string
|
|
local function elem(id)
|
|
id = (id - 1) % #border + 1
|
|
local e = border[id]
|
|
if type(e) == 'table' then
|
|
-- border specified as a table of <character, highlight group>
|
|
return e[1]
|
|
elseif type(e) == 'string' then
|
|
-- border specified as a list of border characters
|
|
return e
|
|
end
|
|
return border_error(border)
|
|
end
|
|
|
|
--- @param e string
|
|
--- @return integer
|
|
local function border_height(e)
|
|
return #e > 0 and 1 or 0
|
|
end
|
|
|
|
local top, bottom = elem(2), elem(6)
|
|
local height = border_height(top) + border_height(bottom)
|
|
|
|
local right, left = elem(4), elem(8)
|
|
local width = vim.fn.strdisplaywidth(right) + vim.fn.strdisplaywidth(left)
|
|
|
|
return height, width
|
|
end
|
|
|
|
--- Splits string at newlines, optionally removing unwanted blank lines.
|
|
---
|
|
--- @param s string Multiline string
|
|
--- @param no_blank boolean? Drop blank lines for each @param/@return (except one empty line
|
|
--- separating each, and before a codeblock).
|
|
--- Workaround for https://github.com/LuaLS/lua-language-server/issues/2333
|
|
local function split_lines(s, no_blank)
|
|
s = string.gsub(s, '\r\n?', '\n')
|
|
local raw = vim.split(s, '\n', { plain = true, trimempty = true })
|
|
|
|
--- @return boolean # true if line begins a (4-space-indented) codeblock. #40860
|
|
local function codeblock(l)
|
|
return l:find('^ ') or l:find('^\t')
|
|
end
|
|
|
|
local lines = {}
|
|
local in_desc = true -- Main description block, before seeing any @foo.
|
|
for i, line in ipairs(raw) do
|
|
local start_annotation = not not line:find('^ ?%@.?[pr]')
|
|
in_desc = (not start_annotation) and in_desc or false
|
|
if start_annotation and no_blank and not (lines[#lines] or ''):find('^%s*$') then
|
|
table.insert(lines, '') -- Separate each @foo with a blank line.
|
|
end
|
|
|
|
local blank = line:find('^%s*$')
|
|
local keep_blank = blank and codeblock(raw[i + 1] or '') -- Keep if it precedes a codeblock. #40860
|
|
if in_desc or not no_blank or not blank or keep_blank then
|
|
table.insert(lines, line)
|
|
end
|
|
end
|
|
return lines
|
|
end
|
|
|
|
local function create_window_without_focus()
|
|
local prev = api.nvim_get_current_win()
|
|
vim.cmd.new()
|
|
local new = api.nvim_get_current_win()
|
|
api.nvim_set_current_win(prev)
|
|
return new
|
|
end
|
|
|
|
--- @param fn fun(x:any):any[]
|
|
--- @return function
|
|
local function sort_by_key(fn)
|
|
return function(a, b)
|
|
local ka, kb = fn(a), fn(b)
|
|
assert(#ka == #kb)
|
|
for i = 1, #ka do
|
|
if ka[i] ~= kb[i] then
|
|
return ka[i] < kb[i]
|
|
end
|
|
end
|
|
-- every value must have been equal here, which means it's not less than.
|
|
return false
|
|
end
|
|
end
|
|
|
|
-- TODO(ofseed): remove these exported functions by replacing their usages with `vim.pos`.
|
|
local get_lines = require('vim.pos._util').get_lines
|
|
local get_line = require('vim.pos._util').get_line
|
|
|
|
--- Applies a list of text edits to a buffer. Note: this mutates `text_edits` (sorts in-place and
|
|
--- adds `_index` fields).
|
|
---
|
|
---@param text_edits (lsp.TextEdit|lsp.AnnotatedTextEdit)[]
|
|
---@param bufnr integer Buffer id
|
|
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
|
|
---@param change_annotations? table<string, lsp.ChangeAnnotation>
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit
|
|
function M.apply_text_edits(text_edits, bufnr, position_encoding, change_annotations)
|
|
validate('text_edits', text_edits, 'table', false)
|
|
validate('bufnr', bufnr, 'number', false)
|
|
validate('position_encoding', position_encoding, 'string', false)
|
|
validate('change_annotations', change_annotations, 'table', true)
|
|
|
|
if not next(text_edits) then
|
|
return
|
|
end
|
|
|
|
assert(bufnr ~= 0, 'Explicit buffer number is required')
|
|
|
|
if not api.nvim_buf_is_loaded(bufnr) then
|
|
vim.fn.bufload(bufnr)
|
|
end
|
|
|
|
local marks = {} --- @type table<string,[integer,integer]>
|
|
local has_eol_text_edit = false
|
|
|
|
local function apply_text_edits()
|
|
-- Fix reversed range and indexing each text_edits
|
|
for index, text_edit in ipairs(text_edits) do
|
|
--- @cast text_edit lsp.TextEdit & { _index?: integer }
|
|
-- XXX: Preserve existing _index to avoid surprises if the same edit is reapplied. #39344
|
|
if text_edit._index == nil then
|
|
text_edit._index = index
|
|
end
|
|
|
|
if
|
|
text_edit.range.start.line > text_edit.range['end'].line
|
|
or text_edit.range.start.line == text_edit.range['end'].line
|
|
and text_edit.range.start.character > text_edit.range['end'].character
|
|
then
|
|
local start = text_edit.range.start
|
|
text_edit.range.start = text_edit.range['end']
|
|
text_edit.range['end'] = start
|
|
end
|
|
end
|
|
|
|
--- @cast text_edits (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer})[]
|
|
|
|
-- Sort text_edits
|
|
---@param a (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer})
|
|
---@param b (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer})
|
|
---@return boolean
|
|
table.sort(text_edits, function(a, b)
|
|
if a.range.start.line ~= b.range.start.line then
|
|
return a.range.start.line > b.range.start.line
|
|
end
|
|
if a.range.start.character ~= b.range.start.character then
|
|
return a.range.start.character > b.range.start.character
|
|
end
|
|
return a._index > b._index
|
|
end)
|
|
|
|
-- save and restore local marks since they get deleted by nvim_buf_set_lines
|
|
for _, m in pairs(vim.fn.getmarklist(bufnr)) do
|
|
if m.mark:match("^'[a-z]$") then
|
|
marks[m.mark:sub(2, 2)] = { m.pos[2], m.pos[3] - 1 } -- api-indexed
|
|
end
|
|
end
|
|
|
|
for _, text_edit in ipairs(text_edits) do
|
|
-- Normalize line ending
|
|
text_edit.newText, _ = string.gsub(text_edit.newText, '\r\n?', '\n')
|
|
|
|
-- Convert from LSP style ranges to Neovim style ranges.
|
|
local range = vim.range.lsp(bufnr, text_edit.range, position_encoding)
|
|
local start_row, start_col, end_row, end_col =
|
|
range.start_row, range.start_col, range.end_row, range.end_col
|
|
local text = vim.split(text_edit.newText, '\n', { plain = true })
|
|
|
|
local max = api.nvim_buf_line_count(bufnr)
|
|
-- If the whole edit is after the lines in the buffer we can simply add the new text to the end
|
|
-- of the buffer.
|
|
if max <= start_row then
|
|
api.nvim_buf_set_lines(bufnr, max, max, false, text)
|
|
has_eol_text_edit = true
|
|
else
|
|
local last_line_len = #get_line(bufnr, math.min(end_row, max - 1))
|
|
-- Some LSP servers may return +1 range of the buffer content but nvim_buf_set_text can't
|
|
-- accept it so we should fix it here.
|
|
if max <= end_row then
|
|
end_row = max - 1
|
|
end_col = last_line_len
|
|
has_eol_text_edit = true
|
|
else
|
|
-- If the replacement is over the end of a line (i.e. end_col is equal to the line length and the
|
|
-- replacement text ends with a newline We can likely assume that the replacement is assumed
|
|
-- to be meant to replace the newline with another newline and we need to make sure this
|
|
-- doesn't add an extra empty line. E.g. when the last line to be replaced contains a '\r'
|
|
-- in the file some servers (clangd on windows) will include that character in the line
|
|
-- while nvim_buf_set_text doesn't count it as part of the line.
|
|
if
|
|
end_col >= last_line_len
|
|
and text_edit.range['end'].character > end_col
|
|
and #text_edit.newText > 0
|
|
and string.sub(text_edit.newText, -1) == '\n'
|
|
then
|
|
table.remove(text, #text)
|
|
end
|
|
end
|
|
-- Make sure we don't go out of bounds for end_col
|
|
end_col = math.min(last_line_len, end_col)
|
|
|
|
api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, text)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Track how many times each change annotation is applied to build up the final description.
|
|
---@type table<string, integer>
|
|
local change_count = {}
|
|
|
|
-- If there are any annotated text edits, we need to confirm them before applying the edits.
|
|
local confirmations = {} ---@type table<string, integer>
|
|
for _, text_edit in ipairs(text_edits) do
|
|
if text_edit.annotationId then
|
|
assert(
|
|
change_annotations ~= nil,
|
|
'change_annotations must be provided for annotated text edits'
|
|
)
|
|
|
|
local annotation = assert(
|
|
change_annotations[text_edit.annotationId],
|
|
string.format('No change annotation found for ID: %s', text_edit.annotationId)
|
|
)
|
|
|
|
if annotation.needsConfirmation then
|
|
confirmations[text_edit.annotationId] = (confirmations[text_edit.annotationId] or 0) + 1
|
|
end
|
|
|
|
change_count[text_edit.annotationId] = (change_count[text_edit.annotationId] or 0) + 1
|
|
end
|
|
end
|
|
|
|
if next(confirmations) then
|
|
local message = { 'Apply all changes?' }
|
|
for id, count in pairs(confirmations) do
|
|
local annotation = assert(change_annotations)[id]
|
|
message[#message + 1] = annotation.label
|
|
.. (annotation.description and (string.format(': %s', annotation.description)) or '')
|
|
.. (count > 1 and string.format(' (%d)', count) or '')
|
|
end
|
|
|
|
local response = vim.fn.confirm(table.concat(message, '\n'), '&Yes\n&No', 1, 'Question')
|
|
if response == 1 then
|
|
-- Proceed with applying text edits.
|
|
apply_text_edits()
|
|
else
|
|
-- Don't apply any text edits.
|
|
return
|
|
end
|
|
else
|
|
-- No confirmations needed, apply text edits directly.
|
|
apply_text_edits()
|
|
end
|
|
|
|
if change_annotations ~= nil and next(change_count) then
|
|
local change_message = { 'Applied changes:' }
|
|
for id, count in pairs(change_count) do
|
|
local annotation = change_annotations[id]
|
|
change_message[#change_message + 1] = annotation.label
|
|
.. (annotation.description and (': ' .. annotation.description) or '')
|
|
.. (count > 1 and string.format(' (%d)', count) or '')
|
|
end
|
|
vim.notify(table.concat(change_message, '\n'), vim.log.levels.INFO)
|
|
end
|
|
|
|
local max = api.nvim_buf_line_count(bufnr)
|
|
|
|
-- no need to restore marks that still exist
|
|
for _, m in pairs(vim.fn.getmarklist(bufnr)) do
|
|
marks[m.mark:sub(2, 2)] = nil
|
|
end
|
|
-- restore marks
|
|
for mark, pos in pairs(marks) do
|
|
if pos then
|
|
-- make sure we don't go out of bounds
|
|
pos[1] = math.min(pos[1], max)
|
|
pos[2] = math.min(pos[2], #get_line(bufnr, pos[1] - 1))
|
|
api.nvim_buf_set_mark(bufnr or 0, mark, pos[1], pos[2])
|
|
end
|
|
end
|
|
|
|
-- Remove final line if needed
|
|
local fix_eol = has_eol_text_edit
|
|
fix_eol = fix_eol and (vim.bo[bufnr].eol or (vim.bo[bufnr].fixeol and not vim.bo[bufnr].binary))
|
|
fix_eol = fix_eol and get_line(bufnr, max - 1) == ''
|
|
if fix_eol then
|
|
api.nvim_buf_set_lines(bufnr, -2, -1, false, {})
|
|
end
|
|
end
|
|
|
|
--- Applies a `TextDocumentEdit`, which is a list of changes to a single
|
|
--- document.
|
|
---
|
|
---@param text_document_edit lsp.TextDocumentEdit
|
|
---@param index? integer: Optional index of the edit, if from a list of edits (or nil, if not from a list)
|
|
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
|
|
---@param change_annotations? table<string, lsp.ChangeAnnotation>
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit
|
|
function M.apply_text_document_edit(
|
|
text_document_edit,
|
|
index,
|
|
position_encoding,
|
|
change_annotations
|
|
)
|
|
vim.validate('position_encoding', position_encoding, 'string')
|
|
|
|
local text_document = text_document_edit.textDocument
|
|
local bufnr = vim.uri_to_bufnr(text_document.uri)
|
|
|
|
-- `VersionedTextDocumentIdentifier`s version may be null
|
|
-- https://microsoft.github.io/language-server-protocol/specification#versionedTextDocumentIdentifier
|
|
if
|
|
-- For lists of text document edits,
|
|
-- do not check the version after the first edit.
|
|
not (index and index > 1)
|
|
and (
|
|
text_document.version ~= vim.NIL
|
|
and text_document.version > 0
|
|
and M.buf_versions[bufnr] > text_document.version
|
|
)
|
|
then
|
|
print('Buffer ', text_document.uri, ' newer than edits.')
|
|
return
|
|
end
|
|
|
|
M.apply_text_edits(text_document_edit.edits, bufnr, position_encoding, change_annotations)
|
|
end
|
|
|
|
local function path_components(path)
|
|
return vim.split(path, '/', { plain = true })
|
|
end
|
|
|
|
--- @param path string[]
|
|
--- @param prefix string[]
|
|
--- @return boolean
|
|
local function path_under_prefix(path, prefix)
|
|
for i, c in ipairs(prefix) do
|
|
if c ~= path[i] then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
--- Get list of loaded writable buffers whose filename matches the given path
|
|
--- prefix (normalized full path).
|
|
---@param prefix string
|
|
---@return integer[]
|
|
local function get_writable_bufs(prefix)
|
|
local prefix_parts = path_components(prefix)
|
|
local buffers = {} --- @type integer[]
|
|
for _, buf in ipairs(api.nvim_list_bufs()) do
|
|
-- No need to care about unloaded or nofile buffers. Also :saveas won't work for them.
|
|
if
|
|
api.nvim_buf_is_loaded(buf)
|
|
and not vim.list_contains({ 'nofile', 'nowrite' }, vim.bo[buf].buftype)
|
|
then
|
|
local bname = api.nvim_buf_get_name(buf)
|
|
local path = path_components(vim.fs.normalize(bname, { plain = true }))
|
|
if path_under_prefix(path, prefix_parts) then
|
|
buffers[#buffers + 1] = buf
|
|
end
|
|
end
|
|
end
|
|
return buffers
|
|
end
|
|
|
|
local function escape_gsub_repl(s)
|
|
return (s:gsub('%%', '%%%%'))
|
|
end
|
|
|
|
--- @class vim.lsp.util.rename.Opts
|
|
--- @inlinedoc
|
|
--- @field overwrite? boolean
|
|
--- @field ignoreIfExists? boolean
|
|
|
|
--- Rename old_fname to new_fname
|
|
---
|
|
--- Existing buffers are renamed as well, while maintaining their bufnr.
|
|
---
|
|
--- It deletes existing buffers that conflict with the renamed file name only when
|
|
--- * `opts` requests overwriting; or
|
|
--- * the conflicting buffers are not loaded, so that deleting them does not result in data loss.
|
|
---
|
|
--- @param old_fname string
|
|
--- @param new_fname string
|
|
--- @param opts? vim.lsp.util.rename.Opts Options:
|
|
function M.rename(old_fname, new_fname, opts)
|
|
opts = opts or {}
|
|
local skip = not opts.overwrite or opts.ignoreIfExists
|
|
|
|
local old_fname_full = uv.fs_realpath(vim.fs.normalize(old_fname, { plain = true }))
|
|
if not old_fname_full then
|
|
vim.notify('Invalid path: ' .. old_fname, vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
local target_exists = uv.fs_stat(new_fname) ~= nil
|
|
if target_exists and skip then
|
|
vim.notify(new_fname .. ' already exists. Skipping rename.', vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
local buf_rename = {} ---@type table<integer, {from: string, to: string}>
|
|
local old_fname_pat = '^' .. vim.pesc(old_fname_full)
|
|
for _, b in ipairs(get_writable_bufs(old_fname_full)) do
|
|
-- Renaming a buffer may conflict with another buffer that happens to have the same name. In
|
|
-- most cases, this would have been already detected by the file conflict check above, but the
|
|
-- conflicting buffer may not be associated with a file. For example, 'buftype' can be "nofile"
|
|
-- or "nowrite", or the buffer can be a normal buffer but has not been written to the file yet.
|
|
-- Renaming should fail in such cases to avoid losing the contents of the conflicting buffer.
|
|
local old_bname = api.nvim_buf_get_name(b)
|
|
local new_bname = old_bname:gsub(old_fname_pat, escape_gsub_repl(new_fname))
|
|
if vim.fn.bufexists(new_bname) == 1 then
|
|
local existing_buf = vim.fn.bufnr(new_bname)
|
|
if api.nvim_buf_is_loaded(existing_buf) and skip then
|
|
vim.notify(
|
|
new_bname .. ' already exists in the buffer list. Skipping rename.',
|
|
vim.log.levels.ERROR
|
|
)
|
|
return
|
|
end
|
|
-- no need to preserve if such a buffer is empty
|
|
api.nvim_buf_delete(existing_buf)
|
|
end
|
|
|
|
buf_rename[b] = { from = old_bname, to = new_bname }
|
|
end
|
|
|
|
local newdir = vim.fs.dirname(new_fname)
|
|
vim.fn.mkdir(newdir, 'p')
|
|
|
|
assert(os.rename(old_fname_full, new_fname))
|
|
|
|
local old_undofile = vim.fn.undofile(old_fname_full)
|
|
if uv.fs_stat(old_undofile) ~= nil then
|
|
local new_undofile = vim.fn.undofile(new_fname)
|
|
vim.fn.mkdir(vim.fs.dirname(new_undofile), 'p')
|
|
os.rename(old_undofile, new_undofile)
|
|
end
|
|
|
|
for b, rename in pairs(buf_rename) do
|
|
-- Rename with :saveas. This does two things:
|
|
-- * Unset BF_WRITE_MASK, so that users don't get E13 when they do :write.
|
|
-- * Send didClose and didOpen via textDocument/didSave handler.
|
|
vim._with({ buf = b }, function()
|
|
vim.cmd('keepalt saveas! ' .. vim.fn.fnameescape(rename.to))
|
|
end)
|
|
-- Delete the new buffer with the old name created by :saveas. nvim_buf_delete and
|
|
-- :bwipeout are futile because the buffer will be added again somewhere else.
|
|
vim.cmd('bdelete! ' .. vim.fn.bufnr(rename.from))
|
|
end
|
|
end
|
|
|
|
--- @param change lsp.CreateFile
|
|
local function create_file(change)
|
|
local opts = change.options or {}
|
|
-- from spec: Overwrite wins over `ignoreIfExists`
|
|
local fname = vim.uri_to_fname(change.uri)
|
|
if not opts.ignoreIfExists or opts.overwrite then
|
|
vim.fn.mkdir(vim.fs.dirname(fname), 'p')
|
|
local file = io.open(fname, 'w')
|
|
if file then
|
|
file:close()
|
|
end
|
|
end
|
|
vim.fn.bufadd(fname)
|
|
end
|
|
|
|
--- @param change lsp.DeleteFile
|
|
local function delete_file(change)
|
|
local opts = change.options or {}
|
|
local fname = vim.uri_to_fname(change.uri)
|
|
local bufnr = vim.fn.bufadd(fname)
|
|
vim.fs.rm(fname, {
|
|
force = opts.ignoreIfNotExists,
|
|
recursive = opts.recursive,
|
|
})
|
|
api.nvim_buf_delete(bufnr, { force = true })
|
|
end
|
|
|
|
--- Applies a `WorkspaceEdit`.
|
|
---
|
|
---@param workspace_edit lsp.WorkspaceEdit
|
|
---@param position_encoding 'utf-8'|'utf-16'|'utf-32' (required)
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit
|
|
function M.apply_workspace_edit(workspace_edit, position_encoding)
|
|
vim.validate('workspace_edit', workspace_edit, 'table')
|
|
vim.validate('position_encoding', position_encoding, 'string')
|
|
|
|
if workspace_edit.documentChanges then
|
|
for idx, change in ipairs(workspace_edit.documentChanges) do
|
|
if change.kind == 'rename' then
|
|
local options = change.options --[[@as vim.lsp.util.rename.Opts]]
|
|
M.rename(vim.uri_to_fname(change.oldUri), vim.uri_to_fname(change.newUri), options)
|
|
elseif change.kind == 'create' then
|
|
create_file(change)
|
|
elseif change.kind == 'delete' then
|
|
delete_file(change)
|
|
elseif change.kind then --- @diagnostic disable-line:undefined-field
|
|
error(string.format('Unsupported change: %q', vim.inspect(change)))
|
|
else
|
|
local bufnr = vim.uri_to_bufnr(change.textDocument.uri)
|
|
M.apply_text_document_edit(change, idx, position_encoding, workspace_edit.changeAnnotations)
|
|
-- avoid triggering OptionSet
|
|
if not vim.bo[bufnr].buflisted then
|
|
vim.bo[bufnr].buflisted = true
|
|
end
|
|
end
|
|
end
|
|
return
|
|
end
|
|
|
|
local all_changes = workspace_edit.changes
|
|
if not (all_changes and not vim.tbl_isempty(all_changes)) then
|
|
return
|
|
end
|
|
|
|
for uri, changes in pairs(all_changes) do
|
|
local bufnr = vim.uri_to_bufnr(uri)
|
|
M.apply_text_edits(changes, bufnr, position_encoding, workspace_edit.changeAnnotations)
|
|
-- avoid triggering OptionSet
|
|
if not vim.bo[bufnr].buflisted then
|
|
vim.bo[bufnr].buflisted = true
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Converts any of `MarkedString` | `MarkedString[]` | `MarkupContent` into
|
|
--- a list of lines containing valid markdown. Useful to populate the hover
|
|
--- window for `textDocument/hover`, for parsing the result of
|
|
--- `textDocument/signatureHelp`, and potentially others.
|
|
---
|
|
--- Note that if the input is of type `MarkupContent` and its kind is `plaintext`,
|
|
--- then the corresponding value is returned without further modifications.
|
|
---
|
|
---@param input lsp.MarkedString|lsp.MarkedString[]|lsp.MarkupContent
|
|
---@param contents string[]? List of strings to extend with converted lines. Defaults to {}.
|
|
---@return string[] extended with lines of converted markdown.
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover
|
|
function M.convert_input_to_markdown_lines(input, contents)
|
|
contents = contents or {}
|
|
-- MarkedString variation 1
|
|
if type(input) == 'string' then
|
|
list_extend(contents, split_lines(input, true))
|
|
else
|
|
assert(type(input) == 'table', 'Expected a table for LSP input')
|
|
-- MarkupContent
|
|
if input.kind then
|
|
local value = input.value or ''
|
|
list_extend(contents, split_lines(value, true))
|
|
-- MarkupString variation 2
|
|
elseif input.language then
|
|
table.insert(contents, '```' .. input.language)
|
|
list_extend(contents, split_lines(input.value or ''))
|
|
table.insert(contents, '```')
|
|
-- By deduction, this must be MarkedString[]
|
|
else
|
|
-- Use our existing logic to handle MarkedString
|
|
for _, marked_string in ipairs(input) do
|
|
M.convert_input_to_markdown_lines(marked_string, contents)
|
|
end
|
|
end
|
|
end
|
|
if (contents[1] == '' or contents[1] == nil) and #contents == 1 then
|
|
return {}
|
|
end
|
|
return contents
|
|
end
|
|
|
|
--- Returns the line/column-based position in `contents` at the given offset.
|
|
---
|
|
---@param offset integer
|
|
---@param contents string[]
|
|
---@return { [1]: integer, [2]: integer }?
|
|
local function get_pos_from_offset(offset, contents)
|
|
local i = 0
|
|
for l, line in ipairs(contents) do
|
|
if offset >= i and offset < i + #line then
|
|
return { l - 1, offset - i + 1 }
|
|
else
|
|
i = i + #line + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Converts `textDocument/signatureHelp` response to markdown lines.
|
|
---
|
|
---@param signature_help lsp.SignatureHelp Response of `textDocument/SignatureHelp`
|
|
---@param ft string? filetype that will be use as the `lang` for the label markdown code block
|
|
---@param triggers string[]? list of trigger characters from the lsp server. used to better determine parameter offsets
|
|
---@return string[]? # lines of converted markdown.
|
|
---@return Range4? # highlight range for the active parameter
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp
|
|
function M.convert_signature_help_to_markdown_lines(signature_help, ft, triggers)
|
|
--The active signature. If omitted or the value lies outside the range of
|
|
--`signatures` the value defaults to zero or is ignored if `signatures.length == 0`.
|
|
--Whenever possible implementors should make an active decision about
|
|
--the active signature and shouldn't rely on a default value.
|
|
local contents = {} --- @type string[]
|
|
local active_offset ---@type [integer, integer]?
|
|
local active_signature = signature_help.activeSignature or 0
|
|
-- If the activeSignature is not inside the valid range, then clip it.
|
|
-- In 3.15 of the protocol, activeSignature was allowed to be negative
|
|
if active_signature >= #signature_help.signatures or active_signature < 0 then
|
|
active_signature = 0
|
|
end
|
|
local signature = signature_help.signatures[active_signature + 1]
|
|
if not signature then
|
|
return
|
|
end
|
|
signature = vim.deepcopy(signature)
|
|
local label = signature.label
|
|
if ft then
|
|
-- wrap inside a code block for proper rendering
|
|
label = ('```%s\n%s\n```'):format(ft, label)
|
|
end
|
|
list_extend(contents, vim.split(label, '\n', { plain = true, trimempty = true }))
|
|
local doc = signature.documentation
|
|
if doc then
|
|
-- if LSP returns plain string, we treat it as plaintext. This avoids
|
|
-- special characters like underscore or similar from being interpreted
|
|
-- as markdown font modifiers
|
|
if type(doc) == 'string' then
|
|
signature.documentation = { kind = 'plaintext', value = doc }
|
|
end
|
|
-- Add delimiter if there is documentation to display
|
|
if signature.documentation.value ~= '' then
|
|
contents[#contents + 1] = '---'
|
|
end
|
|
M.convert_input_to_markdown_lines(signature.documentation, contents)
|
|
end
|
|
if signature.parameters and #signature.parameters > 0 then
|
|
local active_parameter = signature.activeParameter or signature_help.activeParameter
|
|
|
|
-- NOTE: We intentionally violate the LSP spec, which states that if `activeParameter`
|
|
-- is not provided or is out-of-bounds, it should default to 0.
|
|
-- Instead, we default to `nil`, as most clients do. In practice, 'no active parameter'
|
|
-- is better default than 'first parameter' and aligns better with user expectations.
|
|
-- Related discussion: https://github.com/microsoft/language-server-protocol/issues/1271
|
|
if
|
|
not active_parameter
|
|
or active_parameter == vim.NIL
|
|
or active_parameter < 0
|
|
or active_parameter >= #signature.parameters
|
|
then
|
|
return contents, nil
|
|
end
|
|
|
|
local parameter = signature.parameters[active_parameter + 1]
|
|
local parameter_label = parameter.label
|
|
if type(parameter_label) == 'table' then
|
|
active_offset = parameter_label
|
|
else
|
|
local offset = 1 ---@type integer?
|
|
-- try to set the initial offset to the first found trigger character
|
|
for _, t in ipairs(triggers or {}) do
|
|
local trigger_offset = signature.label:find(t, 1, true)
|
|
if trigger_offset and (offset == 1 or trigger_offset < offset) then
|
|
offset = trigger_offset
|
|
end
|
|
end
|
|
for p, param in pairs(signature.parameters) do
|
|
local plabel = param.label
|
|
assert(type(plabel) == 'string', 'Expected label to be a string')
|
|
offset = signature.label:find(plabel, offset, true)
|
|
if not offset then
|
|
break
|
|
end
|
|
if p == active_parameter + 1 then
|
|
active_offset = { offset - 1, offset + #parameter_label - 1 }
|
|
break
|
|
end
|
|
offset = offset + #plabel + 1
|
|
end
|
|
end
|
|
if parameter.documentation then
|
|
M.convert_input_to_markdown_lines(parameter.documentation, contents)
|
|
end
|
|
end
|
|
|
|
local active_hl = nil
|
|
if active_offset then
|
|
-- Account for the start of the markdown block.
|
|
if ft then
|
|
active_offset[1] = active_offset[1] + #contents[1]
|
|
active_offset[2] = active_offset[2] + #contents[1]
|
|
end
|
|
|
|
local a_start = get_pos_from_offset(active_offset[1], contents)
|
|
local a_end = get_pos_from_offset(active_offset[2], contents)
|
|
if a_start and a_end then
|
|
active_hl = { a_start[1], a_start[2], a_end[1], a_end[2] }
|
|
end
|
|
end
|
|
|
|
return contents, active_hl
|
|
end
|
|
|
|
--- Creates a table with sensible default options for a floating window. The
|
|
--- table can be passed to |nvim_open_win()|.
|
|
---
|
|
---@param width integer window width (in character cells)
|
|
---@param height integer window height (in character cells)
|
|
---@param opts? vim.lsp.util.open_floating_preview.Opts
|
|
---@return vim.api.keyset.win_config
|
|
function M.make_floating_popup_options(width, height, opts)
|
|
validate('opts', opts, 'table', true)
|
|
opts = opts or {}
|
|
validate('opts.offset_x', opts.offset_x, 'number', true)
|
|
validate('opts.offset_y', opts.offset_y, 'number', true)
|
|
|
|
local anchor = ''
|
|
|
|
local lines_above = vim.fn.winline() - 1
|
|
local lines_below = vim.fn.winheight(0) - lines_above
|
|
if opts.relative == 'mouse' then
|
|
lines_above = vim.fn.getmousepos().line - 1
|
|
lines_below = vim.fn.winheight(0) - lines_above
|
|
elseif opts.relative == 'editor' then
|
|
-- No cursor to anchor against; treat the whole editor as space below.
|
|
lines_above = 0
|
|
lines_below = vim.o.lines
|
|
end
|
|
|
|
local anchor_bias = opts.anchor_bias or 'auto'
|
|
|
|
local anchor_below --- @type boolean?
|
|
|
|
if anchor_bias == 'below' then
|
|
anchor_below = (lines_below > lines_above) or (height <= lines_below)
|
|
elseif anchor_bias == 'above' then
|
|
local anchor_above = (lines_above > lines_below) or (height <= lines_above)
|
|
anchor_below = not anchor_above
|
|
else
|
|
anchor_below = lines_below > lines_above
|
|
end
|
|
|
|
local border_height = get_border_size(opts)
|
|
local row, col --- @type integer?, integer?
|
|
if anchor_below then
|
|
anchor = anchor .. 'N'
|
|
height = math.max(math.min(lines_below - border_height, height), 0)
|
|
row = 1
|
|
else
|
|
anchor = anchor .. 'S'
|
|
height = math.max(math.min(lines_above - border_height, height), 0)
|
|
row = 0
|
|
end
|
|
|
|
local wincol = vim.fn.wincol()
|
|
if opts.relative == 'mouse' then
|
|
wincol = vim.fn.getmousepos().column
|
|
elseif opts.relative == 'editor' then
|
|
wincol = 0
|
|
end
|
|
|
|
if wincol + width + (opts.offset_x or 0) <= vim.o.columns then
|
|
anchor = anchor .. 'W'
|
|
col = 0
|
|
else
|
|
anchor = anchor .. 'E'
|
|
col = 1
|
|
end
|
|
|
|
local title = ((opts.border or vim.o.winborder ~= '') and opts.title) and opts.title or nil
|
|
local title_pos --- @type 'left'|'center'|'right'?
|
|
|
|
if title then
|
|
title_pos = opts.title_pos or 'center'
|
|
end
|
|
|
|
return {
|
|
anchor = anchor,
|
|
row = row + (opts.offset_y or 0),
|
|
col = col + (opts.offset_x or 0),
|
|
height = height,
|
|
focusable = opts.focusable,
|
|
relative = (opts.relative == 'mouse' or opts.relative == 'editor') and opts.relative
|
|
or 'cursor',
|
|
style = 'minimal',
|
|
width = width,
|
|
border = opts.border,
|
|
zindex = opts.zindex or (api.nvim_win_get_config(0).zindex or 49) + 1,
|
|
title = title,
|
|
title_pos = title_pos,
|
|
}
|
|
end
|
|
|
|
--- @class vim.lsp.util.show_document.Opts
|
|
--- @inlinedoc
|
|
---
|
|
--- Jump to existing window if buffer is already open.
|
|
--- @field reuse_win? boolean
|
|
---
|
|
--- Whether to focus/jump to location if possible.
|
|
--- (defaults: true)
|
|
--- @field focus? boolean
|
|
|
|
--- Shows document and optionally jumps to the location.
|
|
---
|
|
---@param location lsp.Location|lsp.LocationLink
|
|
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
|
|
---@param opts? vim.lsp.util.show_document.Opts
|
|
---@return boolean `true` if succeeded
|
|
function M.show_document(location, position_encoding, opts)
|
|
vim.validate('position_encoding', position_encoding, 'string')
|
|
|
|
-- location may be Location or LocationLink
|
|
local uri = location.uri or location.targetUri
|
|
if uri == nil then
|
|
return false
|
|
end
|
|
local bufnr = vim.uri_to_bufnr(uri)
|
|
|
|
opts = opts or {}
|
|
local focus = vim.nonnil(opts.focus, true)
|
|
if focus then
|
|
-- Save position in jumplist
|
|
vim.cmd("normal! m'")
|
|
|
|
-- Push a new item into tagstack
|
|
local from = { vim.fn.bufnr('%'), vim.fn.line('.'), vim.fn.col('.'), 0 }
|
|
local items = { { tagname = vim.fn.expand('<cword>'), from = from } }
|
|
vim.fn.settagstack(vim.fn.win_getid(), { items = items }, 't')
|
|
end
|
|
|
|
local win = opts.reuse_win and vim.fn.win_findbuf(bufnr)[1]
|
|
or focus and api.nvim_get_current_win()
|
|
or create_window_without_focus()
|
|
|
|
vim.bo[bufnr].buflisted = true
|
|
api.nvim_win_set_buf(win, bufnr)
|
|
if focus then
|
|
api.nvim_set_current_win(win)
|
|
end
|
|
|
|
-- location may be Location or LocationLink
|
|
local range = location.range or location.targetSelectionRange
|
|
if range then
|
|
-- Jump to new location (adjusting for encoding of characters)
|
|
local pos = vim.pos.lsp(bufnr, range.start, position_encoding)
|
|
local row, col = pos.row, pos.col
|
|
api.nvim_win_set_cursor(win, { row + 1, col })
|
|
vim._with({ win = win }, function()
|
|
-- Open folds under the cursor
|
|
vim.cmd('normal! zv')
|
|
end)
|
|
|
|
-- nvim_win_set_cursor clamps to last char at EOL. In insert mode the cursor
|
|
-- should be past the last char (append position).
|
|
if vim.api.nvim_get_mode().mode == 'i' then
|
|
local line = api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
|
if col >= #line then
|
|
vim.api.nvim_feedkeys(vim.keycode('<End>'), 'n', false)
|
|
end
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
--- Previews a location in a floating window
|
|
---
|
|
--- behavior depends on type of location:
|
|
--- - for Location, range is shown (e.g., function definition)
|
|
--- - for LocationLink, targetRange is shown (e.g., body of function definition)
|
|
---
|
|
---@param location lsp.Location|lsp.LocationLink
|
|
---@param opts? vim.lsp.util.open_floating_preview.Opts
|
|
---@return integer? buffer id of float window
|
|
---@return integer? window id of float window
|
|
function M.preview_location(location, opts)
|
|
-- location may be LocationLink or Location (more useful for the former)
|
|
local uri = location.targetUri or location.uri
|
|
if uri == nil then
|
|
return
|
|
end
|
|
local bufnr = vim.uri_to_bufnr(uri)
|
|
if not api.nvim_buf_is_loaded(bufnr) then
|
|
vim.fn.bufload(bufnr)
|
|
end
|
|
local range = location.targetRange or location.range
|
|
local contents = api.nvim_buf_get_lines(bufnr, range.start.line, range['end'].line + 1, false)
|
|
local syntax = vim.bo[bufnr].syntax
|
|
if syntax == '' then
|
|
-- When no syntax is set, we use filetype as fallback. This might not result
|
|
-- in a valid syntax definition.
|
|
-- An empty syntax is more common now with TreeSitter, since TS disables syntax.
|
|
syntax = vim.bo[bufnr].filetype
|
|
end
|
|
opts = opts or {}
|
|
opts.focus_id = 'location'
|
|
return M.open_floating_preview(contents, syntax, opts)
|
|
end
|
|
|
|
local function find_window_by_var(name, value)
|
|
for _, win in ipairs(api.nvim_list_wins()) do
|
|
if vim.w[win][name] == value then
|
|
return win
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param winnr integer
|
|
---@return boolean `true` if `winnr` is a valid floating window
|
|
local function is_float(winnr)
|
|
return api.nvim_win_is_valid(winnr) and vim.fn.win_gettype(winnr) == 'popup'
|
|
end
|
|
|
|
---Returns true if the line is empty or only contains whitespace.
|
|
---@param line string
|
|
---@return boolean
|
|
local function is_blank_line(line)
|
|
return line and line:match('^%s*$')
|
|
end
|
|
|
|
---Returns true if the line corresponds to a Markdown thematic break.
|
|
---@see https://github.github.com/gfm/#thematic-break
|
|
---@param line string
|
|
---@return boolean
|
|
local function is_separator_line(line)
|
|
local i = 1
|
|
-- 1. Skip up to 3 leading spaces
|
|
local leading_spaces = 3
|
|
while i <= #line and line:byte(i) == string.byte(' ') and leading_spaces > 0 do
|
|
i = i + 1
|
|
leading_spaces = leading_spaces - 1
|
|
end
|
|
-- 2. Determine the delimiter character
|
|
local delimiter = line:byte(i) -- nil if i > #line
|
|
if
|
|
delimiter ~= string.byte('-')
|
|
and delimiter ~= string.byte('_')
|
|
and delimiter ~= string.byte('*')
|
|
then
|
|
return false
|
|
end
|
|
local ndelimiters = 1
|
|
i = i + 1
|
|
-- 3. Iterate until found non-whitespace or other than expected delimiter
|
|
while i <= #line do
|
|
local char = line:byte(i)
|
|
if char == delimiter then
|
|
ndelimiters = ndelimiters + 1
|
|
elseif not (char == string.byte(' ') or char == string.byte('\t')) then
|
|
return false
|
|
end
|
|
i = i + 1
|
|
end
|
|
return ndelimiters >= 3
|
|
end
|
|
|
|
---Replaces separator lines by the given divider and removing surrounding blank lines.
|
|
---@param contents string[]
|
|
---@param divider string
|
|
---@return string[]
|
|
local function replace_separators(contents, divider)
|
|
local trimmed = {}
|
|
local l = 1
|
|
while l <= #contents do
|
|
local line = contents[l]
|
|
if is_separator_line(line) then
|
|
if l > 1 and is_blank_line(contents[l - 1]) then
|
|
table.remove(trimmed)
|
|
end
|
|
table.insert(trimmed, divider)
|
|
if is_blank_line(contents[l + 1]) then
|
|
l = l + 1
|
|
end
|
|
else
|
|
table.insert(trimmed, line)
|
|
end
|
|
l = l + 1
|
|
end
|
|
|
|
return trimmed
|
|
end
|
|
|
|
---Collapses successive blank lines in the input table into a single one.
|
|
---@param contents string[]
|
|
---@return string[]
|
|
local function collapse_blank_lines(contents)
|
|
local collapsed = {}
|
|
local l = 1
|
|
while l <= #contents do
|
|
local line = contents[l]
|
|
if is_blank_line(line) then
|
|
while is_blank_line(contents[l + 1]) do
|
|
l = l + 1
|
|
end
|
|
end
|
|
table.insert(collapsed, line)
|
|
l = l + 1
|
|
end
|
|
return collapsed
|
|
end
|
|
|
|
local function get_markdown_fences()
|
|
local fences = {} --- @type table<string,string>
|
|
for _, fence in
|
|
pairs(vim.g.markdown_fenced_languages or {} --[[@as string[] ]])
|
|
do
|
|
local lang, syntax = fence:match('^(.*)=(.*)$')
|
|
if lang then
|
|
fences[lang] = syntax
|
|
end
|
|
end
|
|
return fences
|
|
end
|
|
|
|
--- @deprecated
|
|
--- Converts markdown into syntax highlighted regions by stripping the code
|
|
--- blocks and converting them into highlighted code.
|
|
--- This will by default insert a blank line separator after those code block
|
|
--- regions to improve readability.
|
|
---
|
|
--- This method configures the given buffer and returns the lines to set.
|
|
---
|
|
--- If you want to open a popup with fancy markdown, use `open_floating_preview` instead
|
|
---
|
|
---@param bufnr integer
|
|
---@param contents string[] of lines to show in window
|
|
---@param opts? table with optional fields
|
|
--- - height of floating window
|
|
--- - max_height maximal height of floating window
|
|
--- - max_width maximal width of floating window
|
|
--- - separator insert separator after code block
|
|
--- - width of floating window
|
|
--- - wrap_at character to wrap at for computing height
|
|
---@return table stripped content
|
|
function M.stylize_markdown(bufnr, contents, opts)
|
|
vim.deprecate('vim.lsp.util.stylize_markdown', nil, '0.14')
|
|
validate('contents', contents, 'table')
|
|
validate('opts', opts, 'table', true)
|
|
opts = opts or {}
|
|
|
|
-- table of fence types to {ft, begin, end}
|
|
-- when ft is nil, we get the ft from the regex match
|
|
local matchers = {
|
|
block = { nil, '```+%s*([a-zA-Z0-9_]*)', '```+' },
|
|
pre = { nil, '<pre>([a-z0-9]*)', '</pre>' },
|
|
code = { '', '<code>', '</code>' },
|
|
text = { 'text', '<text>', '</text>' },
|
|
}
|
|
|
|
--- @param line string
|
|
--- @return {type:string,ft:string}?
|
|
local function match_begin(line)
|
|
for type, pattern in pairs(matchers) do
|
|
--- @type string?
|
|
local ret = line:match(string.format('^%%s*%s%%s*$', pattern[2]))
|
|
if ret then
|
|
return {
|
|
type = type,
|
|
ft = pattern[1] or ret,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
--- @param line string
|
|
--- @param match {type:string,ft:string}
|
|
--- @return string
|
|
local function match_end(line, match)
|
|
local pattern = matchers[match.type]
|
|
return line:match(string.format('^%%s*%s%%s*$', pattern[3]))
|
|
end
|
|
|
|
-- Clean up
|
|
contents = vim.split(table.concat(contents, '\n'), '\n', { trimempty = true })
|
|
|
|
local stripped = {} --- @type string[]
|
|
local highlights = {} --- @type {ft:string,start:integer,finish:integer}[]
|
|
|
|
local i = 1
|
|
while i <= #contents do
|
|
local line = contents[i]
|
|
local match = match_begin(line)
|
|
if match then
|
|
local start = #stripped
|
|
i = i + 1
|
|
while i <= #contents do
|
|
line = contents[i]
|
|
if match_end(line, match) then
|
|
i = i + 1
|
|
break
|
|
end
|
|
stripped[#stripped + 1] = line
|
|
i = i + 1
|
|
end
|
|
table.insert(highlights, {
|
|
ft = match.ft,
|
|
start = start + 1,
|
|
finish = #stripped,
|
|
})
|
|
-- add a separator, but not on the last line
|
|
if opts.separator and i < #contents then
|
|
stripped[#stripped + 1] = '---'
|
|
end
|
|
else
|
|
-- strip any empty lines or separators prior to this separator in actual markdown
|
|
if line:match('^---+$') then
|
|
while
|
|
stripped[#stripped]
|
|
and (stripped[#stripped]:match('^%s*$') or stripped[#stripped]:match('^---+$'))
|
|
do
|
|
stripped[#stripped] = nil
|
|
end
|
|
end
|
|
-- add the line if its not an empty line following a separator
|
|
if
|
|
not (line:match('^%s*$') and stripped[#stripped] and stripped[#stripped]:match('^---+$'))
|
|
then
|
|
stripped[#stripped + 1] = line
|
|
end
|
|
i = i + 1
|
|
end
|
|
end
|
|
|
|
-- Handle some common html escape sequences
|
|
--- @type string[]
|
|
stripped = vim.tbl_map(
|
|
--- @param line string
|
|
function(line)
|
|
local escapes = {
|
|
['>'] = '>',
|
|
['<'] = '<',
|
|
['"'] = '"',
|
|
['''] = "'",
|
|
[' '] = ' ',
|
|
[' '] = ' ',
|
|
['&'] = '&',
|
|
}
|
|
return (line:gsub('&[^ ;]+;', escapes))
|
|
end,
|
|
stripped
|
|
)
|
|
|
|
-- Compute size of float needed to show (wrapped) lines
|
|
opts.wrap_at = opts.wrap_at or (vim.wo['wrap'] and api.nvim_win_get_width(0))
|
|
local width = M._make_floating_popup_size(stripped, opts)
|
|
|
|
local sep_line = string.rep('─', math.min(width, opts.wrap_at or width))
|
|
|
|
for l in ipairs(stripped) do
|
|
if stripped[l]:match('^---+$') then
|
|
stripped[l] = sep_line
|
|
end
|
|
end
|
|
|
|
api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped)
|
|
|
|
local idx = 1
|
|
-- keep track of syntaxes we already included.
|
|
-- no need to include the same syntax more than once
|
|
local langs = {} --- @type table<string,boolean>
|
|
local fences = get_markdown_fences()
|
|
local function apply_syntax_to_region(ft, start, finish)
|
|
if ft == '' then
|
|
vim.cmd(
|
|
string.format(
|
|
'syntax region markdownCode start=+\\%%%dl+ end=+\\%%%dl+ keepend extend',
|
|
start,
|
|
finish + 1
|
|
)
|
|
)
|
|
return
|
|
end
|
|
ft = fences[ft] or ft
|
|
local name = ft .. idx
|
|
idx = idx + 1
|
|
local lang = '@' .. ft:upper()
|
|
if not langs[lang] then
|
|
-- HACK: reset current_syntax, since some syntax files like markdown won't load if it is already set
|
|
pcall(api.nvim_buf_del_var, bufnr, 'current_syntax')
|
|
if #api.nvim_get_runtime_file(('syntax/%s.vim'):format(ft), true) == 0 then
|
|
return
|
|
end
|
|
--- @diagnostic disable-next-line:param-type-mismatch
|
|
pcall(vim.cmd, string.format('syntax include %s syntax/%s.vim', lang, ft))
|
|
langs[lang] = true
|
|
end
|
|
vim.cmd(
|
|
string.format(
|
|
'syntax region %s start=+\\%%%dl+ end=+\\%%%dl+ contains=%s keepend',
|
|
name,
|
|
start,
|
|
finish + 1,
|
|
lang
|
|
)
|
|
)
|
|
end
|
|
|
|
-- needs to run in the buffer for the regions to work
|
|
vim._with({ buf = bufnr }, function()
|
|
-- we need to apply lsp_markdown regions speperately, since otherwise
|
|
-- markdown regions can "bleed" through the other syntax regions
|
|
-- and mess up the formatting
|
|
local last = 1
|
|
for _, h in ipairs(highlights) do
|
|
if last < h.start then
|
|
apply_syntax_to_region('lsp_markdown', last, h.start - 1)
|
|
end
|
|
apply_syntax_to_region(h.ft, h.start, h.finish)
|
|
last = h.finish + 1
|
|
end
|
|
if last <= #stripped then
|
|
apply_syntax_to_region('lsp_markdown', last, #stripped)
|
|
end
|
|
end)
|
|
|
|
return stripped
|
|
end
|
|
|
|
--- @class (private) vim.lsp.util._normalize_markdown.Opts
|
|
--- @field width integer Thematic breaks are expanded to this size. Defaults to 80.
|
|
|
|
--- Normalizes Markdown input to a canonical form.
|
|
---
|
|
--- The returned Markdown adheres to the GitHub Flavored Markdown (GFM)
|
|
--- specification, as required by the LSP.
|
|
---
|
|
--- The following transformations are made:
|
|
---
|
|
--- 1. Carriage returns ('\r') and empty lines at the beginning and end are removed
|
|
--- 2. Successive empty lines are collapsed into a single empty line
|
|
--- 3. Thematic breaks are expanded to the given width
|
|
---
|
|
---@param contents string[]
|
|
---@param opts? vim.lsp.util._normalize_markdown.Opts
|
|
---@return string[] table of lines containing normalized Markdown
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#markupContent
|
|
---@see https://github.github.com/gfm
|
|
function M._normalize_markdown(contents, opts)
|
|
validate('contents', contents, 'table')
|
|
validate('opts', opts, 'table', true)
|
|
opts = opts or {}
|
|
|
|
-- 1. Carriage returns are removed
|
|
contents = vim.split(table.concat(contents, '\n'):gsub('\r', ''), '\n', { trimempty = true })
|
|
|
|
-- 2. Successive empty lines are collapsed into a single empty line
|
|
contents = collapse_blank_lines(contents)
|
|
|
|
-- 3. Thematic breaks are expanded to the given width
|
|
local divider = string.rep('─', opts.width or 80)
|
|
contents = replace_separators(contents, divider)
|
|
|
|
return contents
|
|
end
|
|
|
|
--- Closes the preview window
|
|
---
|
|
---@param winnr integer window id of preview window
|
|
---@param bufnrs table? optional list of ignored buffers
|
|
local function close_preview_window(winnr, bufnrs)
|
|
vim.schedule(function()
|
|
-- exit if we are in one of ignored buffers
|
|
if bufnrs and vim.list_contains(bufnrs, api.nvim_get_current_buf()) then
|
|
return
|
|
end
|
|
|
|
local augroup = 'nvim.preview_window_' .. winnr
|
|
pcall(api.nvim_del_augroup_by_name, augroup)
|
|
|
|
-- Preview window was converted to a normal window (e.g. |CTRL-W_H|):
|
|
-- keep it open and stop managing it.
|
|
if api.nvim_win_is_valid(winnr) and not is_float(winnr) then
|
|
local srcbuf = vim.w[winnr].lsp_floating_bufnr
|
|
if
|
|
srcbuf
|
|
and api.nvim_buf_is_valid(srcbuf)
|
|
and vim.b[srcbuf].lsp_floating_preview == winnr
|
|
then
|
|
vim.b[srcbuf].lsp_floating_preview = nil
|
|
end
|
|
return
|
|
end
|
|
|
|
pcall(api.nvim_win_close, winnr, true)
|
|
end)
|
|
end
|
|
|
|
--- Creates autocommands to close a preview window when events happen.
|
|
---
|
|
---@param events table list of events
|
|
---@param winnr integer window id of preview window
|
|
---@param floating_bufnr integer floating preview buffer
|
|
---@param bufnr integer buffer that opened the floating preview buffer
|
|
---@see autocmd-events
|
|
local function close_preview_autocmd(events, winnr, floating_bufnr, bufnr)
|
|
local augroup = api.nvim_create_augroup('nvim.preview_window_' .. winnr, {
|
|
clear = true,
|
|
})
|
|
|
|
-- close the preview window when entered a buffer that is not
|
|
-- the floating window buffer or the buffer that spawned it
|
|
nvim_on('BufLeave', augroup, { buf = bufnr }, function()
|
|
vim.schedule(function()
|
|
-- When jumping to the quickfix window from the preview window,
|
|
-- do not close the preview window.
|
|
if api.nvim_get_option_value('filetype', { buf = 0 }) ~= 'qf' then
|
|
close_preview_window(winnr, { floating_bufnr, bufnr })
|
|
end
|
|
end)
|
|
end)
|
|
|
|
if #events > 0 then
|
|
nvim_on(events, augroup, { buf = bufnr }, function()
|
|
close_preview_window(winnr)
|
|
end)
|
|
end
|
|
end
|
|
|
|
--- Computes size of float needed to show contents (with optional wrapping)
|
|
---
|
|
---@param contents string[] of lines to show in window
|
|
---@param opts? vim.lsp.util.open_floating_preview.Opts
|
|
---@return integer width size of float
|
|
---@return integer height size of float
|
|
function M._make_floating_popup_size(contents, opts)
|
|
validate('contents', contents, 'table')
|
|
validate('opts', opts, 'table', true)
|
|
opts = opts or {}
|
|
|
|
local width = opts.width
|
|
local height = opts.height
|
|
local wrap_at = opts.wrap_at
|
|
local max_width = opts.max_width
|
|
local max_height = opts.max_height
|
|
local line_widths = {} --- @type table<integer,integer>
|
|
|
|
if not width then
|
|
width = 0
|
|
for i, line in ipairs(contents) do
|
|
-- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced.
|
|
line_widths[i] = vim.fn.strdisplaywidth(line:gsub('%z', '\n'))
|
|
width = math.max(line_widths[i], width)
|
|
end
|
|
end
|
|
|
|
local _, border_width = get_border_size(opts)
|
|
local screen_width = opts.relative == 'editor' and vim.o.columns or api.nvim_win_get_width(0)
|
|
width = math.min(width, screen_width)
|
|
|
|
-- make sure borders are always inside the screen
|
|
width = math.min(width, screen_width - border_width)
|
|
|
|
-- Make sure that the width is large enough to fit the title.
|
|
local title_length = 0
|
|
local chunks = type(opts.title) == 'string' and { { opts.title } } or opts.title or {}
|
|
for _, chunk in
|
|
ipairs(chunks --[=[@as [string, string][] ]=])
|
|
do
|
|
title_length = title_length + vim.fn.strdisplaywidth(chunk[1])
|
|
end
|
|
|
|
width = math.max(width, title_length)
|
|
|
|
if wrap_at then
|
|
wrap_at = math.min(wrap_at, width)
|
|
end
|
|
|
|
if max_width then
|
|
width = math.min(width, max_width)
|
|
wrap_at = math.min(wrap_at or max_width, max_width)
|
|
end
|
|
|
|
if not height then
|
|
height = #contents
|
|
if wrap_at and width >= wrap_at then
|
|
height = 0
|
|
if vim.tbl_isempty(line_widths) then
|
|
for _, line in ipairs(contents) do
|
|
local line_width = vim.fn.strdisplaywidth(line:gsub('%z', '\n'))
|
|
height = height + math.max(1, math.ceil(line_width / wrap_at))
|
|
end
|
|
else
|
|
for i = 1, #contents do
|
|
height = height + math.max(1, math.ceil(line_widths[i] / wrap_at))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if max_height then
|
|
height = math.min(height, max_height)
|
|
end
|
|
|
|
return width, height
|
|
end
|
|
|
|
--- @class vim.lsp.util.open_floating_preview.Opts
|
|
---
|
|
--- Height of floating window
|
|
--- @field height? integer
|
|
---
|
|
--- Width of floating window
|
|
--- @field width? integer
|
|
---
|
|
--- Wrap long lines
|
|
--- (default: `true`)
|
|
--- @field wrap? boolean
|
|
---
|
|
--- Character to wrap at for computing height when wrap is enabled
|
|
--- @field wrap_at? integer
|
|
---
|
|
--- Maximal width of floating window
|
|
--- @field max_width? integer
|
|
---
|
|
--- Maximal height of floating window
|
|
--- @field max_height? integer
|
|
---
|
|
--- If a popup with this id is opened, then focus it
|
|
--- @field focus_id? string
|
|
---
|
|
--- List of events that closes the floating window
|
|
--- @field close_events? table
|
|
---
|
|
--- Make float focusable.
|
|
--- (default: `true`)
|
|
--- @field focusable? boolean
|
|
---
|
|
--- If `true`, and if {focusable} is also `true`, focus an existing floating
|
|
--- window with the same {focus_id}
|
|
--- (default: `true`)
|
|
--- @field focus? boolean
|
|
---
|
|
--- offset to add to `col`
|
|
--- @field offset_x? integer
|
|
---
|
|
--- offset to add to `row`
|
|
--- @field offset_y? integer
|
|
--- @field border? string|(string|[string,string])[] override `border`
|
|
--- @field zindex? integer override `zindex`, defaults to 50
|
|
--- @field title? string|[string,string][]
|
|
--- @field title_pos? 'left'|'center'|'right'
|
|
---
|
|
--- (default: `'cursor'`)
|
|
--- @field relative? 'mouse'|'cursor'|'editor'
|
|
---
|
|
--- Adjusts placement relative to cursor.
|
|
--- - "auto": place window based on which side of the cursor has more lines
|
|
--- - "above": place the window above the cursor unless there are not enough lines
|
|
--- to display the full window height.
|
|
--- - "below": place the window below the cursor unless there are not enough lines
|
|
--- to display the full window height.
|
|
--- (default: `'auto'`)
|
|
--- @field anchor_bias? 'auto'|'above'|'below'
|
|
---
|
|
--- @field _update_win? integer
|
|
|
|
--- Shows contents in a floating window.
|
|
---
|
|
---@param contents table of lines to show in window
|
|
---@param syntax string of syntax to set for opened buffer
|
|
---@param opts? vim.lsp.util.open_floating_preview.Opts with optional fields
|
|
--- (additional keys are filtered with |vim.lsp.util.make_floating_popup_options()|
|
|
--- before they are passed on to |nvim_open_win()|)
|
|
---@return integer bufnr of newly created float window
|
|
---@return integer winid of newly created float window preview window
|
|
function M.open_floating_preview(contents, syntax, opts)
|
|
validate('contents', contents, 'table')
|
|
validate('syntax', syntax, 'string', true)
|
|
validate('opts', opts, 'table', true)
|
|
opts = opts or {}
|
|
opts.wrap = opts.wrap ~= false -- wrapping by default
|
|
opts.focus = opts.focus ~= false
|
|
opts.close_events = opts.close_events or { 'CursorMoved', 'CursorMovedI', 'InsertCharPre' }
|
|
|
|
local bufnr = api.nvim_get_current_buf()
|
|
|
|
local floating_winnr = opts._update_win
|
|
|
|
-- Create/get the buffer
|
|
local floating_bufnr --- @type integer
|
|
if floating_winnr then
|
|
floating_bufnr = api.nvim_win_get_buf(floating_winnr)
|
|
else
|
|
-- check if this popup is focusable and we need to focus
|
|
if opts.focus_id and opts.focusable ~= false and opts.focus then
|
|
-- Go back to previous window if we are in a focusable one
|
|
local current_winnr = api.nvim_get_current_win()
|
|
if vim.w[current_winnr][opts.focus_id] and is_float(current_winnr) then
|
|
api.nvim_command('wincmd p')
|
|
return bufnr, current_winnr
|
|
end
|
|
do
|
|
local win = find_window_by_var(opts.focus_id, bufnr)
|
|
if win and is_float(win) and vim.fn.pumvisible() == 0 then
|
|
-- focus and return the existing buf, win
|
|
api.nvim_set_current_win(win)
|
|
api.nvim_command('stopinsert')
|
|
return api.nvim_win_get_buf(win), win
|
|
end
|
|
end
|
|
end
|
|
|
|
-- check if another floating preview already exists for this buffer
|
|
-- and close it if needed
|
|
local existing_float = vim.b[bufnr].lsp_floating_preview
|
|
if existing_float and is_float(existing_float) then
|
|
api.nvim_win_close(existing_float, true)
|
|
end
|
|
floating_bufnr = api.nvim_create_buf(false, true)
|
|
end
|
|
|
|
-- Set up the contents, using treesitter for markdown
|
|
local do_stylize = syntax == 'markdown' and vim.g.syntax_on ~= nil
|
|
|
|
if do_stylize then
|
|
local width = M._make_floating_popup_size(contents, opts)
|
|
contents = M._normalize_markdown(contents, { width = width })
|
|
else
|
|
-- Clean up input: trim empty lines
|
|
contents = vim.split(table.concat(contents, '\n'), '\n', { trimempty = true })
|
|
|
|
if syntax then
|
|
vim.bo[floating_bufnr].syntax = syntax
|
|
end
|
|
end
|
|
|
|
vim.bo[floating_bufnr].modifiable = true
|
|
api.nvim_buf_set_lines(floating_bufnr, 0, -1, false, contents)
|
|
|
|
if floating_winnr then
|
|
api.nvim_win_set_config(floating_winnr, {
|
|
border = opts.border,
|
|
title = opts.title,
|
|
})
|
|
else
|
|
-- Compute size of float needed to show (wrapped) lines
|
|
if opts.wrap then
|
|
opts.wrap_at = opts.wrap_at or api.nvim_win_get_width(0)
|
|
else
|
|
opts.wrap_at = nil
|
|
end
|
|
|
|
-- TODO(lewis6991): These function assume the current window to determine options,
|
|
-- therefore it won't work for opts._update_win and the current window if the floating
|
|
-- window
|
|
local width, height = M._make_floating_popup_size(contents, opts)
|
|
local float_option = M.make_floating_popup_options(width, height, opts)
|
|
|
|
floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option)
|
|
|
|
api.nvim_buf_set_keymap(
|
|
floating_bufnr,
|
|
'n',
|
|
'q',
|
|
'<cmd>bdelete<cr>',
|
|
{ silent = true, noremap = true, nowait = true }
|
|
)
|
|
close_preview_autocmd(opts.close_events, floating_winnr, floating_bufnr, bufnr)
|
|
|
|
-- save focus_id
|
|
if opts.focus_id then
|
|
api.nvim_win_set_var(floating_winnr, opts.focus_id, bufnr)
|
|
end
|
|
api.nvim_buf_set_var(bufnr, 'lsp_floating_preview', floating_winnr)
|
|
api.nvim_win_set_var(floating_winnr, 'lsp_floating_bufnr', bufnr)
|
|
end
|
|
|
|
nvim_on(
|
|
'WinClosed',
|
|
api.nvim_create_augroup('nvim.closing_floating_preview', { clear = true }),
|
|
function(args)
|
|
local winid = vim._tointeger(args.match)
|
|
local preview_bufnr = vim.w[winid].lsp_floating_bufnr
|
|
if
|
|
preview_bufnr
|
|
and api.nvim_buf_is_valid(preview_bufnr)
|
|
and winid == vim.b[preview_bufnr].lsp_floating_preview
|
|
then
|
|
vim.b[bufnr].lsp_floating_preview = nil
|
|
return true
|
|
end
|
|
end
|
|
)
|
|
|
|
vim.wo[floating_winnr].foldenable = false -- Disable folding.
|
|
vim.wo[floating_winnr].wrap = opts.wrap -- Soft wrapping.
|
|
vim.wo[floating_winnr].linebreak = true -- Break lines a bit nicer
|
|
vim.wo[floating_winnr].breakindent = true -- Slightly better list presentation.
|
|
vim.wo[floating_winnr].smoothscroll = true -- Scroll by screen-line instead of buffer-line.
|
|
vim.wo[floating_winnr].winfixbuf = true -- Disable buffer switching.
|
|
|
|
vim.bo[floating_bufnr].modifiable = false
|
|
vim.bo[floating_bufnr].bufhidden = 'wipe'
|
|
|
|
if do_stylize then
|
|
vim.wo[floating_winnr].conceallevel = 2
|
|
vim.wo[floating_winnr].concealcursor = ''
|
|
vim.bo[floating_bufnr].filetype = 'markdown'
|
|
vim.treesitter.start(floating_bufnr)
|
|
if not opts.height then
|
|
-- Reduce window height if TS highlighter conceals code block backticks.
|
|
local win_height = api.nvim_win_get_height(floating_winnr)
|
|
local text_height = api.nvim_win_text_height(floating_winnr, { max_height = win_height }).all
|
|
if text_height < win_height then
|
|
api.nvim_win_resize(floating_winnr, -1, text_height)
|
|
end
|
|
end
|
|
end
|
|
|
|
return floating_bufnr, floating_winnr
|
|
end
|
|
|
|
do --[[ References ]]
|
|
local reference_ns = api.nvim_create_namespace('nvim.lsp.references')
|
|
|
|
--- Removes document highlights from a buffer.
|
|
---
|
|
---@param bufnr integer? Buffer id
|
|
function M.buf_clear_references(bufnr)
|
|
api.nvim_buf_clear_namespace(bufnr or 0, reference_ns, 0, -1)
|
|
end
|
|
|
|
--- Shows a list of document highlights for a certain buffer.
|
|
---
|
|
---@param bufnr integer Buffer id
|
|
---@param references lsp.DocumentHighlight[] objects to highlight
|
|
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
|
|
---@see https://microsoft.github.io/language-server-protocol/specification/#textDocumentContentChangeEvent
|
|
function M.buf_highlight_references(bufnr, references, position_encoding)
|
|
validate('bufnr', bufnr, 'number', true)
|
|
validate('position_encoding', position_encoding, 'string', false)
|
|
for _, reference in ipairs(references) do
|
|
local range = vim.range.lsp(bufnr, reference.range, position_encoding)
|
|
|
|
local document_highlight_kind = {
|
|
[protocol.DocumentHighlightKind.Text] = 'LspReferenceText',
|
|
[protocol.DocumentHighlightKind.Read] = 'LspReferenceRead',
|
|
[protocol.DocumentHighlightKind.Write] = 'LspReferenceWrite',
|
|
}
|
|
local kind = reference['kind'] or protocol.DocumentHighlightKind.Text
|
|
vim.hl.range(
|
|
bufnr,
|
|
reference_ns,
|
|
document_highlight_kind[kind],
|
|
{ range.start_row, range.start_col },
|
|
{ range.end_row, range.end_col },
|
|
{ priority = vim.hl.priorities.user }
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
local position_sort = sort_by_key(function(v)
|
|
return { v.start.line, v.start.character }
|
|
end)
|
|
|
|
--- Returns the items with the byte position calculated correctly and in sorted
|
|
--- order, for display in quickfix and location lists.
|
|
---
|
|
--- The `user_data` field of each resulting item will contain the original
|
|
--- `Location` or `LocationLink` it was computed from.
|
|
---
|
|
--- The result can be passed to the {list} argument of |setqflist()| or
|
|
--- |setloclist()|.
|
|
---
|
|
---@param locations lsp.Location[]|lsp.LocationLink[]
|
|
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
|
|
---@return vim.quickfix.entry[] # See |setqflist()| for the format
|
|
function M.locations_to_items(locations, position_encoding)
|
|
vim.validate('position_encoding', position_encoding, 'string')
|
|
|
|
local items = {} --- @type vim.quickfix.entry[]
|
|
|
|
---@type table<string, {start: lsp.Position, end: lsp.Position, location: lsp.Location|lsp.LocationLink}[]>
|
|
local grouped = {}
|
|
for _, d in ipairs(locations) do
|
|
-- locations may be Location or LocationLink
|
|
local uri = d.uri or d.targetUri
|
|
local range = d.range or d.targetSelectionRange
|
|
grouped[uri] = grouped[uri] or {}
|
|
table.insert(grouped[uri], { start = range.start, ['end'] = range['end'], location = d })
|
|
end
|
|
|
|
for uri, rows in vim.spairs(grouped) do
|
|
table.sort(rows, position_sort)
|
|
local filename = vim.uri_to_fname(uri)
|
|
|
|
local line_numbers = {}
|
|
for _, temp in ipairs(rows) do
|
|
table.insert(line_numbers, temp.start.line)
|
|
if temp.start.line ~= temp['end'].line then
|
|
table.insert(line_numbers, temp['end'].line)
|
|
end
|
|
end
|
|
|
|
-- get all the lines for this uri
|
|
local lines = get_lines(vim.uri_to_bufnr(uri), line_numbers)
|
|
|
|
for _, temp in ipairs(rows) do
|
|
local pos = temp.start
|
|
local end_pos = temp['end']
|
|
local row = pos.line
|
|
local end_row = end_pos.line
|
|
local line = lines[row]
|
|
local end_line = lines[end_row]
|
|
local col = vim.str_byteindex(line, position_encoding, pos.character, false)
|
|
local end_col = vim.str_byteindex(end_line, position_encoding, end_pos.character, false)
|
|
|
|
items[#items + 1] = {
|
|
filename = filename,
|
|
lnum = row + 1,
|
|
end_lnum = end_row + 1,
|
|
col = col + 1,
|
|
end_col = end_col + 1,
|
|
text = line,
|
|
user_data = temp.location,
|
|
}
|
|
end
|
|
end
|
|
return items
|
|
end
|
|
|
|
--- Converts symbols to quickfix list items.
|
|
---
|
|
---@param symbols lsp.DocumentSymbol[]|lsp.SymbolInformation[]|lsp.WorkspaceSymbol[] list of symbols
|
|
---@param bufnr? integer buffer handle or 0 for current, defaults to current
|
|
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
|
|
---@return vim.quickfix.entry[] # See |setqflist()| for the format
|
|
function M.symbols_to_items(symbols, bufnr, position_encoding)
|
|
vim.validate('position_encoding', position_encoding, 'string')
|
|
|
|
bufnr = vim._resolve_bufnr(bufnr)
|
|
|
|
local items = {} --- @type vim.quickfix.entry[]
|
|
for _, symbol in ipairs(symbols) do
|
|
--- @type string?, vim.Range?
|
|
local filename, range
|
|
|
|
if symbol.location then
|
|
--- @cast symbol lsp.SymbolInformation
|
|
filename = vim.uri_to_fname(symbol.location.uri)
|
|
range = vim.range.lsp(bufnr, symbol.location.range, position_encoding)
|
|
elseif symbol.selectionRange then
|
|
--- @cast symbol lsp.DocumentSymbol
|
|
filename = api.nvim_buf_get_name(bufnr)
|
|
range = vim.range.lsp(bufnr, symbol.selectionRange, position_encoding)
|
|
end
|
|
|
|
if filename and range then
|
|
local kind = protocol.SymbolKind[symbol.kind] or 'Unknown'
|
|
local is_deprecated = not vim.isnil(symbol.deprecated or nil)
|
|
or (
|
|
not vim.isnil(symbol.tags)
|
|
and vim.tbl_contains(symbol.tags, protocol.SymbolTag.Deprecated)
|
|
)
|
|
local text = string.format(
|
|
'[%s] %s%s%s',
|
|
kind,
|
|
symbol.name,
|
|
not vim.isnil(symbol.containerName) and ' in ' .. symbol.containerName or '',
|
|
is_deprecated and ' (deprecated)' or ''
|
|
)
|
|
|
|
items[#items + 1] = {
|
|
filename = filename,
|
|
lnum = range.start_row + 1,
|
|
col = range.start_col + 1,
|
|
end_lnum = range.end_row + 1,
|
|
end_col = range.end_col + 1,
|
|
kind = kind,
|
|
text = text,
|
|
}
|
|
end
|
|
|
|
if symbol.children then
|
|
list_extend(items, M.symbols_to_items(symbol.children, bufnr, position_encoding))
|
|
end
|
|
end
|
|
|
|
return items
|
|
end
|
|
|
|
--- Creates a `TextDocumentPositionParams` object for the current buffer and cursor position.
|
|
---
|
|
---@param win integer?: |window-ID| or 0 for current, defaults to current
|
|
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
|
|
---@return lsp.TextDocumentPositionParams
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams
|
|
function M.make_position_params(win, position_encoding)
|
|
win = win or 0
|
|
return {
|
|
textDocument = M.make_text_document_params(api.nvim_win_get_buf(win)),
|
|
position = vim.pos.cursor(win):to_lsp(position_encoding),
|
|
}
|
|
end
|
|
|
|
--- Using the current position in the current buffer, creates an object that
|
|
--- can be used as a building block for several LSP requests, such as
|
|
--- `textDocument/codeAction`, `textDocument/colorPresentation`,
|
|
--- `textDocument/rangeFormatting`.
|
|
---
|
|
---@param win integer?: |window-ID| or 0 for current, defaults to current
|
|
---@param position_encoding "utf-8"|"utf-16"|"utf-32"
|
|
---@return { textDocument: { uri: lsp.DocumentUri }, range: lsp.Range }
|
|
function M.make_range_params(win, position_encoding)
|
|
win = win or 0
|
|
local buf = api.nvim_win_get_buf(win)
|
|
local position = vim.pos.cursor(buf, api.nvim_win_get_cursor(win)):to_lsp(position_encoding)
|
|
return {
|
|
textDocument = M.make_text_document_params(buf),
|
|
range = { start = position, ['end'] = position },
|
|
}
|
|
end
|
|
|
|
--- Using the given range in the current buffer, creates an object that
|
|
--- is similar to |vim.lsp.util.make_range_params()|.
|
|
---
|
|
---@param start_pos [integer,integer]? {row,col} mark-indexed position.
|
|
--- Defaults to the start of the last visual selection.
|
|
---@param end_pos [integer,integer]? {row,col} mark-indexed position.
|
|
--- Defaults to the end of the last visual selection.
|
|
---@param bufnr integer? buffer handle or 0 for current, defaults to current
|
|
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
|
|
---@return { textDocument: { uri: lsp.DocumentUri }, range: lsp.Range }
|
|
function M.make_given_range_params(start_pos, end_pos, bufnr, position_encoding)
|
|
validate('start_pos', start_pos, 'table', true)
|
|
validate('end_pos', end_pos, 'table', true)
|
|
validate('position_encoding', position_encoding, 'string')
|
|
|
|
bufnr = vim._resolve_bufnr(bufnr)
|
|
local start_row, start_col = unpack(start_pos or api.nvim_buf_get_mark(bufnr, '<'))
|
|
local end_row, end_col = unpack(end_pos or api.nvim_buf_get_mark(bufnr, '>'))
|
|
return {
|
|
textDocument = M.make_text_document_params(bufnr),
|
|
range = vim.range.mark(bufnr, start_row, start_col, end_row, end_col):to_lsp(position_encoding),
|
|
}
|
|
end
|
|
|
|
--- Creates a `TextDocumentIdentifier` object for the current buffer.
|
|
---
|
|
---@param bufnr integer?: Buffer handle, defaults to current
|
|
---@return lsp.TextDocumentIdentifier
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentIdentifier
|
|
function M.make_text_document_params(bufnr)
|
|
return { uri = vim.uri_from_bufnr(bufnr or 0) }
|
|
end
|
|
|
|
--- Create the workspace params
|
|
---@param added lsp.WorkspaceFolder[]
|
|
---@param removed lsp.WorkspaceFolder[]
|
|
---@return lsp.DidChangeWorkspaceFoldersParams
|
|
function M.make_workspace_params(added, removed)
|
|
return { event = { added = added, removed = removed } }
|
|
end
|
|
|
|
--- Returns indentation size.
|
|
---
|
|
---@see 'shiftwidth'
|
|
---@param bufnr integer?: Buffer handle, defaults to current
|
|
---@return integer indentation size
|
|
function M.get_effective_tabstop(bufnr)
|
|
validate('bufnr', bufnr, 'number', true)
|
|
local bo = bufnr and vim.bo[bufnr] or vim.bo
|
|
local sw = bo.shiftwidth
|
|
return (sw == 0 and bo.tabstop) or sw
|
|
end
|
|
|
|
--- Creates a `DocumentFormattingParams` object for the current buffer and cursor position.
|
|
---
|
|
---@param options lsp.FormattingOptions? with valid `FormattingOptions` entries
|
|
---@return lsp.DocumentFormattingParams object
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting
|
|
function M.make_formatting_params(options)
|
|
validate('options', options, 'table', true)
|
|
options = vim.tbl_extend('keep', options or {}, {
|
|
tabSize = M.get_effective_tabstop(),
|
|
insertSpaces = vim.bo.expandtab,
|
|
})
|
|
return {
|
|
textDocument = { uri = vim.uri_from_bufnr(0) },
|
|
options = options,
|
|
}
|
|
end
|
|
|
|
--- Returns the UTF-32 and UTF-16 offsets for a position in a certain buffer.
|
|
---
|
|
---@deprecated
|
|
---@param buf integer buffer number (0 for current)
|
|
---@param row integer 0-indexed line
|
|
---@param col integer 0-indexed byte offset in line
|
|
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
|
|
---@return integer `position_encoding` index of the character in line {row} column {col} in buffer {buf}
|
|
function M.character_offset(buf, row, col, position_encoding)
|
|
vim.deprecate('vim.lsp.util.character_offset', 'vim.str_utfindex', '0.14')
|
|
vim.validate('position_encoding', position_encoding, 'string')
|
|
|
|
local line = get_line(buf, row)
|
|
return vim.str_utfindex(line, position_encoding, col, false)
|
|
end
|
|
|
|
---@class (private) vim.lsp.util._cancel_requests.Filter
|
|
---@field bufnr? integer
|
|
---@field clients? vim.lsp.Client[]
|
|
---@field method? vim.lsp.protocol.Method.ClientToServer.Request
|
|
---@field type? string
|
|
|
|
--- Cancel all {filter}ed requests.
|
|
---
|
|
---@param filter? vim.lsp.util._cancel_requests.Filter
|
|
function M._cancel_requests(filter)
|
|
filter = filter or {}
|
|
local bufnr = filter.bufnr and vim._resolve_bufnr(filter.bufnr) or nil
|
|
local clients = filter.clients
|
|
local method = filter.method
|
|
local type = filter.type
|
|
|
|
for _, client in
|
|
ipairs(clients or vim.lsp.get_clients({
|
|
bufnr = bufnr,
|
|
method = method,
|
|
}))
|
|
do
|
|
for id, request in pairs(client.requests) do
|
|
if
|
|
(bufnr == nil or bufnr == request.bufnr)
|
|
and (method == nil or method == request.method)
|
|
and (type == nil or type == request.type)
|
|
then
|
|
client:cancel_request(id)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
---@nodoc
|
|
---@type table<integer,integer>
|
|
M.buf_versions = setmetatable({}, {
|
|
__index = function(t, bufnr)
|
|
return rawget(t, bufnr) or 0
|
|
end,
|
|
})
|
|
|
|
return M
|