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. -- -- {: {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 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(''), 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) api.nvim_buf_set_option(bufnr, 'modifiable', false) -- 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, ',').." ++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: --- ---
  --- 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()
  --- 
--- --@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: --- ---
  --- 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=
  --- 
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