diff options
Diffstat (limited to 'runtime/lua/vim/lsp/util.lua')
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 403 |
1 files changed, 318 insertions, 85 deletions
diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index df82e2d412..099a77099b 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -6,6 +6,31 @@ local list_extend = vim.list_extend 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) @@ -71,16 +96,28 @@ 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 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 = e.range.start.character + local start_bline = api.nvim_buf_get_lines(bufnr, start_row, start_row+1, true)[1] + start_col = vim.str_byteindex(start_bline, start_col) + local end_row = e.range["end"].line + local end_col = e.range["end"].character + local end_bline = api.nvim_buf_get_lines(bufnr, end_row, end_row+1, true)[1] + end_col = vim.str_byteindex(end_bline, end_col) 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 = {e.range.start.line; e.range.start.character}; - B = {e.range["end"].line; e.range["end"].character}; + A = {start_row; start_col}; + B = {end_row; end_col}; lines = vim.split(e.newText, '\n', true); }) end @@ -131,10 +168,12 @@ end 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) - -- TODO(ashkan) check this is correct. - if api.nvim_buf_get_changedtick(bufnr) > text_document.version then - print("Buffer ", text_document.uri, " newer than edits.") - return + 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 @@ -145,15 +184,55 @@ function M.get_current_line_to_cursor() return line:sub(pos[2]+1) 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 + return item.textEdit.newText + elseif item.insertText ~= nil then + return item.insertText + end + return item.label +end + +-- Some lanuguage 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) +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 @@ -169,16 +248,23 @@ function M.text_document_completion_list_to_complete_items(result) end end - local word = completion_item.insertText or completion_item.label + local word = get_completion_word(completion_item) table.insert(matches, { word = word, abbr = completion_item.label, - kind = protocol.CompletionItemKind[completion_item.kind] or '', + kind = M._get_completion_item_kind_name(completion_item.kind), menu = completion_item.detail or '', info = info, icase = 1, - dup = 0, + dup = 1, empty = 1, + user_data = { + nvim = { + lsp = { + completion_item = completion_item + } + } + }, }) end @@ -245,12 +331,71 @@ function M.convert_input_to_markdown_lines(input, contents) end end end - if contents[1] == '' or contents[1] == nil then + 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 }; @@ -297,14 +442,24 @@ function M.make_floating_popup_options(width, height, opts) end function M.jump_to_location(location) - if location.uri == nil then return end - local bufnr = vim.uri_to_bufnr(location.uri) + -- 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'" - -- TODO(ashkan) use tagfunc here to update tagstack. + + -- 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) - local row = location.range.start.line - local col = location.range.start.character + api.nvim_buf_set_option(0, 'buflisted', true) + local range = location.range or location.targetSelectionRange + local row = range.start.line + local col = range.start.character local line = api.nvim_buf_get_lines(0, row, row+1, true)[1] col = vim.str_byteindex(line, col) api.nvim_win_set_cursor(0, {row + 1, col}) @@ -498,36 +653,10 @@ function M.open_floating_preview(contents, filetype, opts) end api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents) api.nvim_buf_set_option(floating_bufnr, 'modifiable', false) - -- TODO make InsertCharPre disappearing optional? - api.nvim_command("autocmd CursorMoved,BufHidden,InsertCharPre <buffer> ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") + M.close_preview_autocmd({"CursorMoved", "CursorMovedI", "BufHidden"}, floating_winnr) return floating_bufnr, floating_winnr end -local function validate_lsp_position(pos) - validate { pos = {pos, 't'} } - validate { - line = {pos.line, 'n'}; - character = {pos.character, 'n'}; - } - return true -end - -function M.open_floating_peek_preview(bufnr, start, finish, opts) - validate { - bufnr = {bufnr, 'n'}; - start = {start, validate_lsp_position, 'valid start Position'}; - finish = {finish, validate_lsp_position, 'valid finish Position'}; - opts = { opts, 't', true }; - } - local width = math.max(finish.character - start.character + 1, 1) - local height = math.max(finish.line - start.line + 1, 1) - local floating_winnr = api.nvim_open_win(bufnr, false, M.make_floating_popup_options(width, height, opts)) - api.nvim_win_set_cursor(floating_winnr, {start.line+1, start.character}) - api.nvim_command("autocmd CursorMoved * ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") - return floating_winnr -end - - local function highlight_range(bufnr, ns, hiname, start, finish) if start[1] == finish[1] then -- TODO care about encoding here since this is in byte index? @@ -542,10 +671,9 @@ local function highlight_range(bufnr, ns, hiname, start, finish) end do - local all_buffer_diagnostics = {} - 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 @@ -573,12 +701,18 @@ 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) severity_highlights[severity] = 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 @@ -593,13 +727,12 @@ do -- if #marks == 0 then -- return -- end - -- local buffer_diagnostics = all_buffer_diagnostics[bufnr] local lines = {"Diagnostics:"} local highlights = {{0, "Bold"}} - local buffer_diagnostics = all_buffer_diagnostics[bufnr] + local buffer_diagnostics = M.diagnostics_by_buf[bufnr] if not buffer_diagnostics then return end - local line_diagnostics = buffer_diagnostics[line] + local line_diagnostics = M.diagnostics_group_by_line(buffer_diagnostics)[line] if not line_diagnostics then return end for i, diagnostic in ipairs(line_diagnostics) do @@ -610,6 +743,7 @@ do -- TODO(ashkan) make format configurable? local prefix = string.format("%d. ", i) local hiname = severity_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}) @@ -627,6 +761,8 @@ do return popup_bufnr, winnr end + --- Saves the diagnostics (Diagnostic[]) into diagnostics_by_buf + -- function M.buf_diagnostics_save_positions(bufnr, diagnostics) validate { bufnr = {bufnr, 'n', true}; @@ -635,31 +771,17 @@ do if not diagnostics then return end bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr - if not all_buffer_diagnostics[bufnr] then + 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) - all_buffer_diagnostics[b] = nil + M.diagnostics_by_buf[b] = nil end }) end - all_buffer_diagnostics[bufnr] = {} - local buffer_diagnostics = all_buffer_diagnostics[bufnr] - - for _, diagnostic in ipairs(diagnostics) do - local start = diagnostic.range.start - -- local mark_id = api.nvim_buf_set_extmark(bufnr, diagnostic_ns, 0, start.line, 0, {}) - -- buffer_diagnostics[mark_id] = diagnostic - local line_diagnostics = buffer_diagnostics[start.line] - if not line_diagnostics then - line_diagnostics = {} - buffer_diagnostics[start.line] = line_diagnostics - end - table.insert(line_diagnostics, diagnostic) - 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"] @@ -681,15 +803,46 @@ do end end - function M.buf_diagnostics_virtual_text(bufnr, diagnostics) - local buffer_line_diagnostics = all_buffer_diagnostics[bufnr] - if not buffer_line_diagnostics then - M.buf_diagnostics_save_positions(bufnr, diagnostics) + 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 - buffer_line_diagnostics = all_buffer_diagnostics[bufnr] - if not buffer_line_diagnostics then + 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 @@ -701,10 +854,36 @@ do api.nvim_buf_set_virtual_text(bufnr, diagnostic_ns, line, virt_texts, {}) end end + + 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"; + } + + 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.line, v.character} + return {v.start.line, v.start.character} end) -- Returns the items with the byte position calculated correctly and in sorted @@ -719,19 +898,25 @@ function M.locations_to_items(locations) end; }) for _, d in ipairs(locations) do - local start = d.range.start - local fname = assert(vim.uri_to_fname(d.uri)) - table.insert(grouped[fname], start) + -- locations may be Location or LocationLink + local uri = d.uri or d.targetUri + local fname = assert(vim.uri_to_fname(uri)) + local range = d.range or d.targetSelectionRange + table.insert(grouped[fname], {start = range.start}) end + + local keys = vim.tbl_keys(grouped) table.sort(keys) -- TODO(ashkan) I wish we could do this lazily. for _, fname in ipairs(keys) do local rows = grouped[fname] + table.sort(rows, position_sort) local i = 0 for line in io.lines(fname) do - for _, pos in ipairs(rows) do + for _, temp in ipairs(rows) do + local pos = temp.start local row = pos.line if i == row then local col @@ -754,23 +939,65 @@ function M.locations_to_items(locations) return items end --- locations is Location[] --- Only sets for the current window. -function M.set_loclist(locations) +function M.set_loclist(items) vim.fn.setloclist(0, {}, ' ', { title = 'Language Server'; - items = M.locations_to_items(locations); + items = items; }) end --- locations is Location[] -function M.set_qflist(locations) +function M.set_qflist(items) vim.fn.setqflist({}, ' ', { title = 'Language Server'; - items = M.locations_to_items(locations); + 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 +--- +--@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 @@ -823,11 +1050,15 @@ function M.make_position_params() local line = api.nvim_buf_get_lines(0, row, row+1, true)[1] col = str_utfindex(line, col) return { - textDocument = { uri = vim.uri_from_bufnr(0) }; + textDocument = M.make_text_document_params(); position = { line = row; character = col; } } end +function M.make_text_document_params() + return { uri = vim.uri_from_bufnr(0) } +end + -- @param buf buffer handle or 0 for current. -- @param row 0-indexed line -- @param col 0-indexed byte offset in line @@ -840,5 +1071,7 @@ function M.character_offset(buf, row, col) return str_utfindex(line, col) end +M.buf_versions = {} + return M -- vim:sw=2 ts=2 et |