neovim/runtime/lua/vim/lsp/util.lua
Christian Clason f9579d473e
lsp: add optional vertical padding, maximal size to floats (#12444)
* add vertical padding to floats

* add max_width, max_height option to float methods

* lint

* lintlint

* guard against nil wrap_at
2020-07-05 21:09:52 -04:00

1360 lines
46 KiB
Lua

local protocol = require 'vim.lsp.protocol'
local vim = vim
local validate = vim.validate
local api = vim.api
local list_extend = vim.list_extend
local highlight = require 'vim.highlight'
local M = {}
--- Diagnostics received from the server via `textDocument/publishDiagnostics`
-- by buffer.
--
-- {<bufnr>: {diagnostics}}
--
-- This contains only entries for active buffers. Entries for detached buffers
-- are discarded.
--
-- If you override the `textDocument/publishDiagnostic` callback,
-- this will be empty unless you call `buf_diagnostics_save_positions`.
--
--
-- Diagnostic is:
--
-- {
-- range: Range
-- message: string
-- severity?: DiagnosticSeverity
-- code?: number | string
-- source?: string
-- tags?: DiagnosticTag[]
-- relatedInformation?: DiagnosticRelatedInformation[]
-- }
M.diagnostics_by_buf = {}
local split = vim.split
local function split_lines(value)
return split(value, '\n', true)
end
local function ok_or_nil(status, ...)
if not status then return end
return ...
end
local function npcall(fn, ...)
return ok_or_nil(pcall(fn, ...))
end
function M.set_lines(lines, A, B, new_lines)
-- 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 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
local edit_sort_key = sort_by_key(function(e)
return {e.A[1], e.A[2], -e.i}
end)
--- Position is a https://microsoft.github.io/language-server-protocol/specifications/specification-current/#position
-- Returns a zero-indexed column, since set_lines() does the conversion to
-- 1-indexed
local function get_line_byte_from_position(bufnr, position)
-- 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 = position.line
local lines = api.nvim_buf_get_lines(bufnr, line, line + 1, false)
if #lines > 0 then
return vim.str_byteindex(lines[1], col)
end
end
return col
end
function M.apply_text_edits(text_edits, bufnr)
if not next(text_edits) then return end
if not api.nvim_buf_is_loaded(bufnr) then
vim.fn.bufload(bufnr)
end
api.nvim_buf_set_option(bufnr, 'buflisted', true)
local start_line, finish_line = math.huge, -1
local cleaned = {}
for i, e in ipairs(text_edits) do
-- adjust start and end column for UTF-16 encoding of non-ASCII characters
local start_row = e.range.start.line
local start_col = get_line_byte_from_position(bufnr, e.range.start)
local end_row = e.range["end"].line
local end_col = get_line_byte_from_position(bufnr, e.range['end'])
start_line = math.min(e.range.start.line, start_line)
finish_line = math.max(e.range["end"].line, finish_line)
-- TODO(ashkan) sanity check ranges for overlap.
table.insert(cleaned, {
i = i;
A = {start_row; start_col};
B = {end_row; end_col};
lines = vim.split(e.newText, '\n', true);
})
end
-- Reverse sort the orders so we can apply them without interfering with
-- eachother. Also add i as a sort key to mimic a stable sort.
table.sort(cleaned, edit_sort_key)
local lines = api.nvim_buf_get_lines(bufnr, start_line, finish_line + 1, false)
local fix_eol = api.nvim_buf_get_option(bufnr, 'fixeol')
local set_eol = fix_eol and api.nvim_buf_line_count(bufnr) <= finish_line + 1
if set_eol and #lines[#lines] ~= 0 then
table.insert(lines, '')
end
for i = #cleaned, 1, -1 do
local e = cleaned[i]
local A = {e.A[1] - start_line, e.A[2]}
local B = {e.B[1] - start_line, e.B[2]}
lines = M.set_lines(lines, A, B, e.lines)
end
if set_eol and #lines[#lines] == 0 then
table.remove(lines)
end
api.nvim_buf_set_lines(bufnr, start_line, finish_line + 1, false, lines)
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
-- textDocument/completion response returns one of CompletionItem[], CompletionList or null.
-- https://microsoft.github.io/language-server-protocol/specification#textDocument_completion
function M.extract_completion_items(result)
if type(result) == 'table' and result.items then
return result.items
elseif result ~= nil then
return result
else
return {}
end
end
--- Apply the TextDocumentEdit response.
-- @params TextDocumentEdit [table] see https://microsoft.github.io/language-server-protocol/specification
function M.apply_text_document_edit(text_document_edit)
local text_document = text_document_edit.textDocument
local bufnr = vim.uri_to_bufnr(text_document.uri)
if text_document.version then
-- `VersionedTextDocumentIdentifier`s version may be null https://microsoft.github.io/language-server-protocol/specification#versionedTextDocumentIdentifier
if text_document.version ~= vim.NIL and M.buf_versions[bufnr] ~= nil and M.buf_versions[bufnr] > text_document.version then
print("Buffer ", text_document.uri, " newer than edits.")
return
end
end
M.apply_text_edits(text_document_edit.edits, bufnr)
end
function M.get_current_line_to_cursor()
local pos = api.nvim_win_get_cursor(0)
local line = assert(api.nvim_buf_get_lines(0, pos[1]-1, pos[1], false)[1])
return line:sub(pos[2]+1)
end
local function parse_snippet_rec(input, inner)
local res = ""
local close, closeend = nil, nil
if inner then
close, closeend = input:find("}", 1, true)
while close ~= nil and input:sub(close-1,close-1) == "\\" do
close, closeend = input:find("}", closeend+1, true)
end
end
local didx = input:find('$', 1, true)
if didx == nil and close == nil then
return input, ""
elseif close ~=nil and (didx == nil or close < didx) then
-- No inner placeholders
return input:sub(0, close-1), input:sub(closeend+1)
end
res = res .. input:sub(0, didx-1)
input = input:sub(didx+1)
local tabstop, tabstopend = input:find('^%d+')
local placeholder, placeholderend = input:find('^{%d+:')
local choice, choiceend = input:find('^{%d+|')
if tabstop then
input = input:sub(tabstopend+1)
elseif choice then
input = input:sub(choiceend+1)
close, closeend = input:find("|}", 1, true)
res = res .. input:sub(0, close-1)
input = input:sub(closeend+1)
elseif placeholder then
-- TODO: add support for variables
input = input:sub(placeholderend+1)
-- placeholders and variables are recursive
while input ~= "" do
local r, tail = parse_snippet_rec(input, true)
r = r:gsub("\\}", "}")
res = res .. r
input = tail
end
else
res = res .. "$"
end
return res, input
end
-- Parse completion entries, consuming snippet tokens
function M.parse_snippet(input)
local res, _ = parse_snippet_rec(input, false)
return res
end
-- Sort by CompletionItem.sortText
-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
local function sort_completion_items(items)
if items[1] and items[1].sortText then
table.sort(items, function(a, b) return a.sortText < b.sortText
end)
end
end
-- Returns text that should be inserted when selecting completion item. The precedence is as follows:
-- textEdit.newText > insertText > label
-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
local function get_completion_word(item)
if item.textEdit ~= nil and item.textEdit.newText ~= nil then
if protocol.InsertTextFormat[item.insertTextFormat] == "PlainText" then
return item.textEdit.newText
else
return M.parse_snippet(item.textEdit.newText)
end
elseif item.insertText ~= nil then
if protocol.InsertTextFormat[item.insertTextFormat] == "PlainText" then
return item.insertText
else
return M.parse_snippet(item.insertText)
end
end
return item.label
end
-- Some language servers return complementary candidates whose prefixes do not match are also returned.
-- So we exclude completion candidates whose prefix does not match.
local function remove_unmatch_completion_items(items, prefix)
return vim.tbl_filter(function(item)
local word = get_completion_word(item)
return vim.startswith(word, prefix)
end, items)
end
-- Acording to LSP spec, if the client set "completionItemKind.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_completion
function M._get_completion_item_kind_name(completion_item_kind)
return protocol.CompletionItemKind[completion_item_kind] or "Unknown"
end
--- Getting vim complete-items with incomplete flag.
-- @params CompletionItem[], CompletionList or nil (https://microsoft.github.io/language-server-protocol/specification#textDocument_completion)
-- @return { matches = complete-items table, incomplete = boolean }
function M.text_document_completion_list_to_complete_items(result, prefix)
local items = M.extract_completion_items(result)
if vim.tbl_isempty(items) then
return {}
end
items = remove_unmatch_completion_items(items, prefix)
sort_completion_items(items)
local matches = {}
for _, completion_item in ipairs(items) do
local info = ' '
local documentation = completion_item.documentation
if documentation then
if type(documentation) == 'string' and documentation ~= '' then
info = documentation
elseif type(documentation) == 'table' and type(documentation.value) == 'string' then
info = documentation.value
-- else
-- TODO(ashkan) Validation handling here?
end
end
local word = get_completion_word(completion_item)
table.insert(matches, {
word = word,
abbr = completion_item.label,
kind = M._get_completion_item_kind_name(completion_item.kind),
menu = completion_item.detail or '',
info = info,
icase = 1,
dup = 1,
empty = 1,
user_data = {
nvim = {
lsp = {
completion_item = completion_item
}
}
},
})
end
return matches
end
-- @params WorkspaceEdit [table] see https://microsoft.github.io/language-server-protocol/specification
function M.apply_workspace_edit(workspace_edit)
if workspace_edit.documentChanges then
for _, change in ipairs(workspace_edit.documentChanges) do
if change.kind then
-- TODO(ashkan) handle CreateFile/RenameFile/DeleteFile
error(string.format("Unsupported change: %q", vim.inspect(change)))
else
M.apply_text_document_edit(change)
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)
end
end
--- Convert any of MarkedString | MarkedString[] | MarkupContent into markdown text lines
-- see https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_hover
-- Useful for textDocument/hover, textDocument/signatureHelp, and potentially others.
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 Hover.contents")
-- MarkupContent
if input.kind then
-- The kind can be either plaintext or markdown. However, either way we
-- will just be rendering markdown, so we handle them both the same way.
-- TODO these can have escaped/sanitized html codes in markdown. We
-- should make sure we handle this correctly.
-- Some servers send input.value as empty, so let's ignore this :(
-- assert(type(input.value) == 'string')
list_extend(contents, split_lines(input.value or ''))
-- MarkupString variation 2
elseif input.language then
-- Some servers send input.value as empty, so let's ignore this :(
-- assert(type(input.value) == 'string')
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
--- Convert SignatureHelp response to markdown lines.
-- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_signatureHelp
function M.convert_signature_help_to_markdown_lines(signature_help)
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_signature = signature_help.activeSignature or 0
-- If the activeSignature is not inside the valid range, then clip it.
if active_signature >= #signature_help.signatures then
active_signature = 0
end
local signature = signature_help.signatures[active_signature + 1]
if not signature then
return
end
vim.list_extend(contents, vim.split(signature.label, '\n', true))
if signature.documentation then
M.convert_input_to_markdown_lines(signature.documentation, contents)
end
if signature_help.parameters then
local active_parameter = signature_help.activeParameter or 0
-- If the activeParameter is not inside the valid range, then clip it.
if active_parameter >= #signature_help.parameters then
active_parameter = 0
end
local parameter = signature.parameters and signature.parameters[active_parameter]
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;
}
--]=]
-- TODO highlight parameter
if parameter.documentation then
M.convert_input_help_to_markdown_lines(parameter.documentation, contents)
end
end
end
return contents
end
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 = vim.fn.winline() - 1
local lines_below = vim.fn.winheight(0) - lines_above
if lines_above < lines_below then
anchor = anchor..'N'
height = math.min(lines_below, height)
row = 1
else
anchor = anchor..'S'
height = math.min(lines_above, height)
row = 0
end
if vim.fn.wincol() + width <= api.nvim_get_option('columns') then
anchor = anchor..'W'
col = 0
else
anchor = anchor..'E'
col = 1
end
return {
anchor = anchor,
col = col + (opts.offset_x or 0),
height = height,
relative = 'cursor',
row = row + (opts.offset_y or 0),
style = 'minimal',
width = width,
}
end
function M.jump_to_location(location)
-- location may be Location or LocationLink
local uri = location.uri or location.targetUri
if uri == nil then return end
local bufnr = vim.uri_to_bufnr(uri)
-- 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')
--- Jump to new location (adjusting for UTF-16 encoding of characters)
api.nvim_set_current_buf(bufnr)
api.nvim_buf_set_option(0, 'buflisted', true)
local range = location.range or location.targetSelectionRange
local row = range.start.line
local col = get_line_byte_from_position(0, range.start)
api.nvim_win_set_cursor(0, {row + 1, col})
return true
end
--- Preview a location in a floating windows
---
--- 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 a single Location or LocationLink
--@return bufnr,winnr buffer and window number of floating window or nil
function M.preview_location(location)
-- 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 filetype = api.nvim_buf_get_option(bufnr, 'filetype')
return M.open_floating_preview(contents, filetype)
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
-- Check if a window with `unique_name` tagged is associated with the current
-- buffer. If not, make a new preview.
--
-- fn()'s return bufnr, winnr
-- case that a new floating window should be created.
function M.focusable_float(unique_name, fn)
if npcall(api.nvim_win_get_var, 0, unique_name) then
return api.nvim_command("wincmd p")
end
local bufnr = api.nvim_get_current_buf()
do
local win = find_window_by_var(unique_name, bufnr)
if win then
api.nvim_set_current_win(win)
api.nvim_command("stopinsert")
return
end
end
local pbufnr, pwinnr = fn()
if pbufnr then
api.nvim_win_set_var(pwinnr, unique_name, bufnr)
return pbufnr, pwinnr
end
end
-- Check if a window with `unique_name` tagged is associated with the current
-- buffer. If not, make a new preview.
--
-- fn()'s return values will be passed directly to open_floating_preview in the
-- case that a new floating window should be created.
function M.focusable_preview(unique_name, fn)
return M.focusable_float(unique_name, function()
return M.open_floating_preview(fn())
end)
end
--- Trim empty lines from input and pad left and right with spaces
---
--@param contents table of lines to trim and pad
--@param opts dictionary with optional fields
-- - pad_left number of columns to pad contents at left (default 1)
-- - pad_right number of columns to pad contents at right (default 1)
-- - pad_top number of lines to pad contents at top (default 0)
-- - pad_bottom number of lines to pad contents at bottom (default 0)
--@return contents table of trimmed and padded lines
function M._trim_and_pad(contents, opts)
validate {
contents = { contents, 't' };
opts = { opts, 't', true };
}
opts = opts or {}
local left_padding = (" "):rep(opts.pad_left or 1)
local right_padding = (" "):rep(opts.pad_right or 1)
contents = M.trim_empty_lines(contents)
for i, line in ipairs(contents) do
contents[i] = string.format('%s%s%s', left_padding, line:gsub("\r", ""), right_padding)
end
if opts.pad_top then
for _ = 1, opts.pad_top do
table.insert(contents, 1, "")
end
end
if opts.pad_bottom then
for _ = 1, opts.pad_bottom do
table.insert(contents, "")
end
end
return contents
end
--- Convert 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.
--- The result is shown in a floating preview
--- TODO: refactor to separate stripping/converting and make use of open_floating_preview
---
--@param contents table of lines to show in window
--@param opts dictionary 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
-- - pad_left number of columns to pad contents at left
-- - pad_right number of columns to pad contents at right
-- - pad_top number of lines to pad contents at top
-- - pad_bottom number of lines to pad contents at bottom
-- - separator insert separator after code block
--@return width,height size of float
function M.fancy_floating_markdown(contents, opts)
validate {
contents = { contents, 't' };
opts = { opts, 't', true };
}
opts = opts or {}
local stripped = {}
local highlights = {}
do
local i = 1
while i <= #contents do
local line = contents[i]
-- TODO(ashkan): use a more strict regex for filetype?
local ft = line:match("^```([a-zA-Z0-9_]*)$")
-- local ft = line:match("^```(.*)$")
-- TODO(ashkan): validate the filetype here.
if ft then
local start = #stripped
i = i + 1
while i <= #contents do
line = contents[i]
if line == "```" then
i = i + 1
break
end
table.insert(stripped, line)
i = i + 1
end
table.insert(highlights, {
ft = ft;
start = start + 1;
finish = #stripped + 1 - 1;
})
else
table.insert(stripped, line)
i = i + 1
end
end
end
-- Clean up and add padding
stripped = M._trim_and_pad(stripped, opts)
-- 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, height = M._make_floating_popup_size(stripped, opts)
-- Insert blank line separator after code block
local insert_separator = opts.separator or true
if insert_separator then
for i, h in ipairs(highlights) do
h.start = h.start + i - 1
h.finish = h.finish + i - 1
if h.finish + 1 <= #stripped then
table.insert(stripped, h.finish + 1, string.rep("", width))
height = height + 1
end
end
end
-- Make the floating window.
local bufnr = api.nvim_create_buf(false, true)
local winnr = api.nvim_open_win(bufnr, false, M.make_floating_popup_options(width, height, opts))
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped)
-- Switch to the floating window to apply the syntax highlighting.
-- This is because the syntax command doesn't accept a target.
local cwin = vim.api.nvim_get_current_win()
vim.api.nvim_set_current_win(winnr)
vim.cmd("ownsyntax markdown")
local idx = 1
local function apply_syntax_to_region(ft, start, finish)
if ft == '' then return end
local name = ft..idx
idx = idx + 1
local lang = "@"..ft:upper()
-- TODO(ashkan): better validation before this.
if not pcall(vim.cmd, string.format("syntax include %s syntax/%s.vim", lang, ft)) then
return
end
vim.cmd(string.format("syntax region %s start=+\\%%%dl+ end=+\\%%%dl+ contains=%s", name, start, finish + 1, lang))
end
-- Previous highlight region.
-- TODO(ashkan): this wasn't working for some reason, but I would like to
-- make sure that regions between code blocks are definitely markdown.
-- local ph = {start = 0; finish = 1;}
for _, h in ipairs(highlights) do
-- apply_syntax_to_region('markdown', ph.finish, h.start)
apply_syntax_to_region(h.ft, h.start, h.finish)
-- ph = h
end
vim.api.nvim_set_current_win(cwin)
return bufnr, winnr
end
function M.close_preview_autocmd(events, winnr)
api.nvim_command("autocmd "..table.concat(events, ',').." <buffer> ++once lua pcall(vim.api.nvim_win_close, "..winnr..", true)")
end
--- Compute size of float needed to show contents (with optional wrapping)
---
--@param contents table of lines to show in window
--@param opts dictionary 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 width,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)
width = math.max(line_widths[i], width)
end
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)
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
--- Show contents in a floating window
---
--@param contents table of lines to show in window
--@param filetype string of filetype to set for opened buffer
--@param opts dictionary 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
-- - pad_left number of columns to pad contents at left
-- - pad_right number of columns to pad contents at right
-- - pad_top number of lines to pad contents at top
-- - pad_bottom number of lines to pad contents at bottom
--@return bufnr,winnr buffer and window number of floating window or nil
function M.open_floating_preview(contents, filetype, opts)
validate {
contents = { contents, 't' };
filetype = { filetype, 's', true };
opts = { opts, 't', true };
}
opts = opts or {}
-- Clean up input: trim empty lines from the end, pad
contents = M._trim_and_pad(contents, opts)
-- 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, height = M._make_floating_popup_size(contents, opts)
local floating_bufnr = api.nvim_create_buf(false, true)
if filetype then
api.nvim_buf_set_option(floating_bufnr, 'filetype', filetype)
end
local float_option = M.make_floating_popup_options(width, height, opts)
local floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option)
if filetype == 'markdown' then
api.nvim_win_set_option(floating_winnr, 'conceallevel', 2)
end
api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents)
api.nvim_buf_set_option(floating_bufnr, 'modifiable', false)
M.close_preview_autocmd({"CursorMoved", "CursorMovedI", "BufHidden", "BufLeave"}, floating_winnr)
return floating_bufnr, floating_winnr
end
do
local diagnostic_ns = api.nvim_create_namespace("vim_lsp_diagnostics")
local reference_ns = api.nvim_create_namespace("vim_lsp_references")
local sign_ns = 'vim_lsp_signs'
local underline_highlight_name = "LspDiagnosticsUnderline"
vim.cmd(string.format("highlight default %s gui=underline cterm=underline", underline_highlight_name))
for kind, _ in pairs(protocol.DiagnosticSeverity) do
if type(kind) == 'string' then
vim.cmd(string.format("highlight default link %s%s %s", underline_highlight_name, kind, underline_highlight_name))
end
end
local severity_highlights = {}
local severity_floating_highlights = {}
local default_severity_highlight = {
[protocol.DiagnosticSeverity.Error] = { guifg = "Red" };
[protocol.DiagnosticSeverity.Warning] = { guifg = "Orange" };
[protocol.DiagnosticSeverity.Information] = { guifg = "LightBlue" };
[protocol.DiagnosticSeverity.Hint] = { guifg = "LightGrey" };
}
-- Initialize default severity highlights
for severity, hi_info in pairs(default_severity_highlight) do
local severity_name = protocol.DiagnosticSeverity[severity]
local highlight_name = "LspDiagnostics"..severity_name
local floating_highlight_name = highlight_name.."Floating"
-- Try to fill in the foreground color with a sane default.
local cmd_parts = {"highlight", "default", highlight_name}
for k, v in pairs(hi_info) do
table.insert(cmd_parts, k.."="..v)
end
api.nvim_command(table.concat(cmd_parts, ' '))
api.nvim_command('highlight link ' .. highlight_name .. 'Sign ' .. highlight_name)
api.nvim_command('highlight link ' .. highlight_name .. 'Floating ' .. highlight_name)
severity_highlights[severity] = highlight_name
severity_floating_highlights[severity] = floating_highlight_name
end
function M.buf_clear_diagnostics(bufnr)
validate { bufnr = {bufnr, 'n', true} }
bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr
-- clear sign group
vim.fn.sign_unplace(sign_ns, {buffer=bufnr})
-- clear virtual text namespace
api.nvim_buf_clear_namespace(bufnr, diagnostic_ns, 0, -1)
end
function M.get_severity_highlight_name(severity)
return severity_highlights[severity]
end
function M.get_line_diagnostics()
local bufnr = api.nvim_get_current_buf()
local linenr = api.nvim_win_get_cursor(0)[1] - 1
local buffer_diagnostics = M.diagnostics_by_buf[bufnr]
if not buffer_diagnostics then
return {}
end
local diagnostics_by_line = M.diagnostics_group_by_line(buffer_diagnostics)
return diagnostics_by_line[linenr] or {}
end
function M.show_line_diagnostics()
-- local marks = api.nvim_buf_get_extmarks(bufnr, diagnostic_ns, {line, 0}, {line, -1}, {})
-- if #marks == 0 then
-- return
-- end
local lines = {"Diagnostics:"}
local highlights = {{0, "Bold"}}
local line_diagnostics = M.get_line_diagnostics()
if vim.tbl_isempty(line_diagnostics) then return end
for i, diagnostic in ipairs(line_diagnostics) do
-- for i, mark in ipairs(marks) do
-- local mark_id = mark[1]
-- local diagnostic = buffer_diagnostics[mark_id]
-- TODO(ashkan) make format configurable?
local prefix = string.format("%d. ", i)
local hiname = severity_floating_highlights[diagnostic.severity]
assert(hiname, 'unknown severity: ' .. tostring(diagnostic.severity))
local message_lines = split_lines(diagnostic.message)
table.insert(lines, prefix..message_lines[1])
table.insert(highlights, {#prefix + 1, hiname})
for j = 2, #message_lines do
table.insert(lines, message_lines[j])
table.insert(highlights, {0, hiname})
end
end
local popup_bufnr, winnr = M.open_floating_preview(lines, 'plaintext')
for i, hi in ipairs(highlights) do
local prefixlen, hiname = unpack(hi)
-- Start highlight after the prefix
api.nvim_buf_add_highlight(popup_bufnr, -1, hiname, i-1, prefixlen, -1)
end
return popup_bufnr, winnr
end
--- Saves the diagnostics (Diagnostic[]) into diagnostics_by_buf
---
--@param bufnr bufnr for which the diagnostics are for.
--@param diagnostics Diagnostics[] received from the language server.
function M.buf_diagnostics_save_positions(bufnr, diagnostics)
validate {
bufnr = {bufnr, 'n', true};
diagnostics = {diagnostics, 't', true};
}
if not diagnostics then return end
bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr
if not M.diagnostics_by_buf[bufnr] then
-- Clean up our data when the buffer unloads.
api.nvim_buf_attach(bufnr, false, {
on_detach = function(b)
M.diagnostics_by_buf[b] = nil
end
})
end
M.diagnostics_by_buf[bufnr] = diagnostics
end
function M.buf_diagnostics_underline(bufnr, diagnostics)
for _, diagnostic in ipairs(diagnostics) do
local start = diagnostic.range["start"]
local finish = diagnostic.range["end"]
local hlmap = {
[protocol.DiagnosticSeverity.Error]='Error',
[protocol.DiagnosticSeverity.Warning]='Warning',
[protocol.DiagnosticSeverity.Information]='Information',
[protocol.DiagnosticSeverity.Hint]='Hint',
}
highlight.range(bufnr, diagnostic_ns,
underline_highlight_name..hlmap[diagnostic.severity],
{start.line, start.character},
{finish.line, finish.character}
)
end
end
function M.buf_clear_references(bufnr)
validate { bufnr = {bufnr, 'n', true} }
api.nvim_buf_clear_namespace(bufnr, reference_ns, 0, -1)
end
function M.buf_highlight_references(bufnr, references)
validate { bufnr = {bufnr, 'n', true} }
for _, reference in ipairs(references) do
local start_pos = {reference["range"]["start"]["line"], reference["range"]["start"]["character"]}
local end_pos = {reference["range"]["end"]["line"], reference["range"]["end"]["character"]}
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_pos, end_pos)
end
end
function M.diagnostics_group_by_line(diagnostics)
if not diagnostics then return end
local diagnostics_by_line = {}
for _, diagnostic in ipairs(diagnostics) do
local start = diagnostic.range.start
local line_diagnostics = diagnostics_by_line[start.line]
if not line_diagnostics then
line_diagnostics = {}
diagnostics_by_line[start.line] = line_diagnostics
end
table.insert(line_diagnostics, diagnostic)
end
return diagnostics_by_line
end
function M.buf_diagnostics_virtual_text(bufnr, diagnostics)
if not diagnostics then
return
end
local buffer_line_diagnostics = M.diagnostics_group_by_line(diagnostics)
for line, line_diags in pairs(buffer_line_diagnostics) do
local virt_texts = {}
for i = 1, #line_diags - 1 do
table.insert(virt_texts, {"", severity_highlights[line_diags[i].severity]})
end
local last = line_diags[#line_diags]
-- TODO(ashkan) use first line instead of subbing 2 spaces?
table.insert(virt_texts, {""..last.message:gsub("\r", ""):gsub("\n", " "), severity_highlights[last.severity]})
api.nvim_buf_set_virtual_text(bufnr, diagnostic_ns, line, virt_texts, {})
end
end
--- Returns the number of diagnostics of given kind for current buffer.
---
--- Useful for showing diagnostic counts in statusline. eg:
---
--- <pre>
--- function! LspStatus() abort
--- let sl = ''
--- if luaeval('not vim.tbl_isempty(vim.lsp.buf_get_clients(0))')
--- let sl.='%#MyStatuslineLSP#E:'
--- let sl.='%#MyStatuslineLSPErrors#%{luaeval("vim.lsp.util.buf_diagnostics_count([[Error]])")}'
--- let sl.='%#MyStatuslineLSP# W:'
--- let sl.='%#MyStatuslineLSPWarnings#%{luaeval("vim.lsp.util.buf_diagnostics_count([[Warning]])")}'
--- else
--- let sl.='%#MyStatuslineLSPErrors#off'
--- endif
--- return sl
--- endfunction
--- let &l:statusline = '%#MyStatuslineLSP#LSP '.LspStatus()
--- </pre>
---
--@param kind Diagnostic severity kind: See |vim.lsp.protocol.DiagnosticSeverity|
---
--@return Count of diagnostics
function M.buf_diagnostics_count(kind)
local bufnr = vim.api.nvim_get_current_buf()
local diagnostics = M.diagnostics_by_buf[bufnr]
if not diagnostics then return end
local count = 0
for _, diagnostic in pairs(diagnostics) do
if protocol.DiagnosticSeverity[kind] == diagnostic.severity then
count = count + 1
end
end
return count
end
local diagnostic_severity_map = {
[protocol.DiagnosticSeverity.Error] = "LspDiagnosticsErrorSign";
[protocol.DiagnosticSeverity.Warning] = "LspDiagnosticsWarningSign";
[protocol.DiagnosticSeverity.Information] = "LspDiagnosticsInformationSign";
[protocol.DiagnosticSeverity.Hint] = "LspDiagnosticsHintSign";
}
--- Place signs for each diagnostic in the sign column.
---
--- Sign characters can be customized with the following commands:
---
--- <pre>
--- sign define LspDiagnosticsErrorSign text=E texthl=LspDiagnosticsError linehl= numhl=
--- sign define LspDiagnosticsWarningSign text=W texthl=LspDiagnosticsWarning linehl= numhl=
--- sign define LspDiagnosticsInformationSign text=I texthl=LspDiagnosticsInformation linehl= numhl=
--- sign define LspDiagnosticsHintSign text=H texthl=LspDiagnosticsHint linehl= numhl=
--- </pre>
function M.buf_diagnostics_signs(bufnr, diagnostics)
for _, diagnostic in ipairs(diagnostics) do
vim.fn.sign_place(0, sign_ns, diagnostic_severity_map[diagnostic.severity], bufnr, {lnum=(diagnostic.range.start.line+1)})
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.
function M.locations_to_items(locations)
local items = {}
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})
end
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 bufnr = vim.uri_to_bufnr(uri)
vim.fn.bufload(bufnr)
local filename = vim.uri_to_fname(uri)
for _, temp in ipairs(rows) do
local pos = temp.start
local row = pos.line
local line = (api.nvim_buf_get_lines(bufnr, row, row + 1, false) or {""})[1]
local col = M.character_offset(bufnr, row, pos.character)
table.insert(items, {
filename = filename,
lnum = row + 1,
col = col + 1;
text = line;
})
end
end
return items
end
function M.set_loclist(items)
vim.fn.setloclist(0, {}, ' ', {
title = 'Language Server';
items = items;
})
end
function M.set_qflist(items)
vim.fn.setqflist({}, ' ', {
title = 'Language Server';
items = items;
})
end
-- Acording 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
--- Convert symbols to quickfix list items
---
--@param symbols DocumentSymbol[] or SymbolInformation[]
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.range then -- DocumentSymbole type
local kind = M._get_symbol_kind_name(symbol.kind)
table.insert(_items, {
-- bufnr = _bufnr,
filename = vim.api.nvim_buf_get_name(_bufnr),
lnum = symbol.range.start.line + 1,
col = symbol.range.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
vim.list_extend(_items, v)
end
end
end
end
return _items
end
return _symbols_to_items(symbols, {}, bufnr)
end
-- Remove empty lines from the beginning and end.
function M.trim_empty_lines(lines)
local start = 1
for i = 1, #lines do
if #lines[i] > 0 then
start = i
break
end
end
local finish = 1
for i = #lines, 1, -1 do
if #lines[i] > 0 then
finish = i
break
end
end
return vim.list_extend({}, lines, start, finish)
end
-- Accepts markdown lines and tries to reduce it to a filetype if it is
-- just a single code block.
-- Note: This modifies the input.
--
-- Returns: filetype or 'markdown' if it was unchanged.
function M.try_trim_markdown_code_blocks(lines)
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
local str_utfindex = vim.str_utfindex
local function make_position_param()
local row, col = unpack(api.nvim_win_get_cursor(0))
row = row - 1
local line = api.nvim_buf_get_lines(0, row, row+1, true)[1]
col = str_utfindex(line, col)
return { line = row; character = col; }
end
function M.make_position_params()
return {
textDocument = M.make_text_document_params();
position = make_position_param()
}
end
function M.make_range_params()
local position = make_position_param()
return {
textDocument = { uri = vim.uri_from_bufnr(0) },
range = { start = position; ["end"] = position; }
}
end
function M.make_text_document_params()
return { uri = vim.uri_from_bufnr(0) }
end
--- Get visual width of tabstop.
---
--@see |softtabstop|
--@param bufnr (optional, number): Buffer handle, defaults to current
--@returns (number) tabstop visual width
function M.get_effective_tabstop(bufnr)
validate { bufnr = {bufnr, 'n', true} }
local bo = bufnr and vim.bo[bufnr] or vim.bo
local sts = bo.softtabstop
return (sts > 0 and sts) or (sts < 0 and bo.shiftwidth) or bo.tabstop
end
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
-- @param buf buffer handle or 0 for current.
-- @param row 0-indexed line
-- @param col 0-indexed byte offset in line
function M.character_offset(buf, row, col)
local line = api.nvim_buf_get_lines(buf, row, row+1, true)[1]
-- If the col is past the EOL, use the line length.
if col > #line then
return str_utfindex(line)
end
return str_utfindex(line, col)
end
M.buf_versions = {}
return M
-- vim:sw=2 ts=2 et