From 00dc12c5d8454a2d3c6806710f63bbb446076e96 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 13 Nov 2019 12:55:26 -0800 Subject: lua LSP client: initial implementation (#11336) Mainly configuration and RPC infrastructure can be considered "done". Specific requests and their callbacks will be improved later (and also served by plugins). There are also some TODO:s for the client itself, like incremental updates. Co-authored by at-tjdevries and at-h-michael, with many review/suggestion contributions. --- runtime/lua/vim/lsp/util.lua | 557 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 557 insertions(+) create mode 100644 runtime/lua/vim/lsp/util.lua (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua new file mode 100644 index 0000000000..f96e0f01a8 --- /dev/null +++ b/runtime/lua/vim/lsp/util.lua @@ -0,0 +1,557 @@ +local protocol = require 'vim.lsp.protocol' +local validate = vim.validate +local api = vim.api + +local M = {} + +local split = vim.split +local function split_lines(value) + return split(value, '\n', true) +end + +local list_extend = vim.list_extend + +--- Find the longest shared prefix between prefix and word. +-- e.g. remove_prefix("123tes", "testing") == "ting" +local function remove_prefix(prefix, word) + local max_prefix_length = math.min(#prefix, #word) + local prefix_length = 0 + for i = 1, max_prefix_length do + local current_line_suffix = prefix:sub(-i) + local word_prefix = word:sub(1, i) + if current_line_suffix == word_prefix then + prefix_length = i + end + end + return word:sub(prefix_length + 1) +end + +local function resolve_bufnr(bufnr) + if bufnr == nil or bufnr == 0 then + return api.nvim_get_current_buf() + end + return bufnr +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 + +--- Apply the TextEdit response. +-- @params TextEdit [table] see https://microsoft.github.io/language-server-protocol/specification +function M.text_document_apply_text_edit(text_edit, bufnr) + bufnr = resolve_bufnr(bufnr) + local range = text_edit.range + local start = range.start + local finish = range['end'] + local new_lines = split_lines(text_edit.newText) + if start.character == 0 and finish.character == 0 then + api.nvim_buf_set_lines(bufnr, start.line, finish.line, false, new_lines) + return + end + api.nvim_err_writeln('apply_text_edit currently only supports character ranges starting at 0') + error('apply_text_edit currently only supports character ranges starting at 0') + return + -- TODO test and finish this support for character ranges. +-- local lines = api.nvim_buf_get_lines(0, start.line, finish.line + 1, false) +-- local suffix = lines[#lines]:sub(finish.character+2) +-- local prefix = lines[1]:sub(start.character+2) +-- new_lines[#new_lines] = new_lines[#new_lines]..suffix +-- new_lines[1] = prefix..new_lines[1] +-- api.nvim_buf_set_lines(0, start.line, finish.line, false, new_lines) +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.text_document_apply_text_document_edit(text_document_edit, bufnr) + -- local text_document = text_document_edit.textDocument + -- TODO use text_document_version? + -- local text_document_version = text_document.version + + -- TODO technically, you could do this without doing multiple buf_get/set + -- by getting the full region (smallest line and largest line) and doing + -- the edits on the buffer, and then applying the buffer at the end. + -- I'm not sure if that's better. + for _, text_edit in ipairs(text_document_edit.edits) do + M.text_document_apply_text_edit(text_edit, bufnr) + end +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 + +--- 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, line_prefix) + local items = M.extract_completion_items(result) + if vim.tbl_isempty(items) then + return {} + end + -- Only initialize if we have some items. + if not line_prefix then + line_prefix = M.get_current_line_to_cursor() + end + + 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 = completion_item.insertText or completion_item.label + + -- Ref: `:h complete-items` + table.insert(matches, { + word = remove_prefix(line_prefix, word), + abbr = completion_item.label, + kind = protocol.CompletionItemKind[completion_item.kind] or '', + menu = completion_item.detail or '', + info = info, + icase = 1, + dup = 0, + empty = 1, + }) + end + + return matches +end + +-- @params WorkspaceEdit [table] see https://microsoft.github.io/language-server-protocol/specification +function M.workspace_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.text_document_apply_text_document_edit(change) + end + end + return + end + + if workspace_edit.changes == nil or #workspace_edit.changes == 0 then + return + end + + for uri, changes in pairs(workspace_edit.changes) do + local fname = vim.uri_to_fname(uri) + -- TODO improve this approach. Try to edit open buffers without switching. + -- Not sure how to handle files which aren't open. This is deprecated + -- anyway, so I guess it could be left as is. + api.nvim_command('edit '..fname) + for _, change in ipairs(changes) do + M.text_document_apply_text_edit(change) + end + 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 then + return {} + 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 + + if vim.fn.winline() <= height then + anchor = anchor..'N' + row = 1 + else + anchor = anchor..'S' + 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.open_floating_preview(contents, filetype, opts) + validate { + contents = { contents, 't' }; + filetype = { filetype, 's', true }; + opts = { opts, 't', true }; + } + + -- Trim empty lines from the end. + for i = #contents, 1, -1 do + if #contents[i] == 0 then + table.remove(contents) + else + break + end + end + + local width = 0 + local height = #contents + for i, line in ipairs(contents) do + -- Clean up the input and add left pad. + line = " "..line:gsub("\r", "") + -- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced. + local line_width = vim.fn.strdisplaywidth(line) + width = math.max(line_width, width) + contents[i] = line + end + -- Add right padding of 1 each. + width = width + 1 + + 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) + api.nvim_command("autocmd CursorMoved ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") + 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? + api.nvim_buf_add_highlight(bufnr, ns, hiname, start[1], start[2], finish[2]) + else + api.nvim_buf_add_highlight(bufnr, ns, hiname, start[1], start[2], -1) + for line = start[1] + 1, finish[1] - 1 do + api.nvim_buf_add_highlight(bufnr, ns, hiname, line, 0, -1) + end + api.nvim_buf_add_highlight(bufnr, ns, hiname, finish[1], 0, finish[2]) + end +end + +do + local all_buffer_diagnostics = {} + + local diagnostic_ns = api.nvim_create_namespace("vim_lsp_diagnostics") + + local default_severity_highlight = { + [protocol.DiagnosticSeverity.Error] = { guifg = "Red" }; + [protocol.DiagnosticSeverity.Warning] = { guifg = "Orange" }; + [protocol.DiagnosticSeverity.Information] = { guifg = "LightBlue" }; + [protocol.DiagnosticSeverity.Hint] = { guifg = "LightGrey" }; + } + + local underline_highlight_name = "LspDiagnosticsUnderline" + api.nvim_command(string.format("highlight %s gui=underline cterm=underline", underline_highlight_name)) + + local function find_color_rgb(color) + local rgb_hex = api.nvim_get_color_by_name(color) + validate { color = {color, function() return rgb_hex ~= -1 end, "valid color name"} } + return rgb_hex + end + + --- Determine whether to use black or white text + -- Ref: https://stackoverflow.com/a/1855903/837964 + -- https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color + local function color_is_bright(r, g, b) + -- Counting the perceptive luminance - human eye favors green color + local luminance = (0.299*r + 0.587*g + 0.114*b)/255 + if luminance > 0.5 then + return true -- Bright colors, black font + else + return false -- Dark colors, white font + end + end + + local severity_highlights = {} + + function M.set_severity_highlights(highlights) + validate {highlights = {highlights, 't'}} + for severity, default_color in pairs(default_severity_highlight) do + local severity_name = protocol.DiagnosticSeverity[severity] + local highlight_name = "LspDiagnostics"..severity_name + local hi_info = highlights[severity] or default_color + -- Try to fill in the foreground color with a sane default. + if not hi_info.guifg and hi_info.guibg then + -- TODO(ashkan) move this out when bitop is guaranteed to be included. + local bit = require 'bit' + local band, rshift = bit.band, bit.rshift + local rgb = find_color_rgb(hi_info.guibg) + local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF)) + hi_info.guifg = is_bright and "Black" or "White" + end + if not hi_info.ctermfg and hi_info.ctermbg then + -- TODO(ashkan) move this out when bitop is guaranteed to be included. + local bit = require 'bit' + local band, rshift = bit.band, bit.rshift + local rgb = find_color_rgb(hi_info.ctermbg) + local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF)) + hi_info.ctermfg = is_bright and "Black" or "White" + end + local cmd_parts = {"highlight", highlight_name} + for k, v in pairs(hi_info) do + table.insert(cmd_parts, k.."="..v) + end + api.nvim_command(table.concat(cmd_parts, ' ')) + severity_highlights[severity] = highlight_name + end + end + + function M.buf_clear_diagnostics(bufnr) + validate { bufnr = {bufnr, 'n', true} } + bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr + api.nvim_buf_clear_namespace(bufnr, diagnostic_ns, 0, -1) + end + + -- Initialize with the defaults. + M.set_severity_highlights(default_severity_highlight) + + function M.get_severity_highlight_name(severity) + return severity_highlights[severity] + end + + function M.show_line_diagnostics() + local bufnr = api.nvim_get_current_buf() + local line = api.nvim_win_get_cursor(0)[1] - 1 + -- local marks = api.nvim_buf_get_extmarks(bufnr, diagnostic_ns, {line, 0}, {line, -1}, {}) + -- 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] + if not buffer_diagnostics then return end + local line_diagnostics = buffer_diagnostics[line] + if not 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_highlights[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 + + 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 all_buffer_diagnostics[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 + 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 + end + + + function M.buf_diagnostics_underline(bufnr, diagnostics) + for _, diagnostic in ipairs(diagnostics) do + local start = diagnostic.range.start + local finish = diagnostic.range["end"] + + -- TODO care about encoding here since this is in byte index? + highlight_range(bufnr, diagnostic_ns, underline_highlight_name, + {start.line, start.character}, + {finish.line, finish.character} + ) + 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) + end + buffer_line_diagnostics = all_buffer_diagnostics[bufnr] + if not buffer_line_diagnostics then + return + end + 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 +end + +function M.buf_loclist(bufnr, locations) + local targetwin + for _, winnr in ipairs(api.nvim_list_wins()) do + local winbuf = api.nvim_win_get_buf(winnr) + if winbuf == bufnr then + targetwin = winnr + break + end + end + if not targetwin then return end + + local items = {} + local path = api.nvim_buf_get_name(bufnr) + for _, d in ipairs(locations) do + -- TODO: URL parsing here? + local start = d.range.start + table.insert(items, { + filename = path, + lnum = start.line + 1, + col = start.character + 1, + text = d.message, + }) + end + vim.fn.setloclist(targetwin, items, ' ', 'Language Server') +end + +return M +-- vim:sw=2 ts=2 et -- cgit From 3ae9b3781e0b66f5874971665b38314d61668627 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 11:34:10 -0800 Subject: Bugfix for floating_preview Don't modify your inputs. --- runtime/lua/vim/lsp/util.lua | 3 +++ 1 file changed, 3 insertions(+) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index f96e0f01a8..707e2d0d3b 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -262,6 +262,9 @@ function M.open_floating_preview(contents, filetype, opts) opts = { opts, 't', true }; } + -- Don't modify our inputs. + contents = vim.deepcopy(contents) + -- Trim empty lines from the end. for i = #contents, 1, -1 do if #contents[i] == 0 then -- cgit From 2d580756ca707945c01703d404e10f4bf412a72c Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 15:35:18 -0800 Subject: Add everything to lsp.buf and get rid of autoload. --- runtime/lua/vim/lsp/util.lua | 88 +++++++++++++++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 21 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 707e2d0d3b..13c83fefd6 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -261,32 +261,27 @@ function M.open_floating_preview(contents, filetype, opts) filetype = { filetype, 's', true }; opts = { opts, 't', true }; } - - -- Don't modify our inputs. - contents = vim.deepcopy(contents) + opts = opts or {} -- Trim empty lines from the end. - for i = #contents, 1, -1 do - if #contents[i] == 0 then - table.remove(contents) - else - break + contents = M.trim_empty_lines(contents) + + local width = opts.width + local height = opts.height or #contents + if not width then + width = 0 + for i, line in ipairs(contents) do + -- Clean up the input and add left pad. + line = " "..line:gsub("\r", "") + -- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced. + local line_width = vim.fn.strdisplaywidth(line) + width = math.max(line_width, width) + contents[i] = line end + -- Add right padding of 1 each. + width = width + 1 end - local width = 0 - local height = #contents - for i, line in ipairs(contents) do - -- Clean up the input and add left pad. - line = " "..line:gsub("\r", "") - -- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced. - local line_width = vim.fn.strdisplaywidth(line) - width = math.max(line_width, width) - contents[i] = line - end - -- Add right padding of 1 each. - width = width + 1 - local floating_bufnr = api.nvim_create_buf(false, true) if filetype then api.nvim_buf_set_option(floating_bufnr, 'filetype', filetype) @@ -556,5 +551,56 @@ function M.buf_loclist(bufnr, locations) vim.fn.setloclist(targetwin, items, ' ', 'Language Server') end +-- Remove empty lines from the beginning and end. +function M.trim_empty_lines(lines) + local result = {} + 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 + -- TODO(ashkan) use tbl_slice. + for i = start, finish do + table.insert(result, lines[i]) + end + return result +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 + + return M -- vim:sw=2 ts=2 et -- cgit From 93beae4f31d42dc70c874020011220444d7f979c Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 16:35:11 -0800 Subject: Fix rename support. --- runtime/lua/vim/lsp/util.lua | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 13c83fefd6..ab2c02ffcd 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -52,16 +52,12 @@ function M.text_document_apply_text_edit(text_edit, bufnr) api.nvim_buf_set_lines(bufnr, start.line, finish.line, false, new_lines) return end - api.nvim_err_writeln('apply_text_edit currently only supports character ranges starting at 0') - error('apply_text_edit currently only supports character ranges starting at 0') - return - -- TODO test and finish this support for character ranges. --- local lines = api.nvim_buf_get_lines(0, start.line, finish.line + 1, false) --- local suffix = lines[#lines]:sub(finish.character+2) --- local prefix = lines[1]:sub(start.character+2) --- new_lines[#new_lines] = new_lines[#new_lines]..suffix --- new_lines[1] = prefix..new_lines[1] --- api.nvim_buf_set_lines(0, start.line, finish.line, false, new_lines) + local lines = api.nvim_buf_get_lines(bufnr, start.line, finish.line + 1, false) + local suffix = lines[#lines]:sub(finish.character+1) + local prefix = lines[1]:sub(1, start.character) + new_lines[#new_lines] = new_lines[#new_lines]..suffix + new_lines[1] = prefix..new_lines[1] + api.nvim_buf_set_lines(bufnr, start.line, finish.line + 1, false, new_lines) end -- textDocument/completion response returns one of CompletionItem[], CompletionList or null. @@ -158,18 +154,15 @@ function M.workspace_apply_workspace_edit(workspace_edit) return end - if workspace_edit.changes == nil or #workspace_edit.changes == 0 then + 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(workspace_edit.changes) do - local fname = vim.uri_to_fname(uri) - -- TODO improve this approach. Try to edit open buffers without switching. - -- Not sure how to handle files which aren't open. This is deprecated - -- anyway, so I guess it could be left as is. - api.nvim_command('edit '..fname) + for uri, changes in pairs(all_changes) do + local bufnr = vim.uri_to_bufnr(uri) for _, change in ipairs(changes) do - M.text_document_apply_text_edit(change) + M.text_document_apply_text_edit(change, bufnr) end end end -- cgit From b984f613c1e8dadbe59bf0d7093a6ed12af61b37 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 17:09:21 -0800 Subject: Extend list_extend to take start/finish. --- runtime/lua/vim/lsp/util.lua | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index ab2c02ffcd..3a2142a478 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -546,7 +546,6 @@ end -- Remove empty lines from the beginning and end. function M.trim_empty_lines(lines) - local result = {} local start = 1 for i = 1, #lines do if #lines[i] > 0 then @@ -561,11 +560,7 @@ function M.trim_empty_lines(lines) break end end - -- TODO(ashkan) use tbl_slice. - for i = start, finish do - table.insert(result, lines[i]) - end - return result + return vim.list_extend({}, lines, start, finish) end -- Accepts markdown lines and tries to reduce it to a filetype if it is -- cgit From 6fc409d5939500b038ba281ac1929fd053f35310 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 20:51:44 -0800 Subject: Add full text_edit implementation. - Implements textDocument/formatting, textDocument/rangeFormatting, workspace/applyEdit. TODO: - still has edge cases around replacement probably. Only tested with inserts on the same position. --- runtime/lua/vim/lsp/util.lua | 134 +++++++++++++++++++++++++++++++------------ 1 file changed, 98 insertions(+), 36 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 3a2142a478..004fb81cba 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -33,6 +33,93 @@ local function resolve_bufnr(bufnr) return bufnr end +function M.apply_edit_to_lines(lines, start_pos, end_pos, new_lines) + -- 0-indexing to 1-indexing makes things look a bit worse. + local i_0 = start_pos[1] + 1 + local i_n = end_pos[1] + 1 + local n = i_n - i_0 + 1 + if not lines[i_0] or not lines[i_n] then + error(vim.inspect{#lines, i_0, i_n, n, start_pos, end_pos, new_lines}) + end + local prefix = "" + local suffix = lines[i_n]:sub(end_pos[2]+1) + lines[i_n] = lines[i_n]:sub(1, end_pos[2]+1) + if start_pos[2] > 0 then + prefix = lines[i_0]:sub(1, start_pos[2]) + -- lines[i_0] = lines[i_0]:sub(start.character+1) + end + -- TODO(ashkan) figure out how to avoid copy here. likely by changing algo. + new_lines = vim.list_extend({}, new_lines) + if #suffix > 0 then + new_lines[#new_lines] = new_lines[#new_lines]..suffix + end + if #prefix > 0 then + new_lines[1] = prefix..new_lines[1] + end + if #new_lines >= n then + for i = 1, n do + lines[i + i_0 - 1] = new_lines[i] + end + for i = n+1,#new_lines do + table.insert(lines, i_n + 1, new_lines[i]) + end + else + for i = 1, #new_lines do + lines[i + i_0 - 1] = new_lines[i] + end + for _ = #new_lines+1, n do + table.remove(lines, i_0 + #new_lines + 1) + end + end +end + +function M.apply_text_edits(text_edits, bufnr) + if not next(text_edits) then return end + -- nvim.print("Start", #text_edits) + local start_line, finish_line = math.huge, -1 + local cleaned = {} + for _, e in ipairs(text_edits) do + start_line = math.min(e.range.start.line, start_line) + finish_line = math.max(e.range["end"].line, finish_line) + table.insert(cleaned, { + A = {e.range.start.line; e.range.start.character}; + B = {e.range["end"].line; e.range["end"].character}; + lines = vim.split(e.newText, '\n', true); + }) + end + local lines = api.nvim_buf_get_lines(bufnr, start_line, finish_line + 1, false) + for i, e in ipairs(cleaned) do + -- nvim.print(i, "e", e.A, e.B, #e.lines[#e.lines], e.lines) + local y = 0 + local x = 0 + -- TODO(ashkan) this could be done in O(n) with dynamic programming + for j = 1, i-1 do + local o = cleaned[j] + -- nvim.print(i, "o", o.A, o.B, x, y, #o.lines[#o.lines], o.lines) + if o.A[1] <= e.A[1] and o.A[2] <= e.A[2] then + y = y - (o.B[1] - o.A[1] + 1) + #o.lines + -- Same line + if #o.lines > 1 then + x = -e.A[2] + #o.lines[#o.lines] + else + if o.A[1] == e.A[1] then + -- Try to account for insertions. + -- TODO how to account for deletions? + x = x - (o.B[2] - o.A[2]) + #o.lines[#o.lines] + end + end + end + end + local A = {e.A[1] + y - start_line, e.A[2] + x} + local B = {e.B[1] + y - start_line, e.B[2] + x} + -- if x ~= 0 or y ~= 0 then + -- nvim.print(i, "_", e.A, e.B, y, x, A, B, e.lines) + -- end + M.apply_edit_to_lines(lines, A, B, e.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 @@ -40,26 +127,6 @@ end -- function M.glob_to_regex(glob) -- end ---- Apply the TextEdit response. --- @params TextEdit [table] see https://microsoft.github.io/language-server-protocol/specification -function M.text_document_apply_text_edit(text_edit, bufnr) - bufnr = resolve_bufnr(bufnr) - local range = text_edit.range - local start = range.start - local finish = range['end'] - local new_lines = split_lines(text_edit.newText) - if start.character == 0 and finish.character == 0 then - api.nvim_buf_set_lines(bufnr, start.line, finish.line, false, new_lines) - return - end - local lines = api.nvim_buf_get_lines(bufnr, start.line, finish.line + 1, false) - local suffix = lines[#lines]:sub(finish.character+1) - local prefix = lines[1]:sub(1, start.character) - new_lines[#new_lines] = new_lines[#new_lines]..suffix - new_lines[1] = prefix..new_lines[1] - api.nvim_buf_set_lines(bufnr, start.line, finish.line + 1, false, new_lines) -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) @@ -74,18 +141,15 @@ end --- Apply the TextDocumentEdit response. -- @params TextDocumentEdit [table] see https://microsoft.github.io/language-server-protocol/specification -function M.text_document_apply_text_document_edit(text_document_edit, bufnr) - -- local text_document = text_document_edit.textDocument - -- TODO use text_document_version? - -- local text_document_version = text_document.version - - -- TODO technically, you could do this without doing multiple buf_get/set - -- by getting the full region (smallest line and largest line) and doing - -- the edits on the buffer, and then applying the buffer at the end. - -- I'm not sure if that's better. - for _, text_edit in ipairs(text_document_edit.edits) do - M.text_document_apply_text_edit(text_edit, bufnr) +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 end + M.apply_text_edits(text_document_edit.edits, bufnr) end function M.get_current_line_to_cursor() @@ -141,14 +205,14 @@ function M.text_document_completion_list_to_complete_items(result, line_prefix) end -- @params WorkspaceEdit [table] see https://microsoft.github.io/language-server-protocol/specification -function M.workspace_apply_workspace_edit(workspace_edit) +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.text_document_apply_text_document_edit(change) + M.apply_text_document_edit(change) end end return @@ -161,9 +225,7 @@ function M.workspace_apply_workspace_edit(workspace_edit) for uri, changes in pairs(all_changes) do local bufnr = vim.uri_to_bufnr(uri) - for _, change in ipairs(changes) do - M.text_document_apply_text_edit(change, bufnr) - end + M.apply_text_edits(changes, bufnr) end end -- cgit From b7170f2d722cee24a62eb74ac836d6192e5475dc Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Wed, 20 Nov 2019 20:59:52 -0800 Subject: Remove resolve_bufnr/lualint --- runtime/lua/vim/lsp/util.lua | 7 ------- 1 file changed, 7 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 004fb81cba..dcf2c17df4 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -26,13 +26,6 @@ local function remove_prefix(prefix, word) return word:sub(prefix_length + 1) end -local function resolve_bufnr(bufnr) - if bufnr == nil or bufnr == 0 then - return api.nvim_get_current_buf() - end - return bufnr -end - function M.apply_edit_to_lines(lines, start_pos, end_pos, new_lines) -- 0-indexing to 1-indexing makes things look a bit worse. local i_0 = start_pos[1] + 1 -- cgit From bcae04f6c62b23104e85bb08c54a16d0cdc33852 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 15:19:06 -0800 Subject: Updates - Use correct implementation of text_edits. - Send indent options to rangeFormatting and formatting. - Remove references to vim bindings and filetype from lsp.txt - Add more examples to docs. - Add before_init to allow changing initialize_params. --- runtime/lua/vim/lsp/util.lua | 110 ++++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 58 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index dcf2c17df4..66430fffc7 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -26,89 +26,83 @@ local function remove_prefix(prefix, word) return word:sub(prefix_length + 1) end -function M.apply_edit_to_lines(lines, start_pos, end_pos, new_lines) - -- 0-indexing to 1-indexing makes things look a bit worse. - local i_0 = start_pos[1] + 1 - local i_n = end_pos[1] + 1 - local n = i_n - i_0 + 1 - if not lines[i_0] or not lines[i_n] then - error(vim.inspect{#lines, i_0, i_n, n, start_pos, end_pos, new_lines}) +-- TODO(ashkan) @performance this could do less copying. +function M.set_lines(lines, A, B, new_lines) + -- 0-indexing to 1-indexing + local i_0 = A[1] + 1 + local i_n = B[1] + 1 + 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(end_pos[2]+1) - lines[i_n] = lines[i_n]:sub(1, end_pos[2]+1) - if start_pos[2] > 0 then - prefix = lines[i_0]:sub(1, start_pos[2]) - -- lines[i_0] = lines[i_0]:sub(start.character+1) - end - -- TODO(ashkan) figure out how to avoid copy here. likely by changing algo. - new_lines = vim.list_extend({}, new_lines) + local suffix = lines[i_n]:sub(B[2]+1) + if A[2] > 0 then + prefix = lines[i_0]:sub(1, A[2]) + end + new_lines = list_extend({}, new_lines) if #suffix > 0 then new_lines[#new_lines] = new_lines[#new_lines]..suffix end if #prefix > 0 then new_lines[1] = prefix..new_lines[1] end - if #new_lines >= n then - for i = 1, n do - lines[i + i_0 - 1] = new_lines[i] - end - for i = n+1,#new_lines do - table.insert(lines, i_n + 1, new_lines[i]) - end - else - for i = 1, #new_lines do - lines[i + i_0 - 1] = new_lines[i] - end - for _ = #new_lines+1, n do - table.remove(lines, i_0 + #new_lines + 1) + local result = list_extend({}, lines, 1, i_0 - 1) + list_extend(result, new_lines) + list_extend(result, lines, i_n + 1) + return result +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) function M.apply_text_edits(text_edits, bufnr) if not next(text_edits) then return end - -- nvim.print("Start", #text_edits) local start_line, finish_line = math.huge, -1 local cleaned = {} - for _, e in ipairs(text_edits) do + for i, e in ipairs(text_edits) do 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}; 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) - for i, e in ipairs(cleaned) do - -- nvim.print(i, "e", e.A, e.B, #e.lines[#e.lines], e.lines) - local y = 0 - local x = 0 - -- TODO(ashkan) this could be done in O(n) with dynamic programming - for j = 1, i-1 do - local o = cleaned[j] - -- nvim.print(i, "o", o.A, o.B, x, y, #o.lines[#o.lines], o.lines) - if o.A[1] <= e.A[1] and o.A[2] <= e.A[2] then - y = y - (o.B[1] - o.A[1] + 1) + #o.lines - -- Same line - if #o.lines > 1 then - x = -e.A[2] + #o.lines[#o.lines] - else - if o.A[1] == e.A[1] then - -- Try to account for insertions. - -- TODO how to account for deletions? - x = x - (o.B[2] - o.A[2]) + #o.lines[#o.lines] - end - end - end - end - local A = {e.A[1] + y - start_line, e.A[2] + x} - local B = {e.B[1] + y - start_line, e.B[2] + x} - -- if x ~= 0 or y ~= 0 then - -- nvim.print(i, "_", e.A, e.B, y, x, A, B, e.lines) - -- end - M.apply_edit_to_lines(lines, A, B, e.lines) + 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 -- cgit From b27965538dcc3faa8e1c5b3d64a98108c18981ec Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 15:20:32 -0800 Subject: Fix hovers staying on bufhidden --- runtime/lua/vim/lsp/util.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 66430fffc7..77f2ae6fbc 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -335,7 +335,7 @@ 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) - api.nvim_command("autocmd CursorMoved ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") + api.nvim_command("autocmd CursorMoved,BufHidden ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") return floating_bufnr, floating_winnr end -- cgit From 43d73ee884f97861c44adab4797a8fc185af1436 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 15:41:32 -0800 Subject: Fix position params for encoding. --- runtime/lua/vim/lsp/util.lua | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 77f2ae6fbc..76681920bd 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -638,6 +638,17 @@ function M.try_trim_markdown_code_blocks(lines) return 'markdown' end +function M.make_position_params() + 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 = vim.str_utfindex(line, col) + return { + textDocument = { uri = vim.uri_from_bufnr(0) }; + position = { line = row; character = col; } + } +end + return M -- vim:sw=2 ts=2 et -- cgit From a3d67dac5f9de6099890ae4b9fab2b70b2279a29 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 16:23:12 -0800 Subject: Fix encoding translation in other places. --- runtime/lua/vim/lsp/util.lua | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 76681920bd..570c4df1dd 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -649,6 +649,13 @@ function M.make_position_params() } 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] + return vim.str_utfindex(line, col) +end return M -- vim:sw=2 ts=2 et -- cgit From 78991ffbf4357ba1ad477a13991078bb4a0bdc58 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Thu, 21 Nov 2019 23:58:32 -0800 Subject: Improve performance of util.set_lines + bugfix Also permit character_offset for col past the end of line (useful in range formatting). --- runtime/lua/vim/lsp/util.lua | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 570c4df1dd..94b4223a20 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -11,6 +11,14 @@ end local list_extend = vim.list_extend +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 + --- Find the longest shared prefix between prefix and word. -- e.g. remove_prefix("123tes", "testing") == "ting" local function remove_prefix(prefix, word) @@ -39,17 +47,22 @@ function M.set_lines(lines, A, B, new_lines) if A[2] > 0 then prefix = lines[i_0]:sub(1, A[2]) end - new_lines = list_extend({}, new_lines) + 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 - new_lines[#new_lines] = new_lines[#new_lines]..suffix + local i = i_0 + #new_lines - 1 + lines[i] = lines[i]..suffix end if #prefix > 0 then - new_lines[1] = prefix..new_lines[1] + lines[i_0] = prefix..lines[i_0] end - local result = list_extend({}, lines, 1, i_0 - 1) - list_extend(result, new_lines) - list_extend(result, lines, i_n + 1) - return result + return lines end local function sort_by_key(fn) @@ -99,7 +112,7 @@ function M.apply_text_edits(text_edits, bufnr) 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) + M.set_lines(lines, A, B, e.lines) end if set_eol and #lines[#lines] == 0 then table.remove(lines) @@ -638,11 +651,12 @@ function M.try_trim_markdown_code_blocks(lines) return 'markdown' end +local str_utfindex = vim.str_utfindex function M.make_position_params() 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 = vim.str_utfindex(line, col) + col = str_utfindex(line, col) return { textDocument = { uri = vim.uri_from_bufnr(0) }; position = { line = row; character = col; } @@ -654,7 +668,9 @@ end -- @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] - return vim.str_utfindex(line, col) + -- TODO(ashkan) is there a better way to handle col being past line length? + -- If the col is past the EOL, use the line length. + return npcall(str_utfindex, line, col) or str_utfindex(line) end return M -- cgit From 73487f4130581da72c9e838189aab39c79c177c5 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Fri, 22 Nov 2019 00:31:10 -0800 Subject: Improve the character_offset code. --- runtime/lua/vim/lsp/util.lua | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 94b4223a20..e2a8748a20 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -11,14 +11,6 @@ end local list_extend = vim.list_extend -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 - --- Find the longest shared prefix between prefix and word. -- e.g. remove_prefix("123tes", "testing") == "ting" local function remove_prefix(prefix, word) @@ -668,9 +660,11 @@ end -- @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] - -- TODO(ashkan) is there a better way to handle col being past line length? -- If the col is past the EOL, use the line length. - return npcall(str_utfindex, line, col) or str_utfindex(line) + if col > #line then + return str_utfindex(line) + end + return str_utfindex(line, col) end return M -- cgit From d410812311f7b462420690455914ea1316953b3a Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Sat, 23 Nov 2019 16:14:24 -0800 Subject: UI tweaks. - Hide diagnostics on client exit - Stop insert on popup focus. - Hide popup on insertchar (for signature_help) --- runtime/lua/vim/lsp/util.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index e2a8748a20..0c53494f02 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -104,7 +104,7 @@ function M.apply_text_edits(text_edits, bufnr) local e = cleaned[i] local A = {e.A[1] - start_line, e.A[2]} local B = {e.B[1] - start_line, e.B[2]} - M.set_lines(lines, A, B, e.lines) + lines = M.set_lines(lines, A, B, e.lines) end if set_eol and #lines[#lines] == 0 then table.remove(lines) @@ -340,7 +340,8 @@ 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) - api.nvim_command("autocmd CursorMoved,BufHidden ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") + -- TODO make InsertCharPre disappearing optional? + api.nvim_command("autocmd CursorMoved,BufHidden,InsertCharPre ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") return floating_bufnr, floating_winnr end -- cgit From b35f6aa9dd686d082405132e3455cd0aff334361 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Sun, 24 Nov 2019 03:01:18 -0800 Subject: Add support for textDocument/references. Add set_qflist and set_loclist. - Also add locations_to_items, which calculates byte offsets for character positions in files and avoids unnecessary operations. --- runtime/lua/vim/lsp/util.lua | 80 +++++++++++++++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 19 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 0c53494f02..2dfcdfc70c 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -1,4 +1,5 @@ local protocol = require 'vim.lsp.protocol' +local vim = vim local validate = vim.validate local api = vim.api @@ -573,30 +574,71 @@ do end end -function M.buf_loclist(bufnr, locations) - local targetwin - for _, winnr in ipairs(api.nvim_list_wins()) do - local winbuf = api.nvim_win_get_buf(winnr) - if winbuf == bufnr then - targetwin = winnr - break - end - end - if not targetwin then return end +local position_sort = sort_by_key(function(v) + return {v.line, v.character} +end) +-- Returns the items with the byte position calculated correctly and in sorted +-- order. +function M.locations_to_items(locations) local items = {} - local path = api.nvim_buf_get_name(bufnr) + local grouped = setmetatable({}, { + __index = function(t, k) + local v = {} + rawset(t, k, v) + return v + end; + }) for _, d in ipairs(locations) do - -- TODO: URL parsing here? local start = d.range.start - table.insert(items, { - filename = path, - lnum = start.line + 1, - col = start.character + 1, - text = d.message, - }) + local fname = assert(vim.uri_to_fname(d.uri)) + table.insert(grouped[fname], 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 + local row = pos.line + if i == row then + local col + if pos.character > #line then + col = #line + else + col = vim.str_byteindex(line, pos.character) + end + table.insert(items, { + filename = fname, + lnum = row + 1, + col = col + 1; + }) + end + end + i = i + 1 + end end - vim.fn.setloclist(targetwin, items, ' ', 'Language Server') + return items +end + +-- locations is Location[] +-- Only sets for the current window. +function M.set_loclist(locations) + vim.fn.setloclist(0, {}, ' ', { + title = 'Language Server'; + items = M.locations_to_items(locations); + }) +end + +-- locations is Location[] +function M.set_qflist(locations) + vim.fn.setqflist({}, ' ', { + title = 'Language Server'; + items = M.locations_to_items(locations); + }) end -- Remove empty lines from the beginning and end. -- cgit From 6e8c5779cf960893850501e4871dc9be671db298 Mon Sep 17 00:00:00 2001 From: Ashkan Kiani Date: Tue, 26 Nov 2019 05:59:40 -0800 Subject: LSP: Move default buf callbacks to vim.lsp.callbacks (#11452) - In the process, refactored focusable_preview to a util function. - Add text for locations_to_items of the current line. - Improve location callback to handle multiple return values by using set_qflist. - Remove update_tagstack and leave note for future travelers. --- runtime/lua/vim/lsp/util.lua | 56 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 2dfcdfc70c..6e0d3fd4ee 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -2,6 +2,7 @@ local protocol = require 'vim.lsp.protocol' local vim = vim local validate = vim.validate local api = vim.api +local list_extend = vim.list_extend local M = {} @@ -10,7 +11,13 @@ local function split_lines(value) return split(value, '\n', true) end -local list_extend = vim.list_extend +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 --- Find the longest shared prefix between prefix and word. -- e.g. remove_prefix("123tes", "testing") == "ting" @@ -303,6 +310,50 @@ 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) + -- TODO(ashkan) use tagfunc here to update tagstack. + api.nvim_set_current_buf(bufnr) + local row = location.range.start.line + local col = location.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}) + return true +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 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) + 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 = M.open_floating_preview(fn()) + api.nvim_win_set_var(pwinnr, unique_name, bufnr) + return pbufnr, pwinnr +end + function M.open_floating_preview(contents, filetype, opts) validate { contents = { contents, 't' }; @@ -609,12 +660,13 @@ function M.locations_to_items(locations) if pos.character > #line then col = #line else - col = vim.str_byteindex(line, pos.character) + col = vim.str_byteindex(line, pos.character) end table.insert(items, { filename = fname, lnum = row + 1, col = col + 1; + text = line; }) end end -- cgit From 950690e48a6ff4788b3ad6c23caa99d253facd57 Mon Sep 17 00:00:00 2001 From: Björn Linse Date: Wed, 27 Nov 2019 19:45:03 +0100 Subject: lsp: allow the user to config LspDiagnosticError etc by standard means --- runtime/lua/vim/lsp/util.lua | 71 ++++++++++---------------------------------- 1 file changed, 15 insertions(+), 56 deletions(-) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 6e0d3fd4ee..b9990ed082 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -440,6 +440,11 @@ do local diagnostic_ns = api.nvim_create_namespace("vim_lsp_diagnostics") + local underline_highlight_name = "LspDiagnosticsUnderline" + api.nvim_command(string.format("highlight default %s gui=underline cterm=underline", underline_highlight_name)) + + local severity_highlights = {} + local default_severity_highlight = { [protocol.DiagnosticSeverity.Error] = { guifg = "Red" }; [protocol.DiagnosticSeverity.Warning] = { guifg = "Orange" }; @@ -447,60 +452,17 @@ do [protocol.DiagnosticSeverity.Hint] = { guifg = "LightGrey" }; } - local underline_highlight_name = "LspDiagnosticsUnderline" - api.nvim_command(string.format("highlight %s gui=underline cterm=underline", underline_highlight_name)) - - local function find_color_rgb(color) - local rgb_hex = api.nvim_get_color_by_name(color) - validate { color = {color, function() return rgb_hex ~= -1 end, "valid color name"} } - return rgb_hex - end - - --- Determine whether to use black or white text - -- Ref: https://stackoverflow.com/a/1855903/837964 - -- https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color - local function color_is_bright(r, g, b) - -- Counting the perceptive luminance - human eye favors green color - local luminance = (0.299*r + 0.587*g + 0.114*b)/255 - if luminance > 0.5 then - return true -- Bright colors, black font - else - return false -- Dark colors, white font - end - end - - local severity_highlights = {} - - function M.set_severity_highlights(highlights) - validate {highlights = {highlights, 't'}} - for severity, default_color in pairs(default_severity_highlight) do - local severity_name = protocol.DiagnosticSeverity[severity] - local highlight_name = "LspDiagnostics"..severity_name - local hi_info = highlights[severity] or default_color - -- Try to fill in the foreground color with a sane default. - if not hi_info.guifg and hi_info.guibg then - -- TODO(ashkan) move this out when bitop is guaranteed to be included. - local bit = require 'bit' - local band, rshift = bit.band, bit.rshift - local rgb = find_color_rgb(hi_info.guibg) - local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF)) - hi_info.guifg = is_bright and "Black" or "White" - end - if not hi_info.ctermfg and hi_info.ctermbg then - -- TODO(ashkan) move this out when bitop is guaranteed to be included. - local bit = require 'bit' - local band, rshift = bit.band, bit.rshift - local rgb = find_color_rgb(hi_info.ctermbg) - local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF)) - hi_info.ctermfg = is_bright and "Black" or "White" - end - local cmd_parts = {"highlight", highlight_name} - for k, v in pairs(hi_info) do - table.insert(cmd_parts, k.."="..v) - end - api.nvim_command(table.concat(cmd_parts, ' ')) - severity_highlights[severity] = highlight_name + -- 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 + -- 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, ' ')) + severity_highlights[severity] = highlight_name end function M.buf_clear_diagnostics(bufnr) @@ -509,9 +471,6 @@ do api.nvim_buf_clear_namespace(bufnr, diagnostic_ns, 0, -1) end - -- Initialize with the defaults. - M.set_severity_highlights(default_severity_highlight) - function M.get_severity_highlight_name(severity) return severity_highlights[severity] end -- cgit From 30ed245d008045a505a87ab21d22ada45dd1f4b5 Mon Sep 17 00:00:00 2001 From: Jakub Łuczyński Date: Sat, 7 Dec 2019 12:34:22 +0100 Subject: LSP: Add jump when calling gotodef (#11521) --- runtime/lua/vim/lsp/util.lua | 2 ++ 1 file changed, 2 insertions(+) (limited to 'runtime/lua/vim/lsp/util.lua') diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index b9990ed082..3798b30a46 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -313,6 +313,8 @@ end function M.jump_to_location(location) if location.uri == nil then return end local bufnr = vim.uri_to_bufnr(location.uri) + -- Save position in jumplist + vim.cmd "normal! m'" -- TODO(ashkan) use tagfunc here to update tagstack. api.nvim_set_current_buf(bufnr) local row = location.range.start.line -- cgit From d00c624ba4731ba7bfe9bd71e2cc32e54f886342 Mon Sep 17 00:00:00 2001 From: Mike Hartington Date: Fri, 20 Dec 2019 05:46:47 -0500 Subject: LSP: fix omnifunc findstart (#11522) --- runtime/lua/vim/l