mirror of
https://github.com/neovim/neovim
synced 2025-07-16 09:11:51 +00:00
Previously rename would unconditionally read the to-be-renamed file from the disk and write it to the disk. This is redundant in some cases If the file is not already loaded, it's not attached to lsp client, so nvim doesn't need to care about this file. If the file is loaded but has no change, it doesn't need to be written.
2278 lines
75 KiB
Lua
2278 lines
75 KiB
Lua
local protocol = require('vim.lsp.protocol')
|
|
local snippet = require('vim.lsp._snippet_grammar')
|
|
local validate = vim.validate
|
|
local api = vim.api
|
|
local list_extend = vim.list_extend
|
|
local highlight = vim.highlight
|
|
local uv = vim.uv
|
|
|
|
local npcall = vim.F.npcall
|
|
local split = vim.split
|
|
|
|
local M = {}
|
|
|
|
local default_border = {
|
|
{ '', 'NormalFloat' },
|
|
{ '', 'NormalFloat' },
|
|
{ '', 'NormalFloat' },
|
|
{ ' ', 'NormalFloat' },
|
|
{ '', 'NormalFloat' },
|
|
{ '', 'NormalFloat' },
|
|
{ '', 'NormalFloat' },
|
|
{ ' ', 'NormalFloat' },
|
|
}
|
|
|
|
--- Check the border given by opts or the default border for the additional
|
|
--- size it adds to a float.
|
|
---@param opts table optional options for the floating window
|
|
--- - border (string or table) the border
|
|
---@return table size of border in the form of { height = height, width = width }
|
|
local function get_border_size(opts)
|
|
local border = opts and opts.border or default_border
|
|
local height = 0
|
|
local width = 0
|
|
|
|
if type(border) == 'string' then
|
|
local border_size = {
|
|
none = { 0, 0 },
|
|
single = { 2, 2 },
|
|
double = { 2, 2 },
|
|
rounded = { 2, 2 },
|
|
solid = { 2, 2 },
|
|
shadow = { 1, 1 },
|
|
}
|
|
if border_size[border] == nil then
|
|
error(
|
|
string.format(
|
|
'invalid floating preview border: %s. :help vim.api.nvim_open_win()',
|
|
vim.inspect(border)
|
|
)
|
|
)
|
|
end
|
|
height, width = unpack(border_size[border])
|
|
else
|
|
if 8 % #border ~= 0 then
|
|
error(
|
|
string.format(
|
|
'invalid floating preview border: %s. :help vim.api.nvim_open_win()',
|
|
vim.inspect(border)
|
|
)
|
|
)
|
|
end
|
|
local function border_width(id)
|
|
id = (id - 1) % #border + 1
|
|
if type(border[id]) == 'table' then
|
|
-- border specified as a table of <character, highlight group>
|
|
return vim.fn.strdisplaywidth(border[id][1])
|
|
elseif type(border[id]) == 'string' then
|
|
-- border specified as a list of border characters
|
|
return vim.fn.strdisplaywidth(border[id])
|
|
end
|
|
error(
|
|
string.format(
|
|
'invalid floating preview border: %s. :help vim.api.nvim_open_win()',
|
|
vim.inspect(border)
|
|
)
|
|
)
|
|
end
|
|
local function border_height(id)
|
|
id = (id - 1) % #border + 1
|
|
if type(border[id]) == 'table' then
|
|
-- border specified as a table of <character, highlight group>
|
|
return #border[id][1] > 0 and 1 or 0
|
|
elseif type(border[id]) == 'string' then
|
|
-- border specified as a list of border characters
|
|
return #border[id] > 0 and 1 or 0
|
|
end
|
|
error(
|
|
string.format(
|
|
'invalid floating preview border: %s. :help vim.api.nvim_open_win()',
|
|
vim.inspect(border)
|
|
)
|
|
)
|
|
end
|
|
height = height + border_height(2) -- top
|
|
height = height + border_height(6) -- bottom
|
|
width = width + border_width(4) -- right
|
|
width = width + border_width(8) -- left
|
|
end
|
|
|
|
return { height = height, width = width }
|
|
end
|
|
|
|
local function split_lines(value)
|
|
value = string.gsub(value, '\r\n?', '\n')
|
|
return split(value, '\n', { plain = true, trimempty = true })
|
|
end
|
|
|
|
local function create_window_without_focus()
|
|
local prev = vim.api.nvim_get_current_win()
|
|
vim.cmd.new()
|
|
local new = vim.api.nvim_get_current_win()
|
|
vim.api.nvim_set_current_win(prev)
|
|
return new
|
|
end
|
|
|
|
--- Convert byte index to `encoding` index.
|
|
--- Convenience wrapper around vim.str_utfindex
|
|
---@param line string line to be indexed
|
|
---@param index integer|nil byte index (utf-8), or `nil` for length
|
|
---@param encoding string|nil utf-8|utf-16|utf-32|nil defaults to utf-16
|
|
---@return integer `encoding` index of `index` in `line`
|
|
function M._str_utfindex_enc(line, index, encoding)
|
|
if not encoding then
|
|
encoding = 'utf-16'
|
|
end
|
|
if encoding == 'utf-8' then
|
|
if index then
|
|
return index
|
|
else
|
|
return #line
|
|
end
|
|
elseif encoding == 'utf-16' then
|
|
local _, col16 = vim.str_utfindex(line, index)
|
|
return col16
|
|
elseif encoding == 'utf-32' then
|
|
local col32, _ = vim.str_utfindex(line, index)
|
|
return col32
|
|
else
|
|
error('Invalid encoding: ' .. vim.inspect(encoding))
|
|
end
|
|
end
|
|
|
|
--- Convert UTF index to `encoding` index.
|
|
--- Convenience wrapper around vim.str_byteindex
|
|
---Alternative to vim.str_byteindex that takes an encoding.
|
|
---@param line string line to be indexed
|
|
---@param index integer UTF index
|
|
---@param encoding string utf-8|utf-16|utf-32| defaults to utf-16
|
|
---@return integer byte (utf-8) index of `encoding` index `index` in `line`
|
|
function M._str_byteindex_enc(line, index, encoding)
|
|
if not encoding then
|
|
encoding = 'utf-16'
|
|
end
|
|
if encoding == 'utf-8' then
|
|
if index then
|
|
return index
|
|
else
|
|
return #line
|
|
end
|
|
elseif encoding == 'utf-16' then
|
|
return vim.str_byteindex(line, index, true)
|
|
elseif encoding == 'utf-32' then
|
|
return vim.str_byteindex(line, index)
|
|
else
|
|
error('Invalid encoding: ' .. vim.inspect(encoding))
|
|
end
|
|
end
|
|
|
|
local _str_utfindex_enc = M._str_utfindex_enc
|
|
local _str_byteindex_enc = M._str_byteindex_enc
|
|
|
|
--- Replaces text in a range with new text.
|
|
---
|
|
--- CAUTION: Changes in-place!
|
|
---
|
|
---@deprecated
|
|
---@param lines (table) Original list of strings
|
|
---@param A (table) Start position; a 2-tuple of {line,col} numbers
|
|
---@param B (table) End position; a 2-tuple of {line,col} numbers
|
|
---@param new_lines (table) list of strings to replace the original
|
|
---@return table The modified {lines} object
|
|
function M.set_lines(lines, A, B, new_lines)
|
|
vim.deprecate('vim.lsp.util.set_lines()', 'nil', '0.12')
|
|
-- 0-indexing to 1-indexing
|
|
local i_0 = A[1] + 1
|
|
-- If it extends past the end, truncate it to the end. This is because the
|
|
-- way the LSP describes the range including the last newline is by
|
|
-- specifying a line number after what we would call the last line.
|
|
local i_n = math.min(B[1] + 1, #lines)
|
|
if not (i_0 >= 1 and i_0 <= #lines + 1 and i_n >= 1 and i_n <= #lines) then
|
|
error('Invalid range: ' .. vim.inspect({ A = A, B = B, #lines, new_lines }))
|
|
end
|
|
local prefix = ''
|
|
local suffix = lines[i_n]:sub(B[2] + 1)
|
|
if A[2] > 0 then
|
|
prefix = lines[i_0]:sub(1, A[2])
|
|
end
|
|
local n = i_n - i_0 + 1
|
|
if n ~= #new_lines then
|
|
for _ = 1, n - #new_lines do
|
|
table.remove(lines, i_0)
|
|
end
|
|
for _ = 1, #new_lines - n do
|
|
table.insert(lines, i_0, '')
|
|
end
|
|
end
|
|
for i = 1, #new_lines do
|
|
lines[i - 1 + i_0] = new_lines[i]
|
|
end
|
|
if #suffix > 0 then
|
|
local i = i_0 + #new_lines - 1
|
|
lines[i] = lines[i] .. suffix
|
|
end
|
|
if #prefix > 0 then
|
|
lines[i_0] = prefix .. lines[i_0]
|
|
end
|
|
return lines
|
|
end
|
|
|
|
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
|
|
|
|
--- Gets the zero-indexed lines from the given buffer.
|
|
--- Works on unloaded buffers by reading the file using libuv to bypass buf reading events.
|
|
--- Falls back to loading the buffer and nvim_buf_get_lines for buffers with non-file URI.
|
|
---
|
|
---@param bufnr integer bufnr to get the lines from
|
|
---@param rows integer[] zero-indexed line numbers
|
|
---@return table<integer, string>|string a table mapping rows to lines
|
|
local function get_lines(bufnr, rows)
|
|
rows = type(rows) == 'table' and rows or { rows }
|
|
|
|
-- This is needed for bufload and bufloaded
|
|
if bufnr == 0 then
|
|
bufnr = api.nvim_get_current_buf()
|
|
end
|
|
|
|
local function buf_lines()
|
|
local lines = {}
|
|
for _, row in ipairs(rows) do
|
|
lines[row] = (api.nvim_buf_get_lines(bufnr, row, row + 1, false) or { '' })[1]
|
|
end
|
|
return lines
|
|
end
|
|
|
|
-- use loaded buffers if available
|
|
if vim.fn.bufloaded(bufnr) == 1 then
|
|
return buf_lines()
|
|
end
|
|
|
|
local uri = vim.uri_from_bufnr(bufnr)
|
|
|
|
-- load the buffer if this is not a file uri
|
|
-- Custom language server protocol extensions can result in servers sending URIs with custom schemes. Plugins are able to load these via `BufReadCmd` autocmds.
|
|
if uri:sub(1, 4) ~= 'file' then
|
|
vim.fn.bufload(bufnr)
|
|
return buf_lines()
|
|
end
|
|
|
|
local filename = api.nvim_buf_get_name(bufnr)
|
|
|
|
-- get the data from the file
|
|
local fd = uv.fs_open(filename, 'r', 438)
|
|
if not fd then
|
|
return ''
|
|
end
|
|
local stat = uv.fs_fstat(fd)
|
|
local data = uv.fs_read(fd, stat.size, 0)
|
|
uv.fs_close(fd)
|
|
|
|
local lines = {} -- rows we need to retrieve
|
|
local need = 0 -- keep track of how many unique rows we need
|
|
for _, row in pairs(rows) do
|
|
if not lines[row] then
|
|
need = need + 1
|
|
end
|
|
lines[row] = true
|
|
end
|
|
|
|
local found = 0
|
|
local lnum = 0
|
|
|
|
for line in string.gmatch(data, '([^\n]*)\n?') do
|
|
if lines[lnum] == true then
|
|
lines[lnum] = line
|
|
found = found + 1
|
|
if found == need then
|
|
break
|
|
end
|
|
end
|
|
lnum = lnum + 1
|
|
end
|
|
|
|
-- change any lines we didn't find to the empty string
|
|
for i, line in pairs(lines) do
|
|
if line == true then
|
|
lines[i] = ''
|
|
end
|
|
end
|
|
return lines
|
|
end
|
|
|
|
--- Gets the zero-indexed line from the given buffer.
|
|
--- Works on unloaded buffers by reading the file using libuv to bypass buf reading events.
|
|
--- Falls back to loading the buffer and nvim_buf_get_lines for buffers with non-file URI.
|
|
---
|
|
---@param bufnr integer
|
|
---@param row integer zero-indexed line number
|
|
---@return string the line at row in filename
|
|
local function get_line(bufnr, row)
|
|
return get_lines(bufnr, { row })[row]
|
|
end
|
|
|
|
--- Position is a https://microsoft.github.io/language-server-protocol/specifications/specification-current/#position
|
|
---@param offset_encoding string|nil utf-8|utf-16|utf-32
|
|
---@return integer
|
|
local function get_line_byte_from_position(bufnr, position, offset_encoding)
|
|
-- LSP's line and characters are 0-indexed
|
|
-- Vim's line and columns are 1-indexed
|
|
local col = position.character
|
|
-- When on the first character, we can ignore the difference between byte and
|
|
-- character
|
|
if col > 0 then
|
|
local line = get_line(bufnr, position.line) or ''
|
|
local ok, result
|
|
ok, result = pcall(_str_byteindex_enc, line, col, offset_encoding)
|
|
if ok then
|
|
return result
|
|
end
|
|
return math.min(#line, col)
|
|
end
|
|
return col
|
|
end
|
|
|
|
--- Process and return progress reports from lsp server
|
|
---@private
|
|
---@deprecated Use vim.lsp.status() or access client.progress directly
|
|
function M.get_progress_messages()
|
|
vim.deprecate('vim.lsp.util.get_progress_messages()', 'vim.lsp.status()', '0.11')
|
|
local new_messages = {}
|
|
local progress_remove = {}
|
|
|
|
for _, client in ipairs(vim.lsp.get_clients()) do
|
|
local groups = {}
|
|
for progress in client.progress do
|
|
local value = progress.value
|
|
if type(value) == 'table' and value.kind then
|
|
local group = groups[progress.token]
|
|
if not group then
|
|
group = {
|
|
done = false,
|
|
progress = true,
|
|
title = 'empty title',
|
|
}
|
|
groups[progress.token] = group
|
|
end
|
|
group.title = value.title or group.title
|
|
group.cancellable = value.cancellable or group.cancellable
|
|
if value.kind == 'end' then
|
|
group.done = true
|
|
end
|
|
group.message = value.message or group.message
|
|
group.percentage = value.percentage or group.percentage
|
|
end
|
|
end
|
|
|
|
for _, group in pairs(groups) do
|
|
table.insert(new_messages, group)
|
|
end
|
|
|
|
local messages = client.messages
|
|
local data = messages
|
|
for token, ctx in pairs(data.progress) do
|
|
local new_report = {
|
|
name = data.name,
|
|
title = ctx.title or 'empty title',
|
|
message = ctx.message,
|
|
percentage = ctx.percentage,
|
|
done = ctx.done,
|
|
progress = true,
|
|
}
|
|
table.insert(new_messages, new_report)
|
|
|
|
if ctx.done then
|
|
table.insert(progress_remove, { client = client, token = token })
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, item in ipairs(progress_remove) do
|
|
item.client.messages.progress[item.token] = nil
|
|
end
|
|
|
|
return new_messages
|
|
end
|
|
|
|
--- Applies a list of text edits to a buffer.
|
|
---@param text_edits table list of `TextEdit` objects
|
|
---@param bufnr integer Buffer id
|
|
---@param offset_encoding string utf-8|utf-16|utf-32
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit
|
|
function M.apply_text_edits(text_edits, bufnr, offset_encoding)
|
|
validate({
|
|
text_edits = { text_edits, 't', false },
|
|
bufnr = { bufnr, 'number', false },
|
|
offset_encoding = { offset_encoding, 'string', false },
|
|
})
|
|
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
|
|
vim.bo[bufnr].buflisted = true
|
|
|
|
-- Fix reversed range and indexing each text_edits
|
|
local index = 0
|
|
text_edits = vim.tbl_map(function(text_edit)
|
|
index = index + 1
|
|
text_edit._index = index
|
|
|
|
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
|
|
return text_edit
|
|
end, text_edits)
|
|
|
|
-- Sort text_edits
|
|
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
|
|
if a._index ~= b._index then
|
|
return a._index > b._index
|
|
end
|
|
end)
|
|
|
|
-- save and restore local marks since they get deleted by nvim_buf_set_lines
|
|
local marks = {}
|
|
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
|
|
|
|
-- Apply text edits.
|
|
local has_eol_text_edit = false
|
|
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 e = {
|
|
start_row = text_edit.range.start.line,
|
|
start_col = get_line_byte_from_position(bufnr, text_edit.range.start, offset_encoding),
|
|
end_row = text_edit.range['end'].line,
|
|
end_col = get_line_byte_from_position(bufnr, text_edit.range['end'], offset_encoding),
|
|
text = 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 <= e.start_row then
|
|
api.nvim_buf_set_lines(bufnr, max, max, false, e.text)
|
|
else
|
|
local last_line_len = #(get_line(bufnr, math.min(e.end_row, max - 1)) or '')
|
|
-- 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 <= e.end_row then
|
|
e.end_row = max - 1
|
|
e.end_col = last_line_len
|
|
has_eol_text_edit = true
|
|
else
|
|
-- If the replacement is over the end of a line (i.e. e.end_col is out of bounds 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
|
|
e.end_col > last_line_len
|
|
and #text_edit.newText > 0
|
|
and string.sub(text_edit.newText, -1) == '\n'
|
|
then
|
|
table.remove(e.text, #e.text)
|
|
end
|
|
end
|
|
-- Make sure we don't go out of bounds for e.end_col
|
|
e.end_col = math.min(last_line_len, e.end_col)
|
|
|
|
api.nvim_buf_set_text(bufnr, e.start_row, e.start_col, e.end_row, e.end_col, e.text)
|
|
end
|
|
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) or ''))
|
|
vim.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
|
|
|
|
-- local valid_windows_path_characters = "[^<>:\"/\\|?*]"
|
|
-- local valid_unix_path_characters = "[^/]"
|
|
-- https://github.com/davidm/lua-glob-pattern
|
|
-- https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names
|
|
-- function M.glob_to_regex(glob)
|
|
-- end
|
|
|
|
--- Can be used to extract the completion items from a
|
|
--- `textDocument/completion` request, which may return one of
|
|
--- `CompletionItem[]`, `CompletionList` or null.
|
|
---
|
|
--- Note that this method doesn't apply `itemDefaults` to `CompletionList`s, and hence the returned
|
|
--- results might be incorrect.
|
|
---
|
|
---@deprecated
|
|
---@param result table The result of a `textDocument/completion` request
|
|
---@return lsp.CompletionItem[] List of completion items
|
|
---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_completion
|
|
function M.extract_completion_items(result)
|
|
vim.deprecate('vim.lsp.util.extract_completion_items()', nil, '0.11')
|
|
if type(result) == 'table' and result.items then
|
|
-- result is a `CompletionList`
|
|
return result.items
|
|
elseif result ~= nil then
|
|
-- result is `CompletionItem[]`
|
|
return result
|
|
else
|
|
-- result is `null`
|
|
return {}
|
|
end
|
|
end
|
|
|
|
--- Applies a `TextDocumentEdit`, which is a list of changes to a single
|
|
--- document.
|
|
---
|
|
---@param text_document_edit table: a `TextDocumentEdit` object
|
|
---@param index integer: Optional index of the edit, if from a list of edits (or nil, if not from a list)
|
|
---@param offset_encoding? string
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit
|
|
function M.apply_text_document_edit(text_document_edit, index, offset_encoding)
|
|
local text_document = text_document_edit.textDocument
|
|
local bufnr = vim.uri_to_bufnr(text_document.uri)
|
|
if offset_encoding == nil then
|
|
vim.notify_once(
|
|
'apply_text_document_edit must be called with valid offset encoding',
|
|
vim.log.levels.WARN
|
|
)
|
|
end
|
|
|
|
-- For lists of text document edits,
|
|
-- do not check the version after the first edit.
|
|
local should_check_version = true
|
|
if index and index > 1 then
|
|
should_check_version = false
|
|
end
|
|
|
|
-- `VersionedTextDocumentIdentifier`s version may be null
|
|
-- https://microsoft.github.io/language-server-protocol/specification#versionedTextDocumentIdentifier
|
|
if
|
|
should_check_version
|
|
and (
|
|
text_document.version
|
|
and text_document.version > 0
|
|
and M.buf_versions[bufnr]
|
|
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, offset_encoding)
|
|
end
|
|
|
|
--- Parses snippets in a completion entry.
|
|
---
|
|
---@deprecated
|
|
---@param input string unparsed snippet
|
|
---@return string parsed snippet
|
|
function M.parse_snippet(input)
|
|
vim.deprecate('vim.lsp.util.parse_snippet()', nil, '0.11')
|
|
local ok, parsed = pcall(function()
|
|
return snippet.parse(input)
|
|
end)
|
|
if not ok then
|
|
return input
|
|
end
|
|
|
|
return tostring(parsed)
|
|
end
|
|
|
|
--- Turns the result of a `textDocument/completion` request into vim-compatible
|
|
--- |complete-items|.
|
|
---
|
|
---@deprecated
|
|
---@param result table The result of a `textDocument/completion` call, e.g.
|
|
--- from |vim.lsp.buf.completion()|, which may be one of `CompletionItem[]`,
|
|
--- `CompletionList` or `null`
|
|
---@param prefix (string) the prefix to filter the completion items
|
|
---@return table[] items
|
|
---@see complete-items
|
|
function M.text_document_completion_list_to_complete_items(result, prefix)
|
|
vim.deprecate('vim.lsp.util.text_document_completion_list_to_complete_items()', nil, '0.11')
|
|
return vim.lsp._completion._lsp_to_complete_items(result, prefix)
|
|
end
|
|
|
|
local function path_components(path)
|
|
return vim.split(path, '/', { plain = true })
|
|
end
|
|
|
|
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 buffers whose filename matches the given path prefix (normalized full path)
|
|
---@return integer[]
|
|
local function get_bufs_with_prefix(prefix)
|
|
prefix = path_components(prefix)
|
|
local buffers = {}
|
|
for _, v in ipairs(vim.api.nvim_list_bufs()) do
|
|
local bname = vim.api.nvim_buf_get_name(v)
|
|
local path = path_components(vim.fs.normalize(bname, { expand_env = false }))
|
|
if path_under_prefix(path, prefix) then
|
|
table.insert(buffers, v)
|
|
end
|
|
end
|
|
return buffers
|
|
end
|
|
|
|
--- Rename old_fname to new_fname
|
|
---
|
|
---@param old_fname string
|
|
---@param new_fname string
|
|
---@param opts? table options
|
|
--- - overwrite? boolean
|
|
--- - ignoreIfExists? boolean
|
|
function M.rename(old_fname, new_fname, opts)
|
|
opts = opts or {}
|
|
local skip = not opts.overwrite or opts.ignoreIfExists
|
|
|
|
local old_fname_full = vim.uv.fs_realpath(vim.fs.normalize(old_fname, { expand_env = false }))
|
|
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 oldbufs = {}
|
|
local win = nil
|
|
|
|
if vim.fn.isdirectory(old_fname_full) == 1 then
|
|
oldbufs = get_bufs_with_prefix(old_fname_full)
|
|
else
|
|
local oldbuf = vim.fn.bufadd(old_fname_full)
|
|
table.insert(oldbufs, oldbuf)
|
|
win = vim.fn.win_findbuf(oldbuf)[1]
|
|
end
|
|
|
|
for _, b in ipairs(oldbufs) do
|
|
-- There may be pending changes in the buffer
|
|
if api.nvim_buf_is_loaded(b) then
|
|
api.nvim_buf_call(b, function()
|
|
vim.cmd('update!')
|
|
end)
|
|
end
|
|
end
|
|
|
|
local newdir = assert(vim.fs.dirname(new_fname))
|
|
vim.fn.mkdir(newdir, 'p')
|
|
|
|
local ok, err = os.rename(old_fname_full, new_fname)
|
|
assert(ok, err)
|
|
|
|
if vim.fn.isdirectory(new_fname) == 0 then
|
|
local newbuf = vim.fn.bufadd(new_fname)
|
|
if win then
|
|
vim.fn.bufload(newbuf)
|
|
vim.bo[newbuf].buflisted = true
|
|
api.nvim_win_set_buf(win, newbuf)
|
|
end
|
|
end
|
|
|
|
for _, b in ipairs(oldbufs) do
|
|
api.nvim_buf_delete(b, {})
|
|
end
|
|
end
|
|
|
|
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
|
|
|
|
local function delete_file(change)
|
|
local opts = change.options or {}
|
|
local fname = vim.uri_to_fname(change.uri)
|
|
local stat = uv.fs_stat(fname)
|
|
if opts.ignoreIfNotExists and not stat then
|
|
return
|
|
end
|
|
assert(stat, 'Cannot delete not existing file or folder ' .. fname)
|
|
local flags
|
|
if stat and stat.type == 'directory' then
|
|
flags = opts.recursive and 'rf' or 'd'
|
|
else
|
|
flags = ''
|
|
end
|
|
local bufnr = vim.fn.bufadd(fname)
|
|
local result = tonumber(vim.fn.delete(fname, flags))
|
|
assert(result == 0, 'Could not delete file: ' .. fname .. ', stat: ' .. vim.inspect(stat))
|
|
api.nvim_buf_delete(bufnr, { force = true })
|
|
end
|
|
|
|
--- Applies a `WorkspaceEdit`.
|
|
---
|
|
---@param workspace_edit table `WorkspaceEdit`
|
|
---@param offset_encoding string 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, offset_encoding)
|
|
if offset_encoding == nil then
|
|
vim.notify_once(
|
|
'apply_workspace_edit must be called with valid offset encoding',
|
|
vim.log.levels.WARN
|
|
)
|
|
end
|
|
if workspace_edit.documentChanges then
|
|
for idx, change in ipairs(workspace_edit.documentChanges) do
|
|
if change.kind == 'rename' then
|
|
M.rename(vim.uri_to_fname(change.oldUri), vim.uri_to_fname(change.newUri), change.options)
|
|
elseif change.kind == 'create' then
|
|
create_file(change)
|
|
elseif change.kind == 'delete' then
|
|
delete_file(change)
|
|
elseif change.kind then
|
|
error(string.format('Unsupported change: %q', vim.inspect(change)))
|
|
else
|
|
M.apply_text_document_edit(change, idx, offset_encoding)
|
|
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, offset_encoding)
|
|
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 (table|nil) 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))
|
|
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))
|
|
-- 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
|
|
|
|
--- Converts `textDocument/signatureHelp` response to markdown lines.
|
|
---
|
|
---@param signature_help table Response of `textDocument/SignatureHelp`
|
|
---@param ft string|nil filetype that will be use as the `lang` for the label markdown code block
|
|
---@param triggers table|nil list of trigger characters from the lsp server. used to better determine parameter offsets
|
|
---@return table|nil table list of lines of converted markdown.
|
|
---@return table|nil table of active hl
|
|
---@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)
|
|
if not signature_help.signatures then
|
|
return
|
|
end
|
|
--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 = {}
|
|
local active_hl
|
|
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
|
|
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, split(label, '\n', { plain = true, trimempty = true }))
|
|
if signature.documentation 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(signature.documentation) == 'string' then
|
|
signature.documentation = { kind = 'plaintext', value = signature.documentation }
|
|
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 or 0)
|
|
if active_parameter < 0 then
|
|
active_parameter = 0
|
|
end
|
|
|
|
-- If the activeParameter is > #parameters, then set it to the last
|
|
-- NOTE: this is not fully according to the spec, but a client-side interpretation
|
|
if active_parameter >= #signature.parameters then
|
|
active_parameter = #signature.parameters - 1
|
|
end
|
|
|
|
local parameter = signature.parameters[active_parameter + 1]
|
|
if parameter then
|
|
--[=[
|
|
--Represents a parameter of a callable-signature. A parameter can
|
|
--have a label and a doc-comment.
|
|
interface ParameterInformation {
|
|
--The label of this parameter information.
|
|
--
|
|
--Either a string or an inclusive start and exclusive end offsets within its containing
|
|
--signature label. (see SignatureInformation.label). The offsets are based on a UTF-16
|
|
--string representation as `Position` and `Range` does.
|
|
--
|
|
--*Note*: a label of type string should be a substring of its containing signature label.
|
|
--Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`.
|
|
label: string | [number, number];
|
|
--The human-readable doc-comment of this parameter. Will be shown
|
|
--in the UI but can be omitted.
|
|
documentation?: string | MarkupContent;
|
|
}
|
|
--]=]
|
|
if parameter.label then
|
|
if type(parameter.label) == 'table' then
|
|
active_hl = parameter.label
|
|
else
|
|
local offset = 1
|
|
-- 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
|
|
offset = signature.label:find(param.label, offset, true)
|
|
if not offset then
|
|
break
|
|
end
|
|
if p == active_parameter + 1 then
|
|
active_hl = { offset - 1, offset + #parameter.label - 1 }
|
|
break
|
|
end
|
|
offset = offset + #param.label + 1
|
|
end
|
|
end
|
|
end
|
|
if parameter.documentation then
|
|
M.convert_input_to_markdown_lines(parameter.documentation, contents)
|
|
end
|
|
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 table optional
|
|
--- - offset_x (integer) offset to add to `col`
|
|
--- - offset_y (integer) offset to add to `row`
|
|
--- - border (string or table) override `border`
|
|
--- - focusable (string or table) override `focusable`
|
|
--- - zindex (string or table) override `zindex`, defaults to 50
|
|
--- - relative ("mouse"|"cursor") defaults to "cursor"
|
|
--- - anchor_bias ("auto"|"above"|"below") defaults to "auto"
|
|
--- - "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.
|
|
---@return table Options
|
|
function M.make_floating_popup_options(width, height, opts)
|
|
validate({
|
|
opts = { opts, 't', true },
|
|
})
|
|
opts = opts or {}
|
|
validate({
|
|
['opts.offset_x'] = { opts.offset_x, 'n', true },
|
|
['opts.offset_y'] = { opts.offset_y, 'n', true },
|
|
})
|
|
|
|
local anchor = ''
|
|
local row, col
|
|
|
|
local lines_above = opts.relative == 'mouse' and vim.fn.getmousepos().line - 1
|
|
or vim.fn.winline() - 1
|
|
local lines_below = vim.fn.winheight(0) - lines_above
|
|
|
|
local anchor_bias = opts.anchor_bias or 'auto'
|
|
|
|
local anchor_below
|
|
|
|
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).height
|
|
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 = opts.relative == 'mouse' and vim.fn.getmousepos().column or vim.fn.wincol()
|
|
|
|
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 and opts.title) and opts.title or nil
|
|
local title_pos
|
|
|
|
if title then
|
|
title_pos = opts.title_pos or 'center'
|
|
end
|
|
|
|
return {
|
|
anchor = anchor,
|
|
col = col + (opts.offset_x or 0),
|
|
height = height,
|
|
focusable = opts.focusable,
|
|
relative = opts.relative == 'mouse' and 'mouse' or 'cursor',
|
|
row = row + (opts.offset_y or 0),
|
|
style = 'minimal',
|
|
width = width,
|
|
border = opts.border or default_border,
|
|
zindex = opts.zindex or 50,
|
|
title = title,
|
|
title_pos = title_pos,
|
|
}
|
|
end
|
|
|
|
--- Shows document and optionally jumps to the location.
|
|
---
|
|
---@param location table (`Location`|`LocationLink`)
|
|
---@param offset_encoding string|nil utf-8|utf-16|utf-32
|
|
---@param opts table|nil options
|
|
--- - reuse_win (boolean) Jump to existing window if buffer is already open.
|
|
--- - focus (boolean) Whether to focus/jump to location if possible. Defaults to true.
|
|
---@return boolean `true` if succeeded
|
|
function M.show_document(location, offset_encoding, opts)
|
|
-- location may be Location or LocationLink
|
|
local uri = location.uri or location.targetUri
|
|
if uri == nil then
|
|
return false
|
|
end
|
|
if offset_encoding == nil then
|
|
vim.notify_once('show_document must be called with valid offset encoding', vim.log.levels.WARN)
|
|
end
|
|
local bufnr = vim.uri_to_bufnr(uri)
|
|
|
|
opts = opts or {}
|
|
local focus = vim.F.if_nil(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 row = range.start.line
|
|
local col = get_line_byte_from_position(bufnr, range.start, offset_encoding)
|
|
api.nvim_win_set_cursor(win, { row + 1, col })
|
|
api.nvim_win_call(win, function()
|
|
-- Open folds under the cursor
|
|
vim.cmd('normal! zv')
|
|
end)
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
--- Jumps to a location.
|
|
---
|
|
---@param location table (`Location`|`LocationLink`)
|
|
---@param offset_encoding string|nil utf-8|utf-16|utf-32
|
|
---@param reuse_win boolean|nil Jump to existing window if buffer is already open.
|
|
---@return boolean `true` if the jump succeeded
|
|
function M.jump_to_location(location, offset_encoding, reuse_win)
|
|
if offset_encoding == nil then
|
|
vim.notify_once(
|
|
'jump_to_location must be called with valid offset encoding',
|
|
vim.log.levels.WARN
|
|
)
|
|
end
|
|
|
|
return M.show_document(location, offset_encoding, { reuse_win = reuse_win, focus = 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 table a single `Location` or `LocationLink`
|
|
---@param opts table
|
|
---@return integer|nil buffer id of float window
|
|
---@return integer|nil 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 npcall(api.nvim_win_get_var, win, name) == value then
|
|
return win
|
|
end
|
|
end
|
|
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.
|
|
---@param line string
|
|
---@return boolean
|
|
local function is_separator_line(line)
|
|
return line and line:match('^ ? ? ?%-%-%-+%s*$')
|
|
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 = {}
|
|
for _, fence in pairs(vim.g.markdown_fenced_languages or {}) do
|
|
local lang, syntax = fence:match('^(.*)=(.*)$')
|
|
if lang then
|
|
fences[lang] = syntax
|
|
end
|
|
end
|
|
return fences
|
|
end
|
|
|
|
--- 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 table of lines to show in window
|
|
---@param opts table with optional fields
|
|
--- - height of floating window
|
|
--- - width of floating window
|
|
--- - wrap_at character to wrap at for computing height
|
|
--- - max_width maximal width of floating window
|
|
--- - max_height maximal height of floating window
|
|
--- - separator insert separator after code block
|
|
---@return table stripped content
|
|
function M.stylize_markdown(bufnr, contents, opts)
|
|
validate({
|
|
contents = { contents, 't' },
|
|
opts = { opts, 't', 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>' },
|
|
}
|
|
|
|
local match_begin = function(line)
|
|
for type, pattern in pairs(matchers) do
|
|
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
|
|
|
|
local match_end = function(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 = {}
|
|
local highlights = {}
|
|
-- keep track of lnums that contain markdown
|
|
local markdown_lines = {}
|
|
do
|
|
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
|
|
table.insert(stripped, 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
|
|
table.insert(stripped, '---')
|
|
markdown_lines[#stripped] = true
|
|
end
|
|
else
|
|
-- strip any empty lines or separators prior to this separator in actual markdown
|
|
if line:match('^---+$') then
|
|
while
|
|
markdown_lines[#stripped]
|
|
and (stripped[#stripped]:match('^%s*$') or stripped[#stripped]:match('^---+$'))
|
|
do
|
|
markdown_lines[#stripped] = false
|
|
table.remove(stripped, #stripped)
|
|
end
|
|
end
|
|
-- add the line if its not an empty line following a separator
|
|
if
|
|
not (
|
|
line:match('^%s*$')
|
|
and markdown_lines[#stripped]
|
|
and stripped[#stripped]:match('^---+$')
|
|
)
|
|
then
|
|
table.insert(stripped, line)
|
|
markdown_lines[#stripped] = true
|
|
end
|
|
i = i + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Handle some common html escape sequences
|
|
stripped = vim.tbl_map(function(line)
|
|
local escapes = {
|
|
['>'] = '>',
|
|
['<'] = '<',
|
|
['"'] = '"',
|
|
['''] = "'",
|
|
[' '] = ' ',
|
|
[' '] = ' ',
|
|
['&'] = '&',
|
|
}
|
|
return (string.gsub(line, '&[^ ;]+;', 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 pairs(markdown_lines) 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 = {}
|
|
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
|
|
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
|
|
api.nvim_buf_call(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 lsp.util.NormalizeMarkdownOptions
|
|
--- @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.
|
|
---
|
|
--- 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
|
|
---
|
|
---@private
|
|
---@param contents string[]
|
|
---@param opts? lsp.util.NormalizeMarkdownOptions
|
|
---@return string[] table of lines containing normalized Markdown
|
|
---@see https://github.github.com/gfm
|
|
function M._normalize_markdown(contents, opts)
|
|
validate({
|
|
contents = { contents, 't' },
|
|
opts = { opts, 't', 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|nil 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 = 'preview_window_' .. winnr
|
|
pcall(api.nvim_del_augroup_by_name, augroup)
|
|
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 bufnrs table list of buffers where the preview window will remain visible
|
|
---@see autocmd-events
|
|
local function close_preview_autocmd(events, winnr, bufnrs)
|
|
local augroup = api.nvim_create_augroup('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
|
|
api.nvim_create_autocmd('BufEnter', {
|
|
group = augroup,
|
|
callback = function()
|
|
close_preview_window(winnr, bufnrs)
|
|
end,
|
|
})
|
|
|
|
if #events > 0 then
|
|
api.nvim_create_autocmd(events, {
|
|
group = augroup,
|
|
buffer = bufnrs[2],
|
|
callback = function()
|
|
close_preview_window(winnr)
|
|
end,
|
|
})
|
|
end
|
|
end
|
|
|
|
---@internal
|
|
--- Computes size of float needed to show contents (with optional wrapping)
|
|
---
|
|
---@param contents table of lines to show in window
|
|
---@param opts table with optional fields
|
|
--- - height of floating window
|
|
--- - width of floating window
|
|
--- - wrap_at character to wrap at for computing height
|
|
--- - max_width maximal width of floating window
|
|
--- - max_height maximal height of floating window
|
|
---@return integer width size of float
|
|
---@return integer height size of float
|
|
function M._make_floating_popup_size(contents, opts)
|
|
validate({
|
|
contents = { contents, 't' },
|
|
opts = { opts, 't', 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 = {}
|
|
|
|
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).width
|
|
local screen_width = api.nvim_win_get_width(0)
|
|
width = math.min(width, screen_width)
|
|
|
|
-- make sure borders are always inside the screen
|
|
if width + border_width > screen_width then
|
|
width = width - (width + border_width - screen_width)
|
|
end
|
|
|
|
if wrap_at and wrap_at > width then
|
|
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.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
|
|
|
|
--- 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 table with optional fields (additional keys are filtered with |vim.lsp.util.make_floating_popup_options()|
|
|
--- before they are passed on to |nvim_open_win()|)
|
|
--- - height: (integer) height of floating window
|
|
--- - width: (integer) width of floating window
|
|
--- - wrap: (boolean, default true) wrap long lines
|
|
--- - wrap_at: (integer) character to wrap at for computing height when wrap is enabled
|
|
--- - max_width: (integer) maximal width of floating window
|
|
--- - max_height: (integer) maximal height of floating window
|
|
--- - focus_id: (string) if a popup with this id is opened, then focus it
|
|
--- - close_events: (table) list of events that closes the floating window
|
|
--- - focusable: (boolean, default true) Make float focusable
|
|
--- - focus: (boolean, default true) If `true`, and if {focusable}
|
|
--- is also `true`, focus an existing floating window with the same
|
|
--- {focus_id}
|
|
---@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, 't' },
|
|
syntax = { syntax, 's', true },
|
|
opts = { opts, 't', 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()
|
|
|
|
-- 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 npcall(api.nvim_win_get_var, current_winnr, opts.focus_id) 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 api.nvim_win_is_valid(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 = npcall(api.nvim_buf_get_var, bufnr, 'lsp_floating_preview')
|
|
if existing_float and api.nvim_win_is_valid(existing_float) then
|
|
api.nvim_win_close(existing_float, true)
|
|
end
|
|
|
|
-- Create the buffer
|
|
local floating_bufnr = api.nvim_create_buf(false, true)
|
|
|
|
-- 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 })
|
|
vim.bo[floating_bufnr].filetype = 'markdown'
|
|
vim.treesitter.start(floating_bufnr)
|
|
api.nvim_buf_set_lines(floating_bufnr, 0, -1, false, contents)
|
|
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
|
|
api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents)
|
|
end
|
|
|
|
-- 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
|
|
local width, height = M._make_floating_popup_size(contents, opts)
|
|
|
|
local float_option = M.make_floating_popup_options(width, height, opts)
|
|
local floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option)
|
|
|
|
if do_stylize then
|
|
vim.wo[floating_winnr].conceallevel = 2
|
|
end
|
|
-- disable folding
|
|
vim.wo[floating_winnr].foldenable = false
|
|
-- soft wrapping
|
|
vim.wo[floating_winnr].wrap = opts.wrap
|
|
|
|
vim.bo[floating_bufnr].modifiable = false
|
|
vim.bo[floating_bufnr].bufhidden = 'wipe'
|
|
|
|
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)
|
|
|
|
return floating_bufnr, floating_winnr
|
|
end
|
|
|
|
do --[[ References ]]
|
|
local reference_ns = api.nvim_create_namespace('vim_lsp_references')
|
|
|
|
--- Removes document highlights from a buffer.
|
|
---
|
|
---@param bufnr integer|nil Buffer id
|
|
function M.buf_clear_references(bufnr)
|
|
validate({ bufnr = { bufnr, { 'n' }, true } })
|
|
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 table List of `DocumentHighlight` objects to highlight
|
|
---@param offset_encoding string One of "utf-8", "utf-16", "utf-32".
|
|
---@see https://microsoft.github.io/language-server-protocol/specification/#textDocumentContentChangeEvent
|
|
function M.buf_highlight_references(bufnr, references, offset_encoding)
|
|
validate({
|
|
bufnr = { bufnr, 'n', true },
|
|
offset_encoding = { offset_encoding, 'string', false },
|
|
})
|
|
for _, reference in ipairs(references) do
|
|
local start_line, start_char =
|
|
reference['range']['start']['line'], reference['range']['start']['character']
|
|
local end_line, end_char =
|
|
reference['range']['end']['line'], reference['range']['end']['character']
|
|
|
|
local start_idx = get_line_byte_from_position(
|
|
bufnr,
|
|
{ line = start_line, character = start_char },
|
|
offset_encoding
|
|
)
|
|
local end_idx = get_line_byte_from_position(
|
|
bufnr,
|
|
{ line = start_line, character = end_char },
|
|
offset_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
|
|
highlight.range(
|
|
bufnr,
|
|
reference_ns,
|
|
document_highlight_kind[kind],
|
|
{ start_line, start_idx },
|
|
{ end_line, end_idx },
|
|
{ priority = vim.highlight.priorities.user }
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
local position_sort = sort_by_key(function(v)
|
|
return { v.start.line, v.start.character }
|
|
end)
|
|
|
|
---@class vim.lsp.util.LocationItem
|
|
---@field filename string
|
|
---@field lnum integer 1-indexed line number
|
|
---@field col integer 1-indexed column
|
|
---@field text string
|
|
---@field user_data lsp.Location|lsp.LocationLink
|
|
|
|
--- 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 offset_encoding string offset_encoding for locations utf-8|utf-16|utf-32
|
|
--- default to first client of buffer
|
|
---@return vim.lsp.util.LocationItem[] list of items
|
|
function M.locations_to_items(locations, offset_encoding)
|
|
if offset_encoding == nil then
|
|
vim.notify_once(
|
|
'locations_to_items must be called with valid offset encoding',
|
|
vim.log.levels.WARN
|
|
)
|
|
offset_encoding = vim.lsp.get_clients({ bufnr = 0 })[1].offset_encoding
|
|
end
|
|
|
|
local items = {}
|
|
---@type table<string, {start: lsp.Position, location: lsp.Location|lsp.LocationLink}[]>
|
|
local grouped = setmetatable({}, {
|
|
__index = function(t, k)
|
|
local v = {}
|
|
rawset(t, k, v)
|
|
return v
|
|
end,
|
|
})
|
|
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
|
|
table.insert(grouped[uri], { start = range.start, location = d })
|
|
end
|
|
|
|
---@type string[]
|
|
local keys = vim.tbl_keys(grouped)
|
|
table.sort(keys)
|
|
-- TODO(ashkan) I wish we could do this lazily.
|
|
for _, uri in ipairs(keys) do
|
|
local rows = grouped[uri]
|
|
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)
|
|
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 row = pos.line
|
|
local line = lines[row] or ''
|
|
local col = M._str_byteindex_enc(line, pos.character, offset_encoding)
|
|
table.insert(items, {
|
|
filename = filename,
|
|
lnum = row + 1,
|
|
col = col + 1,
|
|
text = line,
|
|
user_data = temp.location,
|
|
})
|
|
end
|
|
end
|
|
return items
|
|
end
|
|
|
|
-- According to LSP spec, if the client set "symbolKind.valueSet",
|
|
-- the client must handle it properly even if it receives a value outside the specification.
|
|
-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentSymbol
|
|
function M._get_symbol_kind_name(symbol_kind)
|
|
return protocol.SymbolKind[symbol_kind] or 'Unknown'
|
|
end
|
|
|
|
--- Converts symbols to quickfix list items.
|
|
---
|
|
---@param symbols table DocumentSymbol[] or SymbolInformation[]
|
|
---@param bufnr integer
|
|
function M.symbols_to_items(symbols, bufnr)
|
|
local function _symbols_to_items(_symbols, _items, _bufnr)
|
|
for _, symbol in ipairs(_symbols) do
|
|
if symbol.location then -- SymbolInformation type
|
|
local range = symbol.location.range
|
|
local kind = M._get_symbol_kind_name(symbol.kind)
|
|
table.insert(_items, {
|
|
filename = vim.uri_to_fname(symbol.location.uri),
|
|
lnum = range.start.line + 1,
|
|
col = range.start.character + 1,
|
|
kind = kind,
|
|
text = '[' .. kind .. '] ' .. symbol.name,
|
|
})
|
|
elseif symbol.selectionRange then -- DocumentSymbole type
|
|
local kind = M._get_symbol_kind_name(symbol.kind)
|
|
table.insert(_items, {
|
|
-- bufnr = _bufnr,
|
|
filename = api.nvim_buf_get_name(_bufnr),
|
|
lnum = symbol.selectionRange.start.line + 1,
|
|
col = symbol.selectionRange.start.character + 1,
|
|
kind = kind,
|
|
text = '[' .. kind .. '] ' .. symbol.name,
|
|
})
|
|
if symbol.children then
|
|
for _, v in ipairs(_symbols_to_items(symbol.children, _items, _bufnr)) do
|
|
for _, s in ipairs(v) do
|
|
table.insert(_items, s)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return _items
|
|
end
|
|
return _symbols_to_items(symbols, {}, bufnr or 0)
|
|
end
|
|
|
|
--- Removes empty lines from the beginning and end.
|
|
---@deprecated use `vim.split()` with `trimempty` instead
|
|
---@param lines table list of lines to trim
|
|
---@return table trimmed list of lines
|
|
function M.trim_empty_lines(lines)
|
|
vim.deprecate('vim.lsp.util.trim_empty_lines()', 'vim.split() with `trimempty`', '0.12')
|
|
local start = 1
|
|
for i = 1, #lines do
|
|
if lines[i] ~= nil and #lines[i] > 0 then
|
|
start = i
|
|
break
|
|
end
|
|
end
|
|
local finish = 1
|
|
for i = #lines, 1, -1 do
|
|
if lines[i] ~= nil and #lines[i] > 0 then
|
|
finish = i
|
|
break
|
|
end
|
|
end
|
|
return list_extend({}, lines, start, finish)
|
|
end
|
|
|
|
--- Accepts markdown lines and tries to reduce them to a filetype if they
|
|
--- comprise just a single code block.
|
|
---
|
|
--- CAUTION: Modifies the input in-place!
|
|
---
|
|
---@deprecated
|
|
---@param lines table list of lines
|
|
---@return string filetype or "markdown" if it was unchanged.
|
|
function M.try_trim_markdown_code_blocks(lines)
|
|
vim.deprecate('vim.lsp.util.try_trim_markdown_code_blocks()', 'nil', '0.12')
|
|
local language_id = lines[1]:match('^```(.*)')
|
|
if language_id then
|
|
local has_inner_code_fence = false
|
|
for i = 2, (#lines - 1) do
|
|
local line = lines[i]
|
|
if line:sub(1, 3) == '```' then
|
|
has_inner_code_fence = true
|
|
break
|
|
end
|
|
end
|
|
-- No inner code fences + starting with code fence = hooray.
|
|
if not has_inner_code_fence then
|
|
table.remove(lines, 1)
|
|
table.remove(lines)
|
|
return language_id
|
|
end
|
|
end
|
|
return 'markdown'
|
|
end
|
|
|
|
---@param window integer|nil: window handle or 0 for current, defaults to current
|
|
---@param offset_encoding string utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window`
|
|
local function make_position_param(window, offset_encoding)
|
|
window = window or 0
|
|
local buf = api.nvim_win_get_buf(window)
|
|
local row, col = unpack(api.nvim_win_get_cursor(window))
|
|
offset_encoding = offset_encoding or M._get_offset_encoding(buf)
|
|
row = row - 1
|
|
local line = api.nvim_buf_get_lines(buf, row, row + 1, true)[1]
|
|
if not line then
|
|
return { line = 0, character = 0 }
|
|
end
|
|
|
|
col = _str_utfindex_enc(line, col, offset_encoding)
|
|
|
|
return { line = row, character = col }
|
|
end
|
|
|
|
--- Creates a `TextDocumentPositionParams` object for the current buffer and cursor position.
|
|
---
|
|
---@param window integer|nil: window handle or 0 for current, defaults to current
|
|
---@param offset_encoding string|nil utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window`
|
|
---@return table `TextDocumentPositionParams` object
|
|
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams
|
|
function M.make_position_params(window, offset_encoding)
|
|
window = window or 0
|
|
local buf = api.nvim_win_get_buf(window)
|
|
offset_encoding = offset_encoding or M._get_offset_encoding(buf)
|
|
return {
|
|
textDocument = M.make_text_document_params(buf),
|
|
position = make_position_param(window, offset_encoding),
|
|
}
|
|
end
|
|
|
|
--- Utility function for getting the encoding of the first LSP client on the given buffer.
|
|
---@param bufnr (integer) buffer handle or 0 for current, defaults to current
|
|
---@return string encoding first client if there is one, nil otherwise
|
|
function M._get_offset_encoding(bufnr)
|
|
validate({
|
|
bufnr = { bufnr, 'n', true },
|
|
})
|
|
|
|
local offset_encoding
|
|
|
|
for _, client in pairs(vim.lsp.get_clients({ bufnr = bufnr })) do
|
|
if client.offset_encoding == nil then
|
|
vim.notify_once(
|
|
string.format(
|
|
'Client (id: %s) offset_encoding is nil. Do not unset offset_encoding.',
|
|
client.id
|
|
),
|
|
vim.log.levels.ERROR
|
|
)
|
|
end
|
|
local this_offset_encoding = client.offset_encoding
|
|
if not offset_encoding then
|
|
offset_encoding = this_offset_encoding
|
|
elseif offset_encoding ~= this_offset_encoding then
|
|
vim.notify_once(
|
|
'warning: multiple different client offset_encodings detected for buffer, this is not supported yet',
|
|
vim.log.levels.WARN
|
|
)
|
|
end
|
|
end
|
|
|
|
return offset_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 window integer|nil: window handle or 0 for current, defaults to current
|
|
---@param offset_encoding "utf-8"|"utf-16"|"utf-32"|nil defaults to `offset_encoding` of first client of buffer of `window`
|
|
---@return table { textDocument = { uri = `current_file_uri` }, range = { start =
|
|
---`current_position`, end = `current_position` } }
|
|
function M.make_range_params(window, offset_encoding)
|
|
local buf = api.nvim_win_get_buf(window or 0)
|
|
offset_encoding = offset_encoding or M._get_offset_encoding(buf)
|
|
local position = make_position_param(window, offset_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[]|nil {row,col} mark-indexed position.
|
|
--- Defaults to the start of the last visual selection.
|
|
---@param end_pos integer[]|nil {row,col} mark-indexed position.
|
|
--- Defaults to the end of the last visual selection.
|
|
---@param bufnr integer|nil buffer handle or 0 for current, defaults to current
|
|
---@param offset_encoding "utf-8"|"utf-16"|"utf-32"|nil defaults to `offset_encoding` of first client of `bufnr`
|
|
---@return table { textDocument = { uri = `current_file_uri` }, range = { start =
|
|
---`start_position`, end = `end_position` } }
|
|
function M.make_given_range_params(start_pos, end_pos, bufnr, offset_encoding)
|
|
validate({
|
|
start_pos = { start_pos, 't', true },
|
|
end_pos = { end_pos, 't', true },
|
|
offset_encoding = { offset_encoding, 's', true },
|
|
})
|
|
bufnr = bufnr or api.nvim_get_current_buf()
|
|
offset_encoding = offset_encoding or M._get_offset_encoding(bufnr)
|
|
local A = list_extend({}, start_pos or api.nvim_buf_get_mark(bufnr, '<'))
|
|
local B = list_extend({}, end_pos or api.nvim_buf_get_mark(bufnr, '>'))
|
|
-- convert to 0-index
|
|
A[1] = A[1] - 1
|
|
B[1] = B[1] - 1
|
|
-- account for offset_encoding.
|
|
if A[2] > 0 then
|
|
A = { A[1], M.character_offset(bufnr, A[1], A[2], offset_encoding) }
|
|
end
|
|
if B[2] > 0 then
|
|
B = { B[1], M.character_offset(bufnr, B[1], B[2], offset_encoding) }
|
|
end
|
|
-- we need to offset the end character position otherwise we loose the last
|
|
-- character of the selection, as LSP end position is exclusive
|
|
-- see https://microsoft.github.io/language-server-protocol/specification#range
|
|
if vim.o.selection ~= 'exclusive' then
|
|
B[2] = B[2] + 1
|
|
end
|
|
return {
|
|
textDocument = M.make_text_document_params(bufnr),
|
|
range = {
|
|
start = { line = A[1], character = A[2] },
|
|
['end'] = { line = B[1], character = B[2] },
|
|
},
|
|
}
|
|
end
|
|
|
|
--- Creates a `TextDocumentIdentifier` object for the current buffer.
|
|
---
|
|
---@param bufnr integer|nil: Buffer handle, defaults to current
|
|
---@return table `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 table
|
|
---@param removed table
|
|
function M.make_workspace_params(added, removed)
|
|
return { event = { added = added, removed = removed } }
|
|
end
|
|
|
|
--- Returns indentation size.
|
|
---
|
|
---@see 'shiftwidth'
|
|
---@param bufnr (integer|nil): Buffer handle, defaults to current
|
|
---@return (integer) indentation size
|
|
function M.get_effective_tabstop(bufnr)
|
|
validate({ bufnr = { bufnr, 'n', 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 table|nil 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, 't', 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.
|
|
---
|
|
---@param buf integer buffer number (0 for current)
|
|
---@param row integer 0-indexed line
|
|
---@param col integer 0-indexed byte offset in line
|
|
---@param offset_encoding string utf-8|utf-16|utf-32 defaults to `offset_encoding` of first client of `buf`
|
|
---@return integer `offset_encoding` index of the character in line {row} column {col} in buffer {buf}
|
|
function M.character_offset(buf, row, col, offset_encoding)
|
|
local line = get_line(buf, row)
|
|
if offset_encoding == nil then
|
|
vim.notify_once(
|
|
'character_offset must be called with valid offset encoding',
|
|
vim.log.levels.WARN
|
|
)
|
|
offset_encoding = vim.lsp.get_clients({ bufnr = buf })[1].offset_encoding
|
|
end
|
|
-- If the col is past the EOL, use the line length.
|
|
if col > #line then
|
|
return _str_utfindex_enc(line, nil, offset_encoding)
|
|
end
|
|
return _str_utfindex_enc(line, col, offset_encoding)
|
|
end
|
|
|
|
--- Helper function to return nested values in language server settings
|
|
---
|
|
---@param settings table language server settings
|
|
---@param section string indicating the field of the settings table
|
|
---@return table|string|vim.NIL The value of settings accessed via section. `vim.NIL` if not found.
|
|
---@deprecated
|
|
function M.lookup_section(settings, section)
|
|
vim.deprecate('vim.lsp.util.lookup_section()', 'vim.tbl_get() with `vim.split`', '0.12')
|
|
for part in vim.gsplit(section, '.', { plain = true }) do
|
|
settings = settings[part]
|
|
if settings == nil then
|
|
return vim.NIL
|
|
end
|
|
end
|
|
return settings
|
|
end
|
|
|
|
--- Converts line range (0-based, end-inclusive) to lsp range,
|
|
--- handles absence of a trailing newline
|
|
---
|
|
---@param bufnr integer
|
|
---@param start_line integer
|
|
---@param end_line integer
|
|
---@param offset_encoding lsp.PositionEncodingKind
|
|
---@return lsp.Range
|
|
local function make_line_range_params(bufnr, start_line, end_line, offset_encoding)
|
|
local last_line = api.nvim_buf_line_count(bufnr) - 1
|
|
|
|
---@type lsp.Position
|
|
local end_pos
|
|
|
|
if end_line == last_line and not vim.api.nvim_get_option_value('endofline', { buf = bufnr }) then
|
|
end_pos = {
|
|
line = end_line,
|
|
character = M.character_offset(bufnr, end_line, #get_line(bufnr, end_line), offset_encoding),
|
|
}
|
|
else
|
|
end_pos = { line = end_line + 1, character = 0 }
|
|
end
|
|
|
|
return {
|
|
start = { line = start_line, character = 0 },
|
|
['end'] = end_pos,
|
|
}
|
|
end
|
|
|
|
---@private
|
|
--- Request updated LSP information for a buffer.
|
|
---
|
|
---@class lsp.util.RefreshOptions
|
|
---@field bufnr integer? Buffer to refresh (default: 0)
|
|
---@field only_visible? boolean Whether to only refresh for the visible regions of the buffer (default: false)
|
|
---@field client_id? integer Client ID to refresh (default: all clients)
|
|
--
|
|
---@param method string LSP method to call
|
|
---@param opts? lsp.util.RefreshOptions Options table
|
|
function M._refresh(method, opts)
|
|
opts = opts or {}
|
|
local bufnr = opts.bufnr
|
|
if bufnr == nil or bufnr == 0 then
|
|
bufnr = api.nvim_get_current_buf()
|
|
end
|
|
|
|
local clients = vim.lsp.get_clients({ bufnr = bufnr, method = method, id = opts.client_id })
|
|
|
|
if #clients == 0 then
|
|
return
|
|
end
|
|
|
|
local textDocument = M.make_text_document_params(bufnr)
|
|
|
|
local only_visible = opts.only_visible or false
|
|
|
|
if only_visible then
|
|
for _, window in ipairs(api.nvim_list_wins()) do
|
|
if api.nvim_win_get_buf(window) == bufnr then
|
|
local first = vim.fn.line('w0', window)
|
|
local last = vim.fn.line('w$', window)
|
|
for _, client in ipairs(clients) do
|
|
client.request(method, {
|
|
textDocument = textDocument,
|
|
range = make_line_range_params(bufnr, first - 1, last - 1, client.offset_encoding),
|
|
}, nil, bufnr)
|
|
end
|
|
end
|
|
end
|
|
else
|
|
for _, client in ipairs(clients) do
|
|
client.request(method, {
|
|
textDocument = textDocument,
|
|
range = make_line_range_params(
|
|
bufnr,
|
|
0,
|
|
api.nvim_buf_line_count(bufnr) - 1,
|
|
client.offset_encoding
|
|
),
|
|
}, nil, bufnr)
|
|
end
|
|
end
|
|
end
|
|
|
|
M._get_line_byte_from_position = get_line_byte_from_position
|
|
|
|
---@nodoc
|
|
M.buf_versions = {} ---@type table<integer,integer>
|
|
|
|
return M
|