diff options
Diffstat (limited to 'runtime/lua/vim/lsp/util.lua')
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 1101 |
1 files changed, 507 insertions, 594 deletions
diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 882ec22ca6..6eab0f3da4 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -2,12 +2,8 @@ local protocol = require('vim.lsp.protocol') local validate = vim.validate local api = vim.api local list_extend = vim.list_extend -local highlight = vim.highlight local uv = vim.uv -local npcall = vim.F.npcall -local split = vim.split - local M = {} local default_border = { @@ -21,82 +17,73 @@ local default_border = { { ' ', 'NormalFloat' }, } +--- @param border string|(string|[string,string])[] +local function border_error(border) + error( + string.format( + 'invalid floating preview border: %s. :help vim.api.nvim_open_win()', + vim.inspect(border) + ), + 2 + ) +end + +local border_size = { + none = { 0, 0 }, + single = { 2, 2 }, + double = { 2, 2 }, + rounded = { 2, 2 }, + solid = { 2, 2 }, + shadow = { 1, 1 }, +} + --- Check the border given by opts or the default border for the additional --- size it adds to a float. ----@param opts table optional options for the floating window ---- - border (string or table) the border ----@return table size of border in the form of { height = height, width = width } +--- @param opts? {border:string|(string|[string,string])[]} +--- @return integer height +--- @return integer width local function get_border_size(opts) local border = opts and opts.border or default_border - local height = 0 - local width = 0 if type(border) == 'string' then - local border_size = { - none = { 0, 0 }, - single = { 2, 2 }, - double = { 2, 2 }, - rounded = { 2, 2 }, - solid = { 2, 2 }, - shadow = { 1, 1 }, - } - if border_size[border] == nil then - error( - string.format( - 'invalid floating preview border: %s. :help vim.api.nvim_open_win()', - vim.inspect(border) - ) - ) + if not border_size[border] then + border_error(border) end - height, width = unpack(border_size[border]) - else - if 8 % #border ~= 0 then - error( - string.format( - 'invalid floating preview border: %s. :help vim.api.nvim_open_win()', - vim.inspect(border) - ) - ) - end - local function border_width(id) - id = (id - 1) % #border + 1 - if type(border[id]) == 'table' then - -- border specified as a table of <character, highlight group> - return vim.fn.strdisplaywidth(border[id][1]) - elseif type(border[id]) == 'string' then - -- border specified as a list of border characters - return vim.fn.strdisplaywidth(border[id]) - end - error( - string.format( - 'invalid floating preview border: %s. :help vim.api.nvim_open_win()', - vim.inspect(border) - ) - ) - end - local function border_height(id) - id = (id - 1) % #border + 1 - if type(border[id]) == 'table' then - -- border specified as a table of <character, highlight group> - return #border[id][1] > 0 and 1 or 0 - elseif type(border[id]) == 'string' then - -- border specified as a list of border characters - return #border[id] > 0 and 1 or 0 - end - error( - string.format( - 'invalid floating preview border: %s. :help vim.api.nvim_open_win()', - vim.inspect(border) - ) - ) + return unpack(border_size[border]) + end + + if 8 % #border ~= 0 then + border_error(border) + end + + --- @param id integer + --- @return string + local function elem(id) + id = (id - 1) % #border + 1 + local e = border[id] + if type(e) == 'table' then + -- border specified as a table of <character, highlight group> + return e[1] + elseif type(e) == 'string' then + -- border specified as a list of border characters + return e end - height = height + border_height(2) -- top - height = height + border_height(6) -- bottom - width = width + border_width(4) -- right - width = width + border_width(8) -- left + --- @diagnostic disable-next-line:missing-return + border_error(border) + end + + --- @param e string + local function border_height(e) + return #e > 0 and 1 or 0 end - return { height = height, width = width } + local top, bottom = elem(2), elem(6) + local height = border_height(top) + border_height(bottom) + + local right, left = elem(4), elem(8) + local width = vim.fn.strdisplaywidth(right) + vim.fn.strdisplaywidth(left) + + return height, width end --- Splits string at newlines, optionally removing unwanted blank lines. @@ -122,79 +109,13 @@ local function split_lines(s, no_blank) end local function create_window_without_focus() - local prev = vim.api.nvim_get_current_win() + local prev = api.nvim_get_current_win() vim.cmd.new() - local new = vim.api.nvim_get_current_win() - vim.api.nvim_set_current_win(prev) + local new = api.nvim_get_current_win() + api.nvim_set_current_win(prev) return new end ---- Convert byte index to `encoding` index. ---- Convenience wrapper around vim.str_utfindex ----@param line string line to be indexed ----@param index integer|nil byte index (utf-8), or `nil` for length ----@param encoding 'utf-8'|'utf-16'|'utf-32'|nil defaults to utf-16 ----@return integer `encoding` index of `index` in `line` -function M._str_utfindex_enc(line, index, encoding) - local len32, len16 = vim.str_utfindex(line) - if not encoding then - encoding = 'utf-16' - end - if encoding == 'utf-8' then - if index then - return index - else - return #line - end - elseif encoding == 'utf-16' then - if not index or index > len16 then - return len16 - end - local _, col16 = vim.str_utfindex(line, index) - return col16 - elseif encoding == 'utf-32' then - if not index or index > len32 then - return len32 - end - local col32, _ = vim.str_utfindex(line, index) - return col32 - else - error('Invalid encoding: ' .. vim.inspect(encoding)) - end -end - ---- Convert UTF index to `encoding` index. ---- Convenience wrapper around vim.str_byteindex ----Alternative to vim.str_byteindex that takes an encoding. ----@param line string line to be indexed ----@param index integer UTF index ----@param encoding string utf-8|utf-16|utf-32| defaults to utf-16 ----@return integer byte (utf-8) index of `encoding` index `index` in `line` -function M._str_byteindex_enc(line, index, encoding) - local len = #line - if index > len then - -- LSP spec: if character > line length, default to the line length. - -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position - return len - end - if not encoding then - encoding = 'utf-16' - end - if encoding == 'utf-8' then - if index then - return index - else - return len - end - elseif encoding == 'utf-16' then - return vim.str_byteindex(line, index, true) - elseif encoding == 'utf-32' then - return vim.str_byteindex(line, index) - else - error('Invalid encoding: ' .. vim.inspect(encoding)) - end -end - --- Replaces text in a range with new text. --- --- CAUTION: Changes in-place! @@ -243,6 +164,8 @@ function M.set_lines(lines, A, B, new_lines) return lines end +--- @param fn fun(x:any):any[] +--- @return function local function sort_by_key(fn) return function(a, b) local ka, kb = fn(a), fn(b) @@ -354,7 +277,7 @@ end --- Position is a https://microsoft.github.io/language-server-protocol/specifications/specification-current/#position ---@param position lsp.Position ----@param offset_encoding? string utf-8|utf-16|utf-32 +---@param offset_encoding 'utf-8'|'utf-16'|'utf-32' ---@return integer local function get_line_byte_from_position(bufnr, position, offset_encoding) -- LSP's line and characters are 0-indexed @@ -364,7 +287,7 @@ local function get_line_byte_from_position(bufnr, position, offset_encoding) -- character if col > 0 then local line = get_line(bufnr, position.line) or '' - return M._str_byteindex_enc(line, col, offset_encoding or 'utf-16') + return vim.str_byteindex(line, offset_encoding, col, false) end return col end @@ -372,14 +295,13 @@ end --- Applies a list of text edits to a buffer. ---@param text_edits lsp.TextEdit[] ---@param bufnr integer Buffer id ----@param offset_encoding string utf-8|utf-16|utf-32 +---@param offset_encoding 'utf-8'|'utf-16'|'utf-32' ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit function M.apply_text_edits(text_edits, bufnr, offset_encoding) - validate({ - text_edits = { text_edits, 't', false }, - bufnr = { bufnr, 'number', false }, - offset_encoding = { offset_encoding, 'string', false }, - }) + validate('text_edits', text_edits, 'table', false) + validate('bufnr', bufnr, 'number', false) + validate('offset_encoding', offset_encoding, 'string', false) + if not next(text_edits) then return end @@ -392,10 +314,8 @@ function M.apply_text_edits(text_edits, bufnr, offset_encoding) vim.bo[bufnr].buflisted = true -- Fix reversed range and indexing each text_edits - local index = 0 - --- @param text_edit lsp.TextEdit - text_edits = vim.tbl_map(function(text_edit) - index = index + 1 + for index, text_edit in ipairs(text_edits) do + --- @cast text_edit lsp.TextEdit|{_index: integer} text_edit._index = index if @@ -407,8 +327,7 @@ function M.apply_text_edits(text_edits, bufnr, offset_encoding) text_edit.range.start = text_edit.range['end'] text_edit.range['end'] = start end - return text_edit - end, text_edits) + end -- Sort text_edits ---@param a lsp.TextEdit | { _index: integer } @@ -439,47 +358,45 @@ function M.apply_text_edits(text_edits, bufnr, offset_encoding) text_edit.newText, _ = string.gsub(text_edit.newText, '\r\n?', '\n') -- Convert from LSP style ranges to Neovim style ranges. - local e = { - start_row = text_edit.range.start.line, - start_col = get_line_byte_from_position(bufnr, text_edit.range.start, offset_encoding), - end_row = text_edit.range['end'].line, - end_col = get_line_byte_from_position(bufnr, text_edit.range['end'], offset_encoding), - text = split(text_edit.newText, '\n', { plain = true }), - } + local start_row = text_edit.range.start.line + local start_col = get_line_byte_from_position(bufnr, text_edit.range.start, offset_encoding) + local end_row = text_edit.range['end'].line + local end_col = get_line_byte_from_position(bufnr, text_edit.range['end'], offset_encoding) + local text = vim.split(text_edit.newText, '\n', { plain = true }) local max = api.nvim_buf_line_count(bufnr) -- If the whole edit is after the lines in the buffer we can simply add the new text to the end -- of the buffer. - if max <= e.start_row then - api.nvim_buf_set_lines(bufnr, max, max, false, e.text) + if max <= start_row then + api.nvim_buf_set_lines(bufnr, max, max, false, text) else - local last_line_len = #(get_line(bufnr, math.min(e.end_row, max - 1)) or '') + local last_line_len = #(get_line(bufnr, math.min(end_row, max - 1)) or '') -- Some LSP servers may return +1 range of the buffer content but nvim_buf_set_text can't -- accept it so we should fix it here. - if max <= e.end_row then - e.end_row = max - 1 - e.end_col = last_line_len + if max <= end_row then + end_row = max - 1 + end_col = last_line_len has_eol_text_edit = true else - -- If the replacement is over the end of a line (i.e. e.end_col is equal to the line length and the + -- If the replacement is over the end of a line (i.e. end_col is equal to the line length and the -- replacement text ends with a newline We can likely assume that the replacement is assumed -- to be meant to replace the newline with another newline and we need to make sure this -- doesn't add an extra empty line. E.g. when the last line to be replaced contains a '\r' -- in the file some servers (clangd on windows) will include that character in the line -- while nvim_buf_set_text doesn't count it as part of the line. if - e.end_col >= last_line_len - and text_edit.range['end'].character > e.end_col + end_col >= last_line_len + and text_edit.range['end'].character > end_col and #text_edit.newText > 0 and string.sub(text_edit.newText, -1) == '\n' then - table.remove(e.text, #e.text) + table.remove(text, #text) end end - -- Make sure we don't go out of bounds for e.end_col - e.end_col = math.min(last_line_len, e.end_col) + -- Make sure we don't go out of bounds for end_col + end_col = math.min(last_line_len, end_col) - api.nvim_buf_set_text(bufnr, e.start_row, e.start_col, e.end_row, e.end_col, e.text) + api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, text) end end @@ -495,7 +412,7 @@ function M.apply_text_edits(text_edits, bufnr, offset_encoding) -- make sure we don't go out of bounds pos[1] = math.min(pos[1], max) pos[2] = math.min(pos[2], #(get_line(bufnr, pos[1] - 1) or '')) - vim.api.nvim_buf_set_mark(bufnr or 0, mark, pos[1], pos[2], {}) + api.nvim_buf_set_mark(bufnr or 0, mark, pos[1], pos[2], {}) end end @@ -513,7 +430,7 @@ end --- ---@param text_document_edit lsp.TextDocumentEdit ---@param index? integer: Optional index of the edit, if from a list of edits (or nil, if not from a list) ----@param offset_encoding? string +---@param offset_encoding? 'utf-8'|'utf-16'|'utf-32' ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit function M.apply_text_document_edit(text_document_edit, index, offset_encoding) local text_document = text_document_edit.textDocument @@ -523,19 +440,15 @@ function M.apply_text_document_edit(text_document_edit, index, offset_encoding) 'apply_text_document_edit must be called with valid offset encoding', vim.log.levels.WARN ) - end - - -- For lists of text document edits, - -- do not check the version after the first edit. - local should_check_version = true - if index and index > 1 then - should_check_version = false + return end -- `VersionedTextDocumentIdentifier`s version may be null -- https://microsoft.github.io/language-server-protocol/specification#versionedTextDocumentIdentifier if - should_check_version + -- For lists of text document edits, + -- do not check the version after the first edit. + not (index and index > 1) and ( text_document.version and text_document.version > 0 @@ -553,6 +466,9 @@ local function path_components(path) return vim.split(path, '/', { plain = true }) end +--- @param path string[] +--- @param prefix string[] +--- @return boolean local function path_under_prefix(path, prefix) for i, c in ipairs(prefix) do if c ~= path[i] then @@ -562,17 +478,24 @@ local function path_under_prefix(path, prefix) return true end ---- Get list of buffers whose filename matches the given path prefix (normalized full path) +--- Get list of loaded writable buffers whose filename matches the given path +--- prefix (normalized full path). ---@param prefix string ---@return integer[] -local function get_bufs_with_prefix(prefix) - prefix = path_components(prefix) - local buffers = {} - for _, v in ipairs(vim.api.nvim_list_bufs()) do - local bname = vim.api.nvim_buf_get_name(v) - local path = path_components(vim.fs.normalize(bname, { expand_env = false })) - if path_under_prefix(path, prefix) then - table.insert(buffers, v) +local function get_writable_bufs(prefix) + local prefix_parts = path_components(prefix) + local buffers = {} --- @type integer[] + for _, buf in ipairs(api.nvim_list_bufs()) do + -- No need to care about unloaded or nofile buffers. Also :saveas won't work for them. + if + api.nvim_buf_is_loaded(buf) + and not vim.list_contains({ 'nofile', 'nowrite' }, vim.bo[buf].buftype) + then + local bname = api.nvim_buf_get_name(buf) + local path = path_components(vim.fs.normalize(bname, { expand_env = false })) + if path_under_prefix(path, prefix_parts) then + buffers[#buffers + 1] = buf + end end end return buffers @@ -616,19 +539,13 @@ function M.rename(old_fname, new_fname, opts) local buf_rename = {} ---@type table<integer, {from: string, to: string}> local old_fname_pat = '^' .. vim.pesc(old_fname_full) - for b in - vim.iter(get_bufs_with_prefix(old_fname_full)):filter(function(b) - -- No need to care about unloaded or nofile buffers. Also :saveas won't work for them. - return api.nvim_buf_is_loaded(b) - and not vim.list_contains({ 'nofile', 'nowrite' }, vim.bo[b].buftype) - end) - do + for _, b in ipairs(get_writable_bufs(old_fname_full)) do -- Renaming a buffer may conflict with another buffer that happens to have the same name. In -- most cases, this would have been already detected by the file conflict check above, but the -- conflicting buffer may not be associated with a file. For example, 'buftype' can be "nofile" -- or "nowrite", or the buffer can be a normal buffer but has not been written to the file yet. -- Renaming should fail in such cases to avoid losing the contents of the conflicting buffer. - local old_bname = vim.api.nvim_buf_get_name(b) + local old_bname = api.nvim_buf_get_name(b) local new_bname = old_bname:gsub(old_fname_pat, escape_gsub_repl(new_fname)) if vim.fn.bufexists(new_bname) == 1 then local existing_buf = vim.fn.bufnr(new_bname) @@ -702,7 +619,7 @@ end --- Applies a `WorkspaceEdit`. --- ---@param workspace_edit lsp.WorkspaceEdit ----@param offset_encoding string utf-8|utf-16|utf-32 (required) +---@param offset_encoding 'utf-8'|'utf-16'|'utf-32' (required) ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit function M.apply_workspace_edit(workspace_edit, offset_encoding) if offset_encoding == nil then @@ -710,16 +627,18 @@ function M.apply_workspace_edit(workspace_edit, offset_encoding) 'apply_workspace_edit must be called with valid offset encoding', vim.log.levels.WARN ) + return end if workspace_edit.documentChanges then for idx, change in ipairs(workspace_edit.documentChanges) do if change.kind == 'rename' then - M.rename(vim.uri_to_fname(change.oldUri), vim.uri_to_fname(change.newUri), change.options) + local options = change.options --[[@as vim.lsp.util.rename.Opts]] + M.rename(vim.uri_to_fname(change.oldUri), vim.uri_to_fname(change.newUri), options) elseif change.kind == 'create' then create_file(change) elseif change.kind == 'delete' then delete_file(change) - elseif change.kind then + elseif change.kind then --- @diagnostic disable-line:undefined-field error(string.format('Unsupported change: %q', vim.inspect(change))) else M.apply_text_document_edit(change, idx, offset_encoding) @@ -748,7 +667,7 @@ end --- then the corresponding value is returned without further modifications. --- ---@param input lsp.MarkedString|lsp.MarkedString[]|lsp.MarkupContent ----@param contents string[]|nil List of strings to extend with converted lines. Defaults to {}. +---@param contents string[]? List of strings to extend with converted lines. Defaults to {}. ---@return string[] extended with lines of converted markdown. ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover function M.convert_input_to_markdown_lines(input, contents) @@ -781,111 +700,117 @@ function M.convert_input_to_markdown_lines(input, contents) return contents end +--- Returns the line/column-based position in `contents` at the given offset. +--- +---@param offset integer +---@param contents string[] +---@return { [1]: integer, [2]: integer }? +local function get_pos_from_offset(offset, contents) + local i = 0 + for l, line in ipairs(contents) do + if offset >= i and offset < i + #line then + return { l - 1, offset - i + 1 } + else + i = i + #line + 1 + end + end +end + --- Converts `textDocument/signatureHelp` response to markdown lines. --- ---@param signature_help lsp.SignatureHelp Response of `textDocument/SignatureHelp` ----@param ft string|nil filetype that will be use as the `lang` for the label markdown code block ----@param triggers table|nil list of trigger characters from the lsp server. used to better determine parameter offsets ----@return string[]|nil table list of lines of converted markdown. ----@return number[]|nil table of active hl +---@param ft string? filetype that will be use as the `lang` for the label markdown code block +---@param triggers string[]? list of trigger characters from the lsp server. used to better determine parameter offsets +---@return string[]? # lines of converted markdown. +---@return Range4? # highlight range for the active parameter ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp function M.convert_signature_help_to_markdown_lines(signature_help, ft, triggers) - if not signature_help.signatures then - return - end --The active signature. If omitted or the value lies outside the range of --`signatures` the value defaults to zero or is ignored if `signatures.length == 0`. --Whenever possible implementors should make an active decision about --the active signature and shouldn't rely on a default value. - local contents = {} - local active_hl + local contents = {} --- @type string[] + local active_offset ---@type [integer, integer]? local active_signature = signature_help.activeSignature or 0 -- If the activeSignature is not inside the valid range, then clip it. -- In 3.15 of the protocol, activeSignature was allowed to be negative if active_signature >= #signature_help.signatures or active_signature < 0 then active_signature = 0 end - local signature = signature_help.signatures[active_signature + 1] - if not signature then - return - end + local signature = vim.deepcopy(signature_help.signatures[active_signature + 1]) local label = signature.label if ft then -- wrap inside a code block for proper rendering label = ('```%s\n%s\n```'):format(ft, label) end - list_extend(contents, split(label, '\n', { plain = true, trimempty = true })) - if signature.documentation then + list_extend(contents, vim.split(label, '\n', { plain = true, trimempty = true })) + local doc = signature.documentation + if doc then -- if LSP returns plain string, we treat it as plaintext. This avoids -- special characters like underscore or similar from being interpreted -- as markdown font modifiers - if type(signature.documentation) == 'string' then - signature.documentation = { kind = 'plaintext', value = signature.documentation } + if type(doc) == 'string' then + signature.documentation = { kind = 'plaintext', value = doc } end M.convert_input_to_markdown_lines(signature.documentation, contents) end if signature.parameters and #signature.parameters > 0 then - local active_parameter = (signature.activeParameter or signature_help.activeParameter or 0) - if active_parameter < 0 then - active_parameter = 0 - end + -- First check if the signature has an activeParameter. If it doesn't check if the response + -- had that property instead. Else just default to 0. + local active_parameter = + math.max(signature.activeParameter or signature_help.activeParameter or 0, 0) -- If the activeParameter is > #parameters, then set it to the last -- NOTE: this is not fully according to the spec, but a client-side interpretation - if active_parameter >= #signature.parameters then - active_parameter = #signature.parameters - 1 - end + active_parameter = math.min(active_parameter, #signature.parameters - 1) local parameter = signature.parameters[active_parameter + 1] - if parameter then - --[=[ - --Represents a parameter of a callable-signature. A parameter can - --have a label and a doc-comment. - interface ParameterInformation { - --The label of this parameter information. - -- - --Either a string or an inclusive start and exclusive end offsets within its containing - --signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 - --string representation as `Position` and `Range` does. - -- - --*Note*: a label of type string should be a substring of its containing signature label. - --Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. - label: string | [number, number]; - --The human-readable doc-comment of this parameter. Will be shown - --in the UI but can be omitted. - documentation?: string | MarkupContent; - } - --]=] - if parameter.label then - if type(parameter.label) == 'table' then - active_hl = parameter.label - else - local offset = 1 - -- try to set the initial offset to the first found trigger character - for _, t in ipairs(triggers or {}) do - local trigger_offset = signature.label:find(t, 1, true) - if trigger_offset and (offset == 1 or trigger_offset < offset) then - offset = trigger_offset - end - end - for p, param in pairs(signature.parameters) do - offset = signature.label:find(param.label, offset, true) - if not offset then - break - end - if p == active_parameter + 1 then - active_hl = { offset - 1, offset + #parameter.label - 1 } - break - end - offset = offset + #param.label + 1 - end + local parameter_label = parameter.label + if type(parameter_label) == 'table' then + active_offset = parameter_label + else + local offset = 1 ---@type integer? + -- try to set the initial offset to the first found trigger character + for _, t in ipairs(triggers or {}) do + local trigger_offset = signature.label:find(t, 1, true) + if trigger_offset and (offset == 1 or trigger_offset < offset) then + offset = trigger_offset end end - if parameter.documentation then - M.convert_input_to_markdown_lines(parameter.documentation, contents) + for p, param in pairs(signature.parameters) do + local plabel = param.label + assert(type(plabel) == 'string', 'Expected label to be a string') + offset = signature.label:find(plabel, offset, true) + if not offset then + break + end + if p == active_parameter + 1 then + active_offset = { offset - 1, offset + #parameter_label - 1 } + break + end + offset = offset + #param.label + 1 end end + if parameter.documentation then + M.convert_input_to_markdown_lines(parameter.documentation, contents) + end end + + local active_hl = nil + if active_offset then + -- Account for the start of the markdown block. + if ft then + active_offset[1] = active_offset[1] + #contents[1] + active_offset[2] = active_offset[2] + #contents[1] + end + + local a_start = get_pos_from_offset(active_offset[1], contents) + local a_end = get_pos_from_offset(active_offset[2], contents) + if a_start and a_end then + active_hl = { a_start[1], a_start[2], a_end[1], a_end[2] } + end + end + return contents, active_hl end @@ -894,32 +819,15 @@ end --- ---@param width integer window width (in character cells) ---@param height integer window height (in character cells) ----@param opts table optional ---- - offset_x (integer) offset to add to `col` ---- - offset_y (integer) offset to add to `row` ---- - border (string or table) override `border` ---- - focusable (string or table) override `focusable` ---- - zindex (string or table) override `zindex`, defaults to 50 ---- - relative ("mouse"|"cursor") defaults to "cursor" ---- - anchor_bias ("auto"|"above"|"below") defaults to "auto" ---- - "auto": place window based on which side of the cursor has more lines ---- - "above": place the window above the cursor unless there are not enough lines ---- to display the full window height. ---- - "below": place the window below the cursor unless there are not enough lines ---- to display the full window height. ----@return table Options +---@param opts? vim.lsp.util.open_floating_preview.Opts +---@return vim.api.keyset.win_config function M.make_floating_popup_options(width, height, opts) - validate({ - opts = { opts, 't', true }, - }) + validate('opts', opts, 'table', true) opts = opts or {} - validate({ - ['opts.offset_x'] = { opts.offset_x, 'n', true }, - ['opts.offset_y'] = { opts.offset_y, 'n', true }, - }) + validate('opts.offset_x', opts.offset_x, 'number', true) + validate('opts.offset_y', opts.offset_y, 'number', true) local anchor = '' - local row, col local lines_above = opts.relative == 'mouse' and vim.fn.getmousepos().line - 1 or vim.fn.winline() - 1 @@ -927,7 +835,7 @@ function M.make_floating_popup_options(width, height, opts) local anchor_bias = opts.anchor_bias or 'auto' - local anchor_below + local anchor_below --- @type boolean? if anchor_bias == 'below' then anchor_below = (lines_below > lines_above) or (height <= lines_below) @@ -938,7 +846,8 @@ function M.make_floating_popup_options(width, height, opts) anchor_below = lines_below > lines_above end - local border_height = get_border_size(opts).height + local border_height = get_border_size(opts) + local row, col --- @type integer?, integer? if anchor_below then anchor = anchor .. 'N' height = math.max(math.min(lines_below - border_height, height), 0) @@ -960,7 +869,7 @@ function M.make_floating_popup_options(width, height, opts) end local title = (opts.border and opts.title) and opts.title or nil - local title_pos + local title_pos --- @type 'left'|'center'|'right'? if title then title_pos = opts.title_pos or 'center' @@ -982,13 +891,21 @@ function M.make_floating_popup_options(width, height, opts) } end +--- @class vim.lsp.util.show_document.Opts +--- @inlinedoc +--- +--- Jump to existing window if buffer is already open. +--- @field reuse_win? boolean +--- +--- Whether to focus/jump to location if possible. +--- (defaults: true) +--- @field focus? boolean + --- Shows document and optionally jumps to the location. --- ---@param location lsp.Location|lsp.LocationLink ----@param offset_encoding string|nil utf-8|utf-16|utf-32 ----@param opts table|nil options ---- - reuse_win (boolean) Jump to existing window if buffer is already open. ---- - focus (boolean) Whether to focus/jump to location if possible. Defaults to true. +---@param offset_encoding 'utf-8'|'utf-16'|'utf-32'? +---@param opts? vim.lsp.util.show_document.Opts ---@return boolean `true` if succeeded function M.show_document(location, offset_encoding, opts) -- location may be Location or LocationLink @@ -998,6 +915,7 @@ function M.show_document(location, offset_encoding, opts) end if offset_encoding == nil then vim.notify_once('show_document must be called with valid offset encoding', vim.log.levels.WARN) + return false end local bufnr = vim.uri_to_bufnr(uri) @@ -1041,18 +959,13 @@ end --- Jumps to a location. --- +---@deprecated use `vim.lsp.util.show_document` with `{focus=true}` instead ---@param location lsp.Location|lsp.LocationLink ----@param offset_encoding string|nil utf-8|utf-16|utf-32 ----@param reuse_win boolean|nil Jump to existing window if buffer is already open. +---@param offset_encoding 'utf-8'|'utf-16'|'utf-32'? +---@param reuse_win boolean? Jump to existing window if buffer is already open. ---@return boolean `true` if the jump succeeded function M.jump_to_location(location, offset_encoding, reuse_win) - if offset_encoding == nil then - vim.notify_once( - 'jump_to_location must be called with valid offset encoding', - vim.log.levels.WARN - ) - end - + vim.deprecate('vim.lsp.util.jump_to_location', nil, '0.12') return M.show_document(location, offset_encoding, { reuse_win = reuse_win, focus = true }) end @@ -1063,9 +976,9 @@ end --- - for LocationLink, targetRange is shown (e.g., body of function definition) --- ---@param location lsp.Location|lsp.LocationLink ----@param opts table ----@return integer|nil buffer id of float window ----@return integer|nil window id of float window +---@param opts? vim.lsp.util.open_floating_preview.Opts +---@return integer? buffer id of float window +---@return integer? window id of float window function M.preview_location(location, opts) -- location may be LocationLink or Location (more useful for the former) local uri = location.targetUri or location.uri @@ -1092,7 +1005,7 @@ 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 + if vim.w[win][name] == value then return win end end @@ -1158,8 +1071,10 @@ local function collapse_blank_lines(contents) end local function get_markdown_fences() - local fences = {} - for _, fence in pairs(vim.g.markdown_fenced_languages or {}) do + local fences = {} --- @type table<string,string> + for _, fence in + pairs(vim.g.markdown_fenced_languages or {} --[[@as string[] ]]) + do local lang, syntax = fence:match('^(.*)=(.*)$') if lang then fences[lang] = syntax @@ -1179,7 +1094,7 @@ end --- ---@param bufnr integer ---@param contents string[] of lines to show in window ----@param opts table with optional fields +---@param opts? table with optional fields --- - height of floating window --- - width of floating window --- - wrap_at character to wrap at for computing height @@ -1188,10 +1103,8 @@ end --- - separator insert separator after code block ---@return table stripped content function M.stylize_markdown(bufnr, contents, opts) - validate({ - contents = { contents, 't' }, - opts = { opts, 't', true }, - }) + validate('contents', contents, 'table') + validate('opts', opts, 'table', true) opts = opts or {} -- table of fence types to {ft, begin, end} @@ -1203,8 +1116,11 @@ function M.stylize_markdown(bufnr, contents, opts) text = { 'text', '<text>', '</text>' }, } - local match_begin = function(line) + --- @param line string + --- @return {type:string,ft:string}? + local function match_begin(line) for type, pattern in pairs(matchers) do + --- @type string? local ret = line:match(string.format('^%%s*%s%%s*$', pattern[2])) if ret then return { @@ -1215,7 +1131,10 @@ function M.stylize_markdown(bufnr, contents, opts) end end - local match_end = function(line, match) + --- @param line string + --- @param match {type:string,ft:string} + --- @return string + local function match_end(line, match) local pattern = matchers[match.type] return line:match(string.format('^%%s*%s%%s*$', pattern[3])) end @@ -1224,76 +1143,80 @@ function M.stylize_markdown(bufnr, contents, opts) contents = vim.split(table.concat(contents, '\n'), '\n', { trimempty = true }) local stripped = {} - local highlights = {} + local highlights = {} --- @type {ft:string,start:integer,finish:integer}[] -- keep track of lnums that contain markdown - local markdown_lines = {} - do - local i = 1 - while i <= #contents do - local line = contents[i] - local match = match_begin(line) - if match then - local start = #stripped - i = i + 1 - while i <= #contents do - line = contents[i] - if match_end(line, match) then - i = i + 1 - break - end - table.insert(stripped, line) + local markdown_lines = {} --- @type table<integer,boolean> + + local i = 1 + while i <= #contents do + local line = contents[i] + local match = match_begin(line) + if match then + local start = #stripped + i = i + 1 + while i <= #contents do + line = contents[i] + if match_end(line, match) then i = i + 1 + break end - table.insert(highlights, { - ft = match.ft, - start = start + 1, - finish = #stripped, - }) - -- add a separator, but not on the last line - if opts.separator and i < #contents then - table.insert(stripped, '---') - markdown_lines[#stripped] = true - end - else - -- strip any empty lines or separators prior to this separator in actual markdown - if line:match('^---+$') then - while - markdown_lines[#stripped] - and (stripped[#stripped]:match('^%s*$') or stripped[#stripped]:match('^---+$')) - do - markdown_lines[#stripped] = false - table.remove(stripped, #stripped) - end - end - -- add the line if its not an empty line following a separator - if - not ( - line:match('^%s*$') - and markdown_lines[#stripped] - and stripped[#stripped]:match('^---+$') - ) - then - table.insert(stripped, line) - markdown_lines[#stripped] = true - end + table.insert(stripped, line) i = i + 1 end + table.insert(highlights, { + ft = match.ft, + start = start + 1, + finish = #stripped, + }) + -- add a separator, but not on the last line + if opts.separator and i < #contents then + table.insert(stripped, '---') + markdown_lines[#stripped] = true + end + else + -- strip any empty lines or separators prior to this separator in actual markdown + if line:match('^---+$') then + while + markdown_lines[#stripped] + and (stripped[#stripped]:match('^%s*$') or stripped[#stripped]:match('^---+$')) + do + markdown_lines[#stripped] = false + table.remove(stripped, #stripped) + end + end + -- add the line if its not an empty line following a separator + if + not ( + line:match('^%s*$') + and markdown_lines[#stripped] + and stripped[#stripped]:match('^---+$') + ) + then + table.insert(stripped, line) + markdown_lines[#stripped] = true + end + i = i + 1 end end -- Handle some common html escape sequences - stripped = vim.tbl_map(function(line) - local escapes = { - ['>'] = '>', - ['<'] = '<', - ['"'] = '"', - ['''] = "'", - [' '] = ' ', - [' '] = ' ', - ['&'] = '&', - } - return (string.gsub(line, '&[^ ;]+;', escapes)) - end, stripped) + --- @type string[] + stripped = vim.tbl_map( + --- @param line string + function(line) + local escapes = { + ['>'] = '>', + ['<'] = '<', + ['"'] = '"', + ['''] = "'", + [' '] = ' ', + [' '] = ' ', + ['&'] = '&', + } + return (line:gsub('&[^ ;]+;', escapes)) + end, + stripped + ) -- Compute size of float needed to show (wrapped) lines opts.wrap_at = opts.wrap_at or (vim.wo['wrap'] and api.nvim_win_get_width(0)) @@ -1312,7 +1235,7 @@ function M.stylize_markdown(bufnr, contents, opts) local idx = 1 -- keep track of syntaxes we already included. -- no need to include the same syntax more than once - local langs = {} + local langs = {} --- @type table<string,boolean> local fences = get_markdown_fences() local function apply_syntax_to_region(ft, start, finish) if ft == '' then @@ -1335,6 +1258,7 @@ function M.stylize_markdown(bufnr, contents, opts) if #api.nvim_get_runtime_file(('syntax/%s.vim'):format(ft), true) == 0 then return end + --- @diagnostic disable-next-line:param-type-mismatch pcall(vim.cmd, string.format('syntax include %s syntax/%s.vim', lang, ft)) langs[lang] = true end @@ -1390,10 +1314,8 @@ end ---@return string[] table of lines containing normalized Markdown ---@see https://github.github.com/gfm function M._normalize_markdown(contents, opts) - validate({ - contents = { contents, 't' }, - opts = { opts, 't', true }, - }) + validate('contents', contents, 'table') + validate('opts', opts, 'table', true) opts = opts or {} -- 1. Carriage returns are removed @@ -1412,7 +1334,7 @@ end --- Closes the preview window --- ---@param winnr integer window id of preview window ----@param bufnrs table|nil optional list of ignored buffers +---@param bufnrs table? optional list of ignored buffers local function close_preview_window(winnr, bufnrs) vim.schedule(function() -- exit if we are in one of ignored buffers @@ -1460,20 +1382,13 @@ end ---@private --- Computes size of float needed to show contents (with optional wrapping) --- ----@param contents table of lines to show in window ----@param opts? table with optional fields ---- - height of floating window ---- - width of floating window ---- - wrap_at character to wrap at for computing height ---- - max_width maximal width of floating window ---- - max_height maximal height of floating window +---@param contents string[] of lines to show in window +---@param opts? vim.lsp.util.open_floating_preview.Opts ---@return integer width size of float ---@return integer height size of float function M._make_floating_popup_size(contents, opts) - validate({ - contents = { contents, 't' }, - opts = { opts, 't', true }, - }) + validate('contents', contents, 'table') + validate('opts', opts, 'table', true) opts = opts or {} local width = opts.width @@ -1481,7 +1396,7 @@ function M._make_floating_popup_size(contents, opts) local wrap_at = opts.wrap_at local max_width = opts.max_width local max_height = opts.max_height - local line_widths = {} + local line_widths = {} --- @type table<integer,integer> if not width then width = 0 @@ -1492,17 +1407,15 @@ function M._make_floating_popup_size(contents, opts) end end - local border_width = get_border_size(opts).width + local _, border_width = get_border_size(opts) local screen_width = api.nvim_win_get_width(0) width = math.min(width, screen_width) -- make sure borders are always inside the screen - if width + border_width > screen_width then - width = width - (width + border_width - screen_width) - end + width = math.min(width, screen_width - border_width) - if wrap_at and wrap_at > width then - wrap_at = width + if wrap_at then + wrap_at = math.min(wrap_at, width) end if max_width then @@ -1534,7 +1447,6 @@ function M._make_floating_popup_size(contents, opts) end --- @class vim.lsp.util.open_floating_preview.Opts ---- @inlinedoc --- --- Height of floating window --- @field height? integer @@ -1569,6 +1481,29 @@ end --- window with the same {focus_id} --- (default: `true`) --- @field focus? boolean +--- +--- offset to add to `col` +--- @field offset_x? integer +--- +--- offset to add to `row` +--- @field offset_y? integer +--- @field border? string|(string|[string,string])[] override `border` +--- @field zindex? integer override `zindex`, defaults to 50 +--- @field title? string +--- @field title_pos? 'left'|'center'|'right' +--- +--- (default: `'cursor'`) +--- @field relative? 'mouse'|'cursor' +--- +--- - "auto": place window based on which side of the cursor has more lines +--- - "above": place the window above the cursor unless there are not enough lines +--- to display the full window height. +--- - "below": place the window below the cursor unless there are not enough lines +--- to display the full window height. +--- (default: `'auto'`) +--- @field anchor_bias? 'auto'|'above'|'below' +--- +--- @field _update_win? integer --- Shows contents in a floating window. --- @@ -1580,11 +1515,9 @@ end ---@return integer bufnr of newly created float window ---@return integer winid of newly created float window preview window function M.open_floating_preview(contents, syntax, opts) - validate({ - contents = { contents, 't' }, - syntax = { syntax, 's', true }, - opts = { opts, 't', true }, - }) + validate('contents', contents, 'table') + validate('syntax', syntax, 'string', true) + validate('opts', opts, 'table', true) opts = opts or {} opts.wrap = opts.wrap ~= false -- wrapping by default opts.focus = opts.focus ~= false @@ -1592,43 +1525,49 @@ function M.open_floating_preview(contents, syntax, opts) local bufnr = api.nvim_get_current_buf() - -- check if this popup is focusable and we need to focus - if opts.focus_id and opts.focusable ~= false and opts.focus then - -- Go back to previous window if we are in a focusable one - local current_winnr = api.nvim_get_current_win() - if npcall(api.nvim_win_get_var, current_winnr, opts.focus_id) then - api.nvim_command('wincmd p') - return bufnr, current_winnr - end - do - local win = find_window_by_var(opts.focus_id, bufnr) - if win and api.nvim_win_is_valid(win) and vim.fn.pumvisible() == 0 then - -- focus and return the existing buf, win - api.nvim_set_current_win(win) - api.nvim_command('stopinsert') - return api.nvim_win_get_buf(win), win + local floating_winnr = opts._update_win + + -- Create/get the buffer + local floating_bufnr --- @type integer + if floating_winnr then + floating_bufnr = api.nvim_win_get_buf(floating_winnr) + else + -- check if this popup is focusable and we need to focus + if opts.focus_id and opts.focusable ~= false and opts.focus then + -- Go back to previous window if we are in a focusable one + local current_winnr = api.nvim_get_current_win() + if vim.w[current_winnr][opts.focus_id] then + api.nvim_command('wincmd p') + return bufnr, current_winnr + end + do + local win = find_window_by_var(opts.focus_id, bufnr) + if win and api.nvim_win_is_valid(win) and vim.fn.pumvisible() == 0 then + -- focus and return the existing buf, win + api.nvim_set_current_win(win) + api.nvim_command('stopinsert') + return api.nvim_win_get_buf(win), win + end end end - end - -- check if another floating preview already exists for this buffer - -- and close it if needed - local existing_float = npcall(api.nvim_buf_get_var, bufnr, 'lsp_floating_preview') - if existing_float and api.nvim_win_is_valid(existing_float) then - api.nvim_win_close(existing_float, true) + -- check if another floating preview already exists for this buffer + -- and close it if needed + local existing_float = vim.b[bufnr].lsp_floating_preview + if existing_float and api.nvim_win_is_valid(existing_float) then + api.nvim_win_close(existing_float, true) + end + floating_bufnr = api.nvim_create_buf(false, true) end - -- Create the buffer - local floating_bufnr = api.nvim_create_buf(false, true) - -- Set up the contents, using treesitter for markdown local do_stylize = syntax == 'markdown' and vim.g.syntax_on ~= nil + if do_stylize then local width = M._make_floating_popup_size(contents, opts) contents = M._normalize_markdown(contents, { width = width }) vim.bo[floating_bufnr].filetype = 'markdown' vim.treesitter.start(floating_bufnr) - api.nvim_buf_set_lines(floating_bufnr, 0, -1, false, contents) else -- Clean up input: trim empty lines contents = vim.split(table.concat(contents, '\n'), '\n', { trimempty = true }) @@ -1636,19 +1575,47 @@ function M.open_floating_preview(contents, syntax, opts) if syntax then vim.bo[floating_bufnr].syntax = syntax end - api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents) end - -- Compute size of float needed to show (wrapped) lines - if opts.wrap then - opts.wrap_at = opts.wrap_at or api.nvim_win_get_width(0) + vim.bo[floating_bufnr].modifiable = true + api.nvim_buf_set_lines(floating_bufnr, 0, -1, false, contents) + + if floating_winnr then + api.nvim_win_set_config(floating_winnr, { + border = opts.border, + title = opts.title, + }) else - opts.wrap_at = nil - end - local width, height = M._make_floating_popup_size(contents, opts) + -- Compute size of float needed to show (wrapped) lines + if opts.wrap then + opts.wrap_at = opts.wrap_at or api.nvim_win_get_width(0) + else + opts.wrap_at = nil + end - local float_option = M.make_floating_popup_options(width, height, opts) - local floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option) + -- TODO(lewis6991): These function assume the current window to determine options, + -- therefore it won't work for opts._update_win and the current window if the floating + -- window + local width, height = M._make_floating_popup_size(contents, opts) + local float_option = M.make_floating_popup_options(width, height, opts) + + floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option) + + api.nvim_buf_set_keymap( + floating_bufnr, + 'n', + 'q', + '<cmd>bdelete<cr>', + { silent = true, noremap = true, nowait = true } + ) + close_preview_autocmd(opts.close_events, floating_winnr, { floating_bufnr, bufnr }) + + -- save focus_id + if opts.focus_id then + api.nvim_win_set_var(floating_winnr, opts.focus_id, bufnr) + end + api.nvim_buf_set_var(bufnr, 'lsp_floating_preview', floating_winnr) + end if do_stylize then vim.wo[floating_winnr].conceallevel = 2 @@ -1656,25 +1623,11 @@ function M.open_floating_preview(contents, syntax, opts) vim.wo[floating_winnr].foldenable = false -- Disable folding. vim.wo[floating_winnr].wrap = opts.wrap -- Soft wrapping. vim.wo[floating_winnr].breakindent = true -- Slightly better list presentation. + vim.wo[floating_winnr].smoothscroll = true -- Scroll by screen-line instead of buffer-line. vim.bo[floating_bufnr].modifiable = false vim.bo[floating_bufnr].bufhidden = 'wipe' - api.nvim_buf_set_keymap( - floating_bufnr, - 'n', - 'q', - '<cmd>bdelete<cr>', - { silent = true, noremap = true, nowait = true } - ) - close_preview_autocmd(opts.close_events, floating_winnr, { floating_bufnr, bufnr }) - - -- save focus_id - if opts.focus_id then - api.nvim_win_set_var(floating_winnr, opts.focus_id, bufnr) - end - api.nvim_buf_set_var(bufnr, 'lsp_floating_preview', floating_winnr) - return floating_bufnr, floating_winnr end @@ -1683,9 +1636,8 @@ do --[[ References ]] --- Removes document highlights from a buffer. --- - ---@param bufnr integer|nil Buffer id + ---@param bufnr integer? Buffer id function M.buf_clear_references(bufnr) - validate({ bufnr = { bufnr, { 'n' }, true } }) api.nvim_buf_clear_namespace(bufnr or 0, reference_ns, 0, -1) end @@ -1693,29 +1645,18 @@ do --[[ References ]] --- ---@param bufnr integer Buffer id ---@param references lsp.DocumentHighlight[] objects to highlight - ---@param offset_encoding string One of "utf-8", "utf-16", "utf-32". + ---@param offset_encoding 'utf-8'|'utf-16'|'utf-32' ---@see https://microsoft.github.io/language-server-protocol/specification/#textDocumentContentChangeEvent function M.buf_highlight_references(bufnr, references, offset_encoding) - validate({ - bufnr = { bufnr, 'n', true }, - offset_encoding = { offset_encoding, 'string', false }, - }) + validate('bufnr', bufnr, 'number', true) + validate('offset_encoding', offset_encoding, 'string', false) for _, reference in ipairs(references) do - local start_line, start_char = - reference['range']['start']['line'], reference['range']['start']['character'] - local end_line, end_char = - reference['range']['end']['line'], reference['range']['end']['character'] + local range = reference.range + local start_line = range.start.line + local end_line = range['end'].line - local start_idx = get_line_byte_from_position( - bufnr, - { line = start_line, character = start_char }, - offset_encoding - ) - local end_idx = get_line_byte_from_position( - bufnr, - { line = start_line, character = end_char }, - offset_encoding - ) + local start_idx = get_line_byte_from_position(bufnr, range.start, offset_encoding) + local end_idx = get_line_byte_from_position(bufnr, range['end'], offset_encoding) local document_highlight_kind = { [protocol.DocumentHighlightKind.Text] = 'LspReferenceText', @@ -1723,13 +1664,13 @@ do --[[ References ]] [protocol.DocumentHighlightKind.Write] = 'LspReferenceWrite', } local kind = reference['kind'] or protocol.DocumentHighlightKind.Text - highlight.range( + vim.hl.range( bufnr, reference_ns, document_highlight_kind[kind], { start_line, start_idx }, { end_line, end_idx }, - { priority = vim.highlight.priorities.user } + { priority = vim.hl.priorities.user } ) end end @@ -1739,16 +1680,6 @@ local position_sort = sort_by_key(function(v) return { v.start.line, v.start.character } end) ----@class vim.lsp.util.locations_to_items.ret ----@inlinedoc ----@field filename string ----@field lnum integer 1-indexed line number ----@field end_lnum integer 1-indexed end line number ----@field col integer 1-indexed column ----@field end_col integer 1-indexed end column ----@field text string ----@field user_data lsp.Location|lsp.LocationLink - --- Returns the items with the byte position calculated correctly and in sorted --- order, for display in quickfix and location lists. --- @@ -1759,9 +1690,9 @@ end) --- |setloclist()|. --- ---@param locations lsp.Location[]|lsp.LocationLink[] ----@param offset_encoding string offset_encoding for locations utf-8|utf-16|utf-32 ---- default to first client of buffer ----@return vim.lsp.util.locations_to_items.ret[] +---@param offset_encoding? 'utf-8'|'utf-16'|'utf-32' +--- default to first client of buffer +---@return vim.quickfix.entry[] # See |setqflist()| for the format function M.locations_to_items(locations, offset_encoding) if offset_encoding == nil then vim.notify_once( @@ -1771,28 +1702,19 @@ function M.locations_to_items(locations, offset_encoding) offset_encoding = vim.lsp.get_clients({ bufnr = 0 })[1].offset_encoding end - local items = {} + local items = {} --- @type vim.quickfix.entry[] + ---@type table<string, {start: lsp.Position, end: lsp.Position, location: lsp.Location|lsp.LocationLink}[]> - local grouped = setmetatable({}, { - __index = function(t, k) - local v = {} - rawset(t, k, v) - return v - end, - }) + local grouped = {} 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 + grouped[uri] = grouped[uri] or {} table.insert(grouped[uri], { start = range.start, ['end'] = range['end'], location = d }) end - ---@type string[] - local keys = vim.tbl_keys(grouped) - table.sort(keys) - -- TODO(ashkan) I wish we could do this lazily. - for _, uri in ipairs(keys) do - local rows = grouped[uri] + for uri, rows in vim.spairs(grouped) do table.sort(rows, position_sort) local filename = vim.uri_to_fname(uri) @@ -1814,10 +1736,10 @@ function M.locations_to_items(locations, offset_encoding) local end_row = end_pos.line local line = lines[row] or '' local end_line = lines[end_row] or '' - local col = M._str_byteindex_enc(line, pos.character, offset_encoding) - local end_col = M._str_byteindex_enc(end_line, end_pos.character, offset_encoding) + local col = vim.str_byteindex(line, offset_encoding, pos.character, false) + local end_col = vim.str_byteindex(end_line, offset_encoding, end_pos.character, false) - table.insert(items, { + items[#items + 1] = { filename = filename, lnum = row + 1, end_lnum = end_row + 1, @@ -1825,58 +1747,51 @@ function M.locations_to_items(locations, offset_encoding) end_col = end_col + 1, text = line, user_data = temp.location, - }) + } end end return items end --- According to LSP spec, if the client set "symbolKind.valueSet", --- the client must handle it properly even if it receives a value outside the specification. --- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentSymbol -function M._get_symbol_kind_name(symbol_kind) - return protocol.SymbolKind[symbol_kind] or 'Unknown' -end - --- Converts symbols to quickfix list items. --- ----@param symbols table DocumentSymbol[] or SymbolInformation[] +---@param symbols lsp.DocumentSymbol[]|lsp.SymbolInformation[] ---@param bufnr? integer +---@return vim.quickfix.entry[] # See |setqflist()| for the format function M.symbols_to_items(symbols, bufnr) - local function _symbols_to_items(_symbols, _items, _bufnr) - for _, symbol in ipairs(_symbols) do - if symbol.location then -- SymbolInformation type - local range = symbol.location.range - local kind = M._get_symbol_kind_name(symbol.kind) - table.insert(_items, { - filename = vim.uri_to_fname(symbol.location.uri), - lnum = range.start.line + 1, - col = range.start.character + 1, - kind = kind, - text = '[' .. kind .. '] ' .. symbol.name, - }) - elseif symbol.selectionRange then -- DocumentSymbole type - local kind = M._get_symbol_kind_name(symbol.kind) - table.insert(_items, { - -- bufnr = _bufnr, - filename = api.nvim_buf_get_name(_bufnr), - lnum = symbol.selectionRange.start.line + 1, - col = symbol.selectionRange.start.character + 1, - kind = kind, - text = '[' .. kind .. '] ' .. symbol.name, - }) - if symbol.children then - for _, v in ipairs(_symbols_to_items(symbol.children, _items, _bufnr)) do - for _, s in ipairs(v) do - table.insert(_items, s) - end - end - end - end + bufnr = bufnr or 0 + local items = {} --- @type vim.quickfix.entry[] + for _, symbol in ipairs(symbols) do + --- @type string?, lsp.Position? + local filename, pos + + if symbol.location then + --- @cast symbol lsp.SymbolInformation + filename = vim.uri_to_fname(symbol.location.uri) + pos = symbol.location.range.start + elseif symbol.selectionRange then + --- @cast symbol lsp.DocumentSymbol + filename = api.nvim_buf_get_name(bufnr) + pos = symbol.selectionRange.start + end + + if filename and pos then + local kind = protocol.SymbolKind[symbol.kind] or 'Unknown' + items[#items + 1] = { + filename = filename, + lnum = pos.line + 1, + col = pos.character + 1, + kind = kind, + text = '[' .. kind .. '] ' .. symbol.name, + } + end + + if symbol.children then + list_extend(items, M.symbols_to_items(symbol.children, bufnr)) end - return _items end - return _symbols_to_items(symbols, {}, bufnr or 0) + + return items end --- Removes empty lines from the beginning and end. @@ -1899,7 +1814,7 @@ function M.trim_empty_lines(lines) break end end - return list_extend({}, lines, start, finish) + return vim.list_slice(lines, start, finish) end --- Accepts markdown lines and tries to reduce them to a filetype if they @@ -1932,8 +1847,8 @@ function M.try_trim_markdown_code_blocks(lines) return 'markdown' end ----@param window integer|nil: window handle or 0 for current, defaults to current ----@param offset_encoding? string utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window` +---@param window integer?: window handle or 0 for current, defaults to current +---@param offset_encoding? 'utf-8'|'utf-16'|'utf-32'? defaults to `offset_encoding` of first client of buffer of `window` local function make_position_param(window, offset_encoding) window = window or 0 local buf = api.nvim_win_get_buf(window) @@ -1945,15 +1860,15 @@ local function make_position_param(window, offset_encoding) return { line = 0, character = 0 } end - col = M._str_utfindex_enc(line, col, offset_encoding) + col = vim.str_utfindex(line, offset_encoding, col, false) return { line = row, character = col } end --- Creates a `TextDocumentPositionParams` object for the current buffer and cursor position. --- ----@param window integer|nil: window handle or 0 for current, defaults to current ----@param offset_encoding string|nil utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window` +---@param window integer?: window handle or 0 for current, defaults to current +---@param offset_encoding 'utf-8'|'utf-16'|'utf-32'? defaults to `offset_encoding` of first client of buffer of `window` ---@return lsp.TextDocumentPositionParams ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams function M.make_position_params(window, offset_encoding) @@ -1970,11 +1885,9 @@ end ---@param bufnr integer buffer handle or 0 for current, defaults to current ---@return string encoding first client if there is one, nil otherwise function M._get_offset_encoding(bufnr) - validate({ - bufnr = { bufnr, 'n', true }, - }) + validate('bufnr', bufnr, 'number', true) - local offset_encoding + local offset_encoding --- @type 'utf-8'|'utf-16'|'utf-32'? for _, client in pairs(vim.lsp.get_clients({ bufnr = bufnr })) do if client.offset_encoding == nil then @@ -2005,8 +1918,8 @@ end --- `textDocument/codeAction`, `textDocument/colorPresentation`, --- `textDocument/rangeFormatting`. --- ----@param window integer|nil: window handle or 0 for current, defaults to current ----@param offset_encoding "utf-8"|"utf-16"|"utf-32"|nil defaults to `offset_encoding` of first client of buffer of `window` +---@param window integer? window handle or 0 for current, defaults to current +---@param offset_encoding "utf-8"|"utf-16"|"utf-32"? defaults to `offset_encoding` of first client of buffer of `window` ---@return table { textDocument = { uri = `current_file_uri` }, range = { start = ---`current_position`, end = `current_position` } } function M.make_range_params(window, offset_encoding) @@ -2022,33 +1935,33 @@ end --- Using the given range in the current buffer, creates an object that --- is similar to |vim.lsp.util.make_range_params()|. --- ----@param start_pos integer[]|nil {row,col} mark-indexed position. +---@param start_pos [integer,integer]? {row,col} mark-indexed position. --- Defaults to the start of the last visual selection. ----@param end_pos integer[]|nil {row,col} mark-indexed position. +---@param end_pos [integer,integer]? {row,col} mark-indexed position. --- Defaults to the end of the last visual selection. ----@param bufnr integer|nil buffer handle or 0 for current, defaults to current ----@param offset_encoding "utf-8"|"utf-16"|"utf-32"|nil defaults to `offset_encoding` of first client of `bufnr` +---@param bufnr integer? buffer handle or 0 for current, defaults to current +---@param offset_encoding 'utf-8'|'utf-16'|'utf-32'? defaults to `offset_encoding` of first client of `bufnr` ---@return table { textDocument = { uri = `current_file_uri` }, range = { start = ---`start_position`, end = `end_position` } } function M.make_given_range_params(start_pos, end_pos, bufnr, offset_encoding) - validate({ - start_pos = { start_pos, 't', true }, - end_pos = { end_pos, 't', true }, - offset_encoding = { offset_encoding, 's', true }, - }) + validate('start_pos', start_pos, 'table', true) + validate('end_pos', end_pos, 'table', true) + validate('offset_encoding', offset_encoding, 'string', true) bufnr = bufnr or api.nvim_get_current_buf() offset_encoding = offset_encoding or M._get_offset_encoding(bufnr) - local A = list_extend({}, start_pos or api.nvim_buf_get_mark(bufnr, '<')) - local B = list_extend({}, end_pos or api.nvim_buf_get_mark(bufnr, '>')) + --- @type [integer, integer] + local A = { unpack(start_pos or api.nvim_buf_get_mark(bufnr, '<')) } + --- @type [integer, integer] + local B = { unpack(end_pos or api.nvim_buf_get_mark(bufnr, '>')) } -- convert to 0-index A[1] = A[1] - 1 B[1] = B[1] - 1 -- account for offset_encoding. if A[2] > 0 then - A = { A[1], M.character_offset(bufnr, A[1], A[2], offset_encoding) } + A[2] = M.character_offset(bufnr, A[1], A[2], offset_encoding) end if B[2] > 0 then - B = { B[1], M.character_offset(bufnr, B[1], B[2], offset_encoding) } + B[2] = M.character_offset(bufnr, B[1], B[2], offset_encoding) end -- we need to offset the end character position otherwise we loose the last -- character of the selection, as LSP end position is exclusive @@ -2067,7 +1980,7 @@ end --- Creates a `TextDocumentIdentifier` object for the current buffer. --- ----@param bufnr integer|nil: Buffer handle, defaults to current +---@param bufnr integer?: Buffer handle, defaults to current ---@return lsp.TextDocumentIdentifier ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentIdentifier function M.make_text_document_params(bufnr) @@ -2085,10 +1998,10 @@ end --- Returns indentation size. --- ---@see 'shiftwidth' ----@param bufnr integer|nil: Buffer handle, defaults to current +---@param bufnr integer?: Buffer handle, defaults to current ---@return integer indentation size function M.get_effective_tabstop(bufnr) - validate({ bufnr = { bufnr, 'n', true } }) + validate('bufnr', bufnr, 'number', true) local bo = bufnr and vim.bo[bufnr] or vim.bo local sw = bo.shiftwidth return (sw == 0 and bo.tabstop) or sw @@ -2096,11 +2009,11 @@ end --- Creates a `DocumentFormattingParams` object for the current buffer and cursor position. --- ----@param options lsp.FormattingOptions|nil with valid `FormattingOptions` entries +---@param options lsp.FormattingOptions? with valid `FormattingOptions` entries ---@return lsp.DocumentFormattingParams object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting function M.make_formatting_params(options) - validate({ options = { options, 't', true } }) + validate('options', options, 'table', true) options = vim.tbl_extend('keep', options or {}, { tabSize = M.get_effective_tabstop(), insertSpaces = vim.bo.expandtab, @@ -2116,7 +2029,8 @@ end ---@param buf integer buffer number (0 for current) ---@param row integer 0-indexed line ---@param col integer 0-indexed byte offset in line ----@param offset_encoding string utf-8|utf-16|utf-32 defaults to `offset_encoding` of first client of `buf` +---@param offset_encoding? 'utf-8'|'utf-16'|'utf-32' +--- defaults to `offset_encoding` of first client of `buf` ---@return integer `offset_encoding` index of the character in line {row} column {col} in buffer {buf} function M.character_offset(buf, row, col, offset_encoding) local line = get_line(buf, row) @@ -2127,7 +2041,7 @@ function M.character_offset(buf, row, col, offset_encoding) ) offset_encoding = vim.lsp.get_clients({ bufnr = buf })[1].offset_encoding end - return M._str_utfindex_enc(line, col, offset_encoding) + return vim.str_utfindex(line, offset_encoding, col, false) end --- Helper function to return nested values in language server settings @@ -2139,6 +2053,7 @@ end function M.lookup_section(settings, section) vim.deprecate('vim.lsp.util.lookup_section()', 'vim.tbl_get() with `vim.split`', '0.12') for part in vim.gsplit(section, '.', { plain = true }) do + --- @diagnostic disable-next-line:no-unknown settings = settings[part] if settings == nil then return vim.NIL @@ -2153,7 +2068,7 @@ end ---@param bufnr integer ---@param start_line integer ---@param end_line integer ----@param offset_encoding lsp.PositionEncodingKind +---@param offset_encoding 'utf-8'|'utf-16'|'utf-32' ---@return lsp.Range local function make_line_range_params(bufnr, start_line, end_line, offset_encoding) local last_line = api.nvim_buf_line_count(bufnr) - 1 @@ -2161,7 +2076,7 @@ local function make_line_range_params(bufnr, start_line, end_line, offset_encodi ---@type lsp.Position local end_pos - if end_line == last_line and not vim.api.nvim_get_option_value('endofline', { buf = bufnr }) then + if end_line == last_line and not vim.bo[bufnr].endofline then end_pos = { line = end_line, character = M.character_offset(bufnr, end_line, #get_line(bufnr, end_line), offset_encoding), @@ -2201,9 +2116,7 @@ function M._refresh(method, opts) local textDocument = M.make_text_document_params(bufnr) - local only_visible = opts.only_visible or false - - if only_visible then + if opts.only_visible then for _, window in ipairs(api.nvim_list_wins()) do if api.nvim_win_get_buf(window) == bufnr then local first = vim.fn.line('w0', window) |